z在开始之前我们先讨论一下为什么rt-thread中很多对象跟接口都分成了静态跟动态两套

RT-Thread 中将很多对象和接口设计成静态和动态两种形式,主要是为了在灵活性和确定性之间取得平衡,以适应不同应用场景的需求。

1. 静态对象的优势:确定性与效率

静态对象在编译时就确定了内存分配,通常存储在全局或静态数据区。这种设计带来了以下优势:

  • 无运行时开销:静态对象的创建和初始化不涉及动态内存分配(malloc),因此没有堆操作的开销,这对于对实时性要求极高的任务非常重要。
  • 无内存碎片:由于不使用动态堆内存,可以从根本上避免内存碎片问题。在长期运行且频繁创建/销毁对象的系统中,内存碎片可能导致后续的动态内存分配失败。
  • 更高的确定性:内存地址在编译时就已固定,不会在运行时发生变化。这使得系统行为更可预测,特别是在资源受限或需要硬实时保证的系统中。
  • 无需销毁:静态对象的生命周期与程序相同,通常不需要显式地调用销毁函数,简化了资源管理。

适用场景:适用于那些数量固定、生命周期贯穿整个程序运行周期的对象,例如:

  • 系统核心线程:如空闲线程、定时器线程等。
  • 固定数量的设备对象:如串口、I2C 总线等。
  • 全局的信号量或互斥量:用于保护全局共享资源。

2. 动态对象的优势:灵活性与可扩展性

动态对象在程序运行时根据需要从堆内存中分配。这种设计带来了以下优势:

  • 高度灵活:可以根据程序运行时的情况动态地创建和销毁对象。例如,可以根据网络连接的数量动态创建线程或定时器。
  • 更好的可扩展性:系统的对象数量不再受编译时的限制。当需要处理更多任务时,可以随时创建更多线程、信号量或消息队列。
  • 高效利用内存:动态对象在不再需要时可以释放内存,将其归还给系统堆,供其他对象使用。这避免了静态对象长期占用内存造成的浪费。

适用场景:适用于那些数量和生命周期不确定、与特定任务绑定的对象,例如:

  • 网络连接处理线程:为每个新连接创建一个线程。
  • 临时的定时器:在某个任务中创建,超时后自动销毁。
  • 动态创建的消息队列:用于两个临时线程间的通信。

总结

静态和动态两种设计方式是RT-Thread为了在不同场景下优化系统性能和资源利用率而做出的选择。

  • 静态方式提供了确定性、高效和无碎片的优势,是嵌入式系统特别是硬实时系统的首选。
  • 动态方式提供了灵活性和可扩展性,使得系统能够适应复杂且多变的应用场景。

开发者可以根据具体需求,在两种模式中灵活选择,从而更好地平衡系统的性能、稳定性和资源利用。


信号量跟互斥量是为了解决竞态条件而出现的方法。

关于竞态条件,ai给了我们这样的回答

竞态条件(Race Condition)是多线程或多进程并发编程中的一个核心概念,指的是当多个线程或进程并发访问和修改同一个共享资源时,最终的结果取决于这些线程的执行顺序,而这个顺序是不可预测的

简单来说,就是“谁先谁后”不确定,导致最后的结果也不确定。

竞态条件的产生

竞态条件通常发生在以下场景:

  1. 共享资源:多个线程同时访问同一个内存地址、变量、文件、数据库记录等。

  2. 非原子性操作:对共享资源的操作不是一个单一、不可中断的步骤。例如,i++ 这个简单的操作实际上分为三个步骤:

    • 读取 i 的值。
    • i 加 1。
    • 将结果写回 i

    如果两个线程同时执行 i++,它们可能会在上述三个步骤中的任何一个地方被打断,导致最终结果错误。


信号量(Semaphore)

信号量本质上是一个计数器。它允许多个线程在同一时刻访问共享资源,但会限制同时访问的线程数量。

  • 工作原理:
    • 信号量有一个初始计数值(通常大于等于1)。
    • 当一个线程想要访问资源时,它会执行一个 P 操作(或 wait/take)。如果信号量计数器大于0,就将其减1,然后线程继续执行。
    • 如果计数器等于0,线程就会被阻塞,直到有其他线程释放信号量。
    • 当一个线程完成对资源的访问后,它会执行一个 V 操作(或 signal/release),将信号量计数器加1,并唤醒一个被阻塞的线程。
  • 用途:
    • 资源计数: 控制对有限资源的访问,比如限制同时下载的线程数量、控制对打印机的访问等。
    • 线程同步: 确保某个任务在另一个任务完成之前不会执行。

互斥量(Mutex)

互斥量是一种特殊的信号量,它的计数器只有两个值:0 和 1。它专门用于实现互斥,即在任何时刻只允许一个线程访问共享资源

  • 工作原理:
    • 互斥量是一个二值信号量,初始值为1。
    • 当一个线程想要访问资源时,它会执行 lock 操作。如果互斥量可用(值为1),它就会获取锁,将值变为0,然后继续执行。
    • 如果互斥量被其他线程占用(值为0),该线程就会被阻塞。
    • 当线程完成访问后,它会执行 unlock 操作,释放锁,将值重新设为1,并唤醒一个被阻塞的线程。
  • 用途:
    • 独占式访问: 保护共享数据、全局变量、文件等,防止多个线程同时修改导致数据不一致。

解决了什么问题?

它们共同解决了多任务并发编程中的**“竞态条件”(Race Condition)**问题。

当多个线程同时访问和修改同一个共享资源时,由于线程的执行顺序不确定,最终结果可能与预期不符,导致数据损坏或程序崩溃。信号量和互斥量通过强制线程按照一定的规则(独占或受限并发)访问资源,消除了这种不确定性,保证了数据的完整性和程序的正确性。

信号量与互斥量的关系和区别

特性 互斥量 (Mutex) 信号量 (Semaphore)
本质 二值信号量(锁) 计数器
目的 独占式访问,保护共享资源不被同时修改 控制并发,允许多个线程同时访问,但有数量限制
所有权 线程拥有,只有获取锁的线程才能释放锁 无所有权,任何线程都可以释放信号量
使用场景 保护临界区,避免多个线程同时读写同一段内存 资源管理、任务同步、生产者-消费者问题等

可以这样简单理解:

  • 互斥量就像一把钥匙,只有拥有钥匙的人(获取锁的线程)才能进入房间(访问资源)。而且这把钥匙是独一无二的,房间里只能有一个人。
  • 信号量则更像是一个房间里的椅子数量。当你想进入房间时,如果还有空椅子(计数器大于0),你就可以进去。但当椅子都满了(计数器等于0),你就必须在门口等待。离开时,你带走一把椅子,其他人就可以进来了。

可以通过互斥量跟信号量配合来防止多个线程同时修改一个数据。

前段时间学习Rust,发现有这么一个机制同时只能存在对数据的一个可变引用或者若干个不可变引用,这个机制也是为了解决竞态,不过是从根源上禁止你写出在同时在多个地方修改同一个数据的情况。

接下来我们接着看官方文档

1. 线程同步与线程互斥

  • 线程同步:指的是多个线程按照预定的先后次序来执行。它是一种广义的概念,目的是在线程之间建立起一种执行顺序或依赖关系。
    • 例子:一个线程必须等待另一个线程完成某个任务后才能开始自己的任务。
  • 线程互斥:指的是对共享资源(临界区)的排他性访问。这是线程同步的一种特殊且非常重要的形式。在任何时候,最多只允许一个线程访问临界区,其他线程必须等待。
    • 例子:你描述的共享内存访问,在写操作完成前,读操作不能开始;在读操作完成前,写操作也不能开始。

2. 临界区(Critical Section)

临界区是共享资源的载体,它指的是一段代码,这段代码用于访问或操作共享资源。在你的例子中,共享内存块就是临界区。

为了防止数据错乱,所有对临界区的访问都必须是排他性的。只有当一个线程完成了对临界区的操作,另一个线程才能进入。

3. 同步方式

RT-Thread提供了多种同步方式,其核心思想都是为了保护临界区,确保在任何时刻只有一个(或一类)线程能访问。

  • 全局中断开关
    • rt_hw_interrupt_disable():禁用全局中断,进入临界区。
    • rt_hw_interrupt_enable():启用全局中断,退出临界区。
    • 作用:通过关闭中断,可以防止线程切换,从而确保临界区的代码是不可中断地执行。
    • 注意:这种方式会导致整个系统被阻塞,因此只适用于执行时间极短的临界区。
  • 临界区保护
    • rt_enter_critical():进入临界区。
    • rt_exit_critical():退出临界区。
    • 作用:与全局中断开关类似,它通过禁用调度来保护临界区。线程进入临界区后,即使有更高优先级的线程就绪,调度器也不会进行切换。
  • 内核同步对象
    • 信号量(Semaphore):一个计数器,可以控制并发访问的数量。它不提供所有权,任何线程都可以释放信号量。
    • 互斥量(Mutex):一种特殊的信号量,用于实现独占式访问。它具有所有权属性,只有获取互斥量的线程才能释放它。
    • 事件集(Event):一种更高级的同步机制,允许一个线程等待多个事件的发生。

总结

这段话的核心观点是:在多线程协作中,为了避免因共享资源访问导致的错误(如数据不一致),我们必须使用线程同步互斥机制来保护临界区。RT-Thread提供了多种实现方式,从简单的中断开关到更复杂的信号量和互斥量,开发者需要根据具体场景选择最合适的工具。

上文中提到了3种同步线程的方法,三种方法适用的情况不太一样,一般来说第三种方法使用的最多

信号量工作机制

信号量是一种轻型的用于解决线程间同步问题的内核对象,线程可以获取或释放它,从而达到同步或互斥的目的。

信号量工作示意图如下图所示,每个信号量对象都有一个信号量值和一个线程等待队列,信号量的值对应了信号量对象的实例数目、资源数目,假如信号量值为 5,则表示共有 5 个信号量实例(资源)可以被使用,当信号量实例数目为零时,再申请该信号量的线程就会被挂起在该信号量的等待队列上,等待可用的信号量实例(资源)。

一个信号量对象主要由两部分构成:

  • 信号量值(Semaphore Value):这是一个非负整数,可以把它看作是可用资源的数量可同时访问的线程数。这个值在信号量被创建时设定,比如你例子中的5
  • 线程等待队列(Thread Waiting Queue):这是一个队列,当信号量的值为零时,所有试图获取该信号量的线程都会被挂起(阻塞),并排队等候。

信号量工作示意图

信号量控制块

在 RT-Thread 中,信号量控制块是操作系统用于管理信号量的一个数据结构,由结构体 struct rt_semaphore 表示。另外一种 C 表达方式 rt_sem_t,表示的是信号量的句柄,在 C 语言中的实现是指向信号量控制块的指针。信号量控制块结构的详细定义如下:

1
2
3
4
5
6
7
struct rt_semaphore
{
struct rt_ipc_object parent; /* 继承自 ipc_object 类 */
rt_uint16_t value; /* 信号量的值 */
};
/* rt_sem_t 是指向 semaphore 结构体的指针类型 */
typedef struct rt_semaphore* rt_sem_t;

rt_semaphore 对象从 rt_ipc_object 中派生,由 IPC 容器所管理,信号量的最大值是 65535(即是uint16_t的最大值)。

信号量的管理方式

信号量控制块中含有信号量相关的重要参数,在信号量各种状态间起到纽带的作用。信号量相关接口如下图所示,对一个信号量的操作包含:创建 / 初始化信号量、获取信号量、释放信号量、删除 / 脱离信号量。

信号量相关接口

创建和删除信号量

当创建一个信号量时,内核首先创建一个信号量控制块,然后对该控制块进行基本的初始化工作,创建信号量使用下面的函数接口:

1
2
3
rt_sem_t rt_sem_create(const char *name,
rt_uint32_t value,
rt_uint8_t flag);

当调用这个函数时,系统将先从对象管理器中分配一个 semaphore 对象,并初始化这个对象,然后初始化父类 IPC 对象以及与 semaphore 相关的部分。在创建信号量指定的参数中,信号量标志参数决定了当信号量不可用时,多个线程等待的排队方式。当选择 RT_IPC_FLAG_FIFO(先进先出)方式时,那么等待线程队列将按照先进先出的方式排队,先进入的线程将先获得等待的信号量;当选择 RT_IPC_FLAG_PRIO(优先级等待)方式时,等待线程队列将按照优先级进行排队,优先级高的等待线程将先获得等待的信号量。

注:RT_IPC_FLAG_FIFO 属于非实时调度方式,除非应用程序非常在意先来后到,并且你清楚地明白所有涉及到该信号量的线程都将会变为非实时线程,方可使用 RT_IPC_FLAG_FIFO,否则建议采用 RT_IPC_FLAG_PRIO,即确保线程的实时性。

1. FIFO(先进先出)排队方式

  • 工作原理:当线程因无法获取信号量而进入等待队列时,它们会按照进入的先后顺序进行排队。第一个进入等待队列的线程,会排在队首;第二个进入的,排在第二个位置,以此类推。
  • 唤醒顺序:当信号量被释放时,系统会唤醒等待队列中最早进入的那个线程
  • 特点:这种方式保证了公平性。无论线程的优先级高低,只要先进入等待队列,就会先被唤醒。
  • 适用场景:适用于对公平性有要求的场景,可以防止高优先级线程“饿死”低优先级线程。

2. PRIO(优先级等待)排队方式

  • 工作原理:当线程因无法获取信号量而进入等待队列时,它们会根据自身的优先级进行排队。等待队列将始终按照优先级从高到低进行排序。
  • 唤醒顺序:当信号量被释放时,系统会唤醒等待队列中优先级最高的那个线程
  • 特点:这种方式优先满足高优先级任务的需求。即使一个高优先级线程进入队列较晚,它也能立即被排到队首,并优先被唤醒。
  • 适用场景:适用于对实时性要求较高的系统。当一个关键任务需要获取资源时,它可以插队,确保及时响应。

下表描述了该函数的输入参数与返回值:

参数 描述
name 信号量名称
value 信号量初始值
flag 信号量标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO
返回 ——
RT_NULL 创建失败
信号量的控制块指针 创建成功

系统不再使用信号量时,可通过删除信号量以释放系统资源,适用于动态创建的信号量。删除信号量使用下面的函数接口:

1
rt_err_t rt_sem_delete(rt_sem_t sem);

调用这个函数时,系统将删除这个信号量。如果删除该信号量时,有线程正在等待该信号量,那么删除操作会先唤醒等待在该信号量上的线程(等待线程的返回值是 - RT_ERROR),然后再释放信号量的内存资源。下表描述了该函数的输入参数与返回值:

参数 描述
sem rt_sem_create() 创建的信号量对象
返回 ——
RT_EOK 删除成功

初始化和脱离信号量

对于静态信号量对象,它的内存空间在编译时期就被编译器分配出来,放在读写数据段或未初始化数据段上,此时使用信号量就不再需要使用 rt_sem_create 接口来创建它,而只需在使用前对它进行初始化即可。初始化信号量对象可使用下面的函数接口:

1
2
3
4
rt_err_t rt_sem_init(rt_sem_t       sem,
const char *name,
rt_uint32_t value,
rt_uint8_t flag);

当调用这个函数时,系统将对这个 semaphore 对象进行初始化,然后初始化 IPC 对象以及与 semaphore 相关的部分。信号量标志可用上面创建信号量函数里提到的标志。下表描述了该函数的输入参数与返回值:

参数 描述
sem 信号量对象的句柄
name 信号量名称
value 信号量初始值
flag 信号量标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO
返回 ——
RT_EOK 初始化成功

脱离信号量就是让信号量对象从内核对象管理器中脱离,适用于静态初始化的信号量。脱离信号量使用下面的函数接口:

1
rt_err_t rt_sem_detach(rt_sem_t sem);

使用该函数后,内核先唤醒所有挂在该信号量等待队列上的线程,然后将该信号量从内核对象管理器中脱离。原来挂起在信号量上的等待线程将获得 - RT_ERROR 的返回值。下表描述了该函数的输入参数与返回值:

参数 描述
sem 信号量对象的句柄
返回 ——
RT_EOK 脱离成功

获取信号量

线程通过获取信号量来获得信号量资源实例,当信号量值大于零时,线程将获得信号量,并且相应的信号量值会减 1,获取信号量使用下面的函数接口:

1
rt_err_t rt_sem_take (rt_sem_t sem, rt_int32_t time);

在调用这个函数时,如果信号量的值等于零,那么说明当前信号量资源实例不可用,申请该信号量的线程将根据 time 参数的情况选择直接返回、或挂起等待一段时间、或永久等待,直到其他线程或中断释放该信号量。如果在参数 time 指定的时间内依然得不到信号量,线程将超时返回,返回值是 - RT_ETIMEOUT。下表描述了该函数的输入参数与返回值:

参数 描述
sem 信号量对象的句柄
time 指定的等待时间,单位是操作系统时钟节拍(OS Tick)
返回 ——
RT_EOK 成功获得信号量
-RT_ETIMEOUT 超时依然未获得信号量
-RT_ERROR 其他错误

无等待获取信号量

当用户不想在申请的信号量上挂起线程进行等待时,可以使用无等待方式获取信号量,无等待获取信号量使用下面的函数接口:

1
rt_err_t rt_sem_trytake(rt_sem_t sem);

这个函数与 rt_sem_take(sem, RT_WAITING_NO) 的作用相同,即当线程申请的信号量资源实例不可用的时候,它不会等待在该信号量上,而是直接返回 - RT_ETIMEOUT。下表描述了该函数的输入参数与返回值:

参数 描述
sem 信号量对象的句柄
返回 ——
RT_EOK 成功获得信号量
-RT_ETIMEOUT 获取失败

释放信号量

释放信号量可以唤醒挂起在该信号量上的线程。释放信号量使用下面的函数接口:

1
rt_err_t rt_sem_release(rt_sem_t sem);

例如当信号量的值等于零时,并且有线程等待这个信号量时,释放信号量将唤醒等待在该信号量线程队列中的第一个线程,由它获取信号量;否则将把信号量的值加 1。下表描述了该函数的输入参数与返回值:

参数 描述
sem 信号量对象的句柄
返回 ——
RT_EOK 成功释放信号量

信号量应用示例

这是一个信号量使用例程,该例程创建了一个动态信号量,初始化两个线程,一个线程发送信号量,一个线程接收到信号量后,执行相应的操作。如下代码所示:

注意:RT-Thread 5.0 及更高的版本将 ALIGN 关键字改成了 rt_align,使用时注意修改。

信号量的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <rtthread.h>

#define THREAD_PRIORITY 25
#define THREAD_TIMESLICE 5

/* 信号量退出标志 */
static rt_bool_t sem_flag = 0;
/* 指向信号量的指针 */
static rt_sem_t dynamic_sem = RT_NULL;

ALIGN(RT_ALIGN_SIZE)
static char thread1_stack[1024];
static struct rt_thread thread1;
static void rt_thread1_entry(void *parameter)
{
static rt_uint8_t count = 0;

while (1)
{
if (count <= 100)
{
count++;
}
else
{
rt_kprintf("thread1 exiting...\n");
sem_flag = 1;
rt_sem_release(dynamic_sem);
count = 0;
return;
}

/* count 每计数 10 次,就释放一次信号量 */
if (0 == (count % 10))
{
rt_kprintf("t1 release a dynamic semaphore.\n");
rt_sem_release(dynamic_sem);
}
}
}

ALIGN(RT_ALIGN_SIZE)
static char thread2_stack[1024];
static struct rt_thread thread2;
static void rt_thread2_entry(void *parameter)
{
static rt_err_t result;
static rt_uint8_t number = 0;
while (1)
{
/* 永久方式等待信号量,获取到信号量,则执行 number 自加的操作 */
result = rt_sem_take(dynamic_sem, RT_WAITING_FOREVER);
if (sem_flag && result == RT_EOK)
{
rt_kprintf("thread2 exiting...\n");
rt_sem_delete(dynamic_sem);
sem_flag = 0;
number = 0;
return;
}
else
{
number++;
rt_kprintf("t2 take a dynamic semaphore. number = %d\n", number);
}
}
}

/* 信号量示例的初始化 */
int semaphore_sample(void)
{
/* 创建一个动态信号量,初始值是 0 */
dynamic_sem = rt_sem_create("dsem", 0, RT_IPC_FLAG_PRIO);
if (dynamic_sem == RT_NULL)
{
rt_kprintf("create dynamic semaphore failed.\n");
return -1;
}
else
{
rt_kprintf("create done. dynamic semaphore value = 0.\n");
}

rt_thread_init(&thread1,
"thread1",
rt_thread1_entry,
RT_NULL,
&thread1_stack[0],
sizeof(thread1_stack),
THREAD_PRIORITY, THREAD_TIMESLICE);
rt_thread_startup(&thread1);

rt_thread_init(&thread2,
"thread2",
rt_thread2_entry,
RT_NULL,
&thread2_stack[0],
sizeof(thread2_stack),
THREAD_PRIORITY - 1, THREAD_TIMESLICE);
rt_thread_startup(&thread2);

return 0;
}
/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(semaphore_sample, semaphore sample);

仿真运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 \ | /
- RT - Thread Operating System
/ | \ 4.1.1 build Sep 2 2024 14:52:06
2006 - 2022 Copyright by RT-Thread team
msh >semaphore_sample
create done. dynamic semaphore value = 0.
msh >thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 1
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 2
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 3
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 4
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 5
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 6
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 7
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 8
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 9
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 10
thread1 exiting...
thread2 exiting...

msh >semaphore_sample
create done. dynamic semaphore value = 0.
msh >thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 1
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 2
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 3
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 4
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 5
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 6
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 7
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 8
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 9
thread1 release a dynamic semaphore.
thread2 take a dynamic semaphore. number = 10
thread1 exiting...
thread2 exiting...

如上面运行结果:线程 1 在 count 计数为 10 的倍数时(count 计数为 100 之后线程退出),发送一个信号量,线程 2 在接收信号量后,对 number 进行加 1 操作。

信号量跟标志位叠加使用

信号量的另一个应用例程如下所示,本例程将使用 2 个线程、3 个信号量实现生产者与消费者的例子。其中:

3 个信号量分别为:①lock:信号量锁的作用,因为 2 个线程都会对同一个数组 array 进行操作,所以该数组是一个共享资源,锁用来保护这个共享资源。②empty:空位个数,初始化为 5 个空位。③full:满位个数,初始化为 0 个满位。

2 个线程分别为:①生产者线程:获取到空位后,产生一个数字,循环放入数组中,然后释放一个满位。②消费者线程:获取到满位后,读取数组内容并相加,然后释放一个空位。

生产者消费者例程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <rtthread.h>

#define THREAD_PRIORITY 6
#define THREAD_STACK_SIZE 512
#define THREAD_TIMESLICE 5

/* 定义最大 5 个元素能够被产生 */
#define MAXSEM 5

/* 用于放置生产的整数数组 */
rt_uint32_t array[MAXSEM];

/* 指向生产者、消费者在 array 数组中的读写位置 */
static rt_uint32_t set, get;

/* 指向线程控制块的指针 */
static rt_thread_t producer_tid = RT_NULL;
static rt_thread_t consumer_tid = RT_NULL;

struct rt_semaphore sem_lock;
struct rt_semaphore sem_empty, sem_full;

/* 生产者线程入口 */
void producer_thread_entry(void *parameter)
{
int cnt = 0;

/* 运行 10 次 */
while (cnt < 10)
{
/* 获取一个空位 */
rt_sem_take(&sem_empty, RT_WAITING_FOREVER);

/* 修改 array 内容,上锁 */
rt_sem_take(&sem_lock, RT_WAITING_FOREVER);
array[set % MAXSEM] = cnt + 1;
rt_kprintf("the producer generates a number: %d\n", array[set % MAXSEM]);
set++;
rt_sem_release(&sem_lock);

/* 发布一个满位 */
rt_sem_release(&sem_full);
cnt++;

/* 暂停一段时间 */
rt_thread_mdelay(20);
}

rt_kprintf("the producer exit!\n");
cnt = 0;
}

/* 消费者线程入口 */
void consumer_thread_entry(void *parameter)
{
rt_uint32_t sum = 0;

while (1)
{
/* 获取一个满位 */
rt_sem_take(&sem_full, RT_WAITING_FOREVER);

/* 临界区,上锁进行操作 */
rt_sem_take(&sem_lock, RT_WAITING_FOREVER);
sum += array[get % MAXSEM];
rt_kprintf("the consumer[%d] get a number: %d\n", (get % MAXSEM), array[get % MAXSEM]);
get++;
rt_sem_release(&sem_lock);

/* 释放一个空位 */
rt_sem_release(&sem_empty);

/* 生产者生产到 10 个数目,停止,消费者线程相应停止 */
if (get == 10) break;

/* 暂停一小会时间 */
rt_thread_mdelay(50);
}

rt_kprintf("the consumer sum is: %d\n", sum);
rt_kprintf("the consumer exit!\n");
rt_sem_detach(&sem_lock);
rt_sem_detach(&sem_empty);
rt_sem_detach(&sem_full);
sum = 0;
}

int producer_consumer(void)
{
set = 0;
get = 0;

/* 初始化 3 个信号量 */
rt_sem_init(&sem_lock, "lock", 1, RT_IPC_FLAG_PRIO);
rt_sem_init(&sem_empty, "empty", MAXSEM, RT_IPC_FLAG_PRIO);
rt_sem_init(&sem_full, "full", 0, RT_IPC_FLAG_PRIO);

/* 创建生产者线程 */
producer_tid = rt_thread_create("producer",
producer_thread_entry, RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY - 1,
THREAD_TIMESLICE);
if (producer_tid != RT_NULL)
{
rt_thread_startup(producer_tid);
}
else
{
rt_kprintf("create thread producer failed");
return -1;
}

/* 创建消费者线程 */
consumer_tid = rt_thread_create("consumer",
consumer_thread_entry, RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY + 1,
THREAD_TIMESLICE);
if (consumer_tid != RT_NULL)
{
rt_thread_startup(consumer_tid);
}
else
{
rt_kprintf("create thread consumer failed");
return -1;
}

return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(producer_consumer, producer_consumer sample);

该例程的仿真结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 \ | /
- RT - Thread Operating System
/ | \ 4.1.1 build Sep 2 2024 18:24:30
2006 - 2022 Copyright by RT-Thread team
msh >producer_consumer
the producer generates a number: 1
the consumer[0] get a number: 1
msh >the producer generates a number: 2
the producer generates a number: 3
the consumer[1] get a number: 2
the producer generates a number: 4
the producer generates a number: 5
the consumer[2] get a number: 3
the producer generates a number: 6
the producer generates a number: 7
the producer generates a number: 8
the consumer[3] get a number: 4
the producer generates a number: 9
the consumer[4] get a number: 5
the producer generates a number: 10
the producer exit!
the consumer[0] get a number: 6
the consumer[1] get a number: 7
the consumer[2] get a number: 8
the consumer[3] get a number: 9
the consumer[4] get a number: 10
the consumer sum is: 55
the consumer exit!

msh >producer_consumer
the producer generates a number: 1
the consumer[0] get a number: 1
msh >the producer generates a number: 2
the producer generates a number: 3
the consumer[1] get a number: 2
the producer generates a number: 4
the producer generates a number: 5
the consumer[2] get a number: 3
the producer generates a number: 6
the producer generates a number: 7
the producer generates a number: 8
the consumer[3] get a number: 4
the producer generates a number: 9
the consumer[4] get a number: 5
the producer generates a number: 10
the producer exit!
the consumer[0] get a number: 6
the consumer[1] get a number: 7
the consumer[2] get a number: 8
the consumer[3] get a number: 9
the consumer[4] get a number: 10
the consumer sum is: 55
the consumer exit!

本例程可以理解为生产者生产产品放入仓库,消费者从仓库中取走产品。

(1)生产者线程:

1)获取 1 个空位(放产品 number),此时空位减 1;

2)上锁保护;本次的产生的 number 值为 cnt+1,把值循环存入数组 array 中;再开锁;

3)释放 1 个满位(给仓库中放置一个产品,仓库就多一个满位),满位加 1;

(2)消费者线程:

1)获取 1 个满位(取产品 number),此时满位减 1;

2)上锁保护;将本次生产者生产的 number 值从 array 中读出来,并与上次的 number 值相加;再开锁;

3)释放 1 个空位(从仓库上取走一个产品,仓库就多一个空位),空位加 1。

生产者依次产生 10 个 number,消费者依次取走,并将 10 个 number 的值求和。信号量锁 lock 保护 array 临界区资源:保证了消费者每次取 number 值的排他性,实现了线程间同步。

信号量的使用场合

信号量是一种非常灵活的同步方式,可以运用在多种场合中。形成锁、同步、资源计数等关系,也能方便的用于线程与线程、中断与线程间的同步中。

线程同步

线程同步是信号量最简单的一类应用。例如,使用信号量进行两个线程之间的同步,信号量的值初始化成 0,表示具备 0 个信号量资源实例;而尝试获得该信号量的线程,将直接在这个信号量上进行等待。

当持有信号量的线程完成它处理的工作时,释放这个信号量,可以把等待在这个信号量上的线程唤醒,让它执行下一部分工作。这类场合也可以看成把信号量用于工作完成标志:持有信号量的线程完成它自己的工作,然后通知等待该信号量的线程继续下一部分工作。

(该功能仅做了解)

锁,单一的锁常应用于多个线程间对同一共享资源(即临界区)的访问。信号量在作为锁来使用时,通常应将信号量资源实例初始化成 1,代表系统默认有一个资源可用,因为信号量的值始终在 1 和 0 之间变动,所以这类锁也叫做二值信号量。如下图所示,当线程需要访问共享资源时,它需要先获得这个资源锁。当这个线程成功获得资源锁时,其他打算访问共享资源的线程会由于获取不到资源而挂起,这是因为其他线程在试图获取这个锁时,这个锁已经被锁上(信号量值是 0)。当获得信号量的线程处理完毕,退出临界区时,它将会释放信号量并把锁解开,而挂起在锁上的第一个等待线程将被唤醒从而获得临界区的访问权。

锁

注:在计算机操作系统发展历史上,人们早期使用二值信号量来保护临界区,但是在1990年,研究人员发现了使用信号量保护临界区会导致无界优先级反转的问题,因此提出了互斥量的概念。如今,我们已经不使用二值信号量来保护临界区,互斥量取而代之。

中断与线程的同步

信号量也能够方便地应用于中断与线程间的同步,例如一个中断触发,中断服务例程需要通知线程进行相应的数据处理。这个时候可以设置信号量的初始值是 0,线程在试图持有这个信号量时,由于信号量的初始值是 0,线程直接在这个信号量上挂起直到信号量被释放。当中断触发时,先进行与硬件相关的动作,例如从硬件的 I/O 口中读取相应的数据,并确认中断以清除中断源,而后释放一个信号量来唤醒相应的线程以做后续的数据处理。例如 FinSH 线程的处理方式,如下图所示。

FinSH 的中断、线程间同步示意图

信号量的值初始为 0,当 FinSH 线程试图取得信号量时,因为信号量值是 0,所以它会被挂起。当 console 设备有数据输入时,产生中断,从而进入中断服务例程。在中断服务例程中,它会读取 console 设备的数据,并把读得的数据放入 UART buffer 中进行缓冲,而后释放信号量,释放信号量的操作将唤醒 shell 线程。在中断服务例程运行完毕后,如果系统中没有比 shell 线程优先级更高的就绪线程存在时,shell 线程将持有信号量并运行,从 UART buffer 缓冲区中获取输入的数据。

注:中断与线程间的互斥不能采用信号量(锁)的方式,而应采用开关中断的方式。

资源计数

信号量也可以认为是一个递增或递减的计数器,需要注意的是信号量的值非负。例如:初始化一个信号量的值为 5,则这个信号量可最大连续减少 5 次,直到计数器减为 0。资源计数适合于线程间工作处理速度不匹配的场合,这个时候信号量可以做为前一线程工作完成个数的计数,而当调度到后一线程时,它也可以以一种连续的方式一次处理多个事件。例如,生产者与消费者问题中,生产者可以对信号量进行多次释放,而后消费者被调度到时能够一次处理多个信号量资源。

注:一般资源计数类型多是混合方式的线程间同步,因为对于单个的资源处理依然存在线程的多重访问,这就需要对一个单独的资源进行访问、处理,并进行锁方式的互斥操作。

互斥量

互斥量又叫相互排斥的信号量,是一种特殊的二值信号量。互斥量类似于只有一个车位的停车场:当有一辆车进入的时候,将停车场大门锁住,其他车辆在外面等候。当里面的车出来时,将停车场大门打开,下一辆车才可以进入。

互斥量工作机制

互斥量和信号量不同的是:拥有互斥量的线程拥有互斥量的所有权,互斥量支持递归访问且能防止线程优先级翻转;并且互斥量只能由持有线程释放,而信号量则可以由任何线程释放。

互斥量的状态只有两种,开锁或闭锁(两种状态值)。当有线程持有它时,互斥量处于闭锁状态,由这个线程获得它的所有权。相反,当这个线程释放它时,将对互斥量进行开锁,失去它的所有权。当一个线程持有互斥量时,其他线程将不能够对它进行开锁或持有它,持有该互斥量的线程也能够再次获得这个锁而不被挂起,如下图时所示。这个特性与一般的二值信号量有很大的不同:在信号量中,因为已经不存在实例,线程递归持有会发生主动挂起(最终形成死锁)。

互斥量工作示意图

使用信号量会导致的另一个潜在问题是线程优先级翻转问题。所谓优先级翻转,即当一个高优先级线程试图通过信号量机制访问共享资源时,如果该信号量已被一低优先级线程持有,而这个低优先级线程在运行过程中可能又被其它一些中等优先级的线程抢占,因此造成高优先级线程被许多具有较低优先级的线程阻塞,实时性难以得到保证。如下图所示:有优先级为 A、B 和 C 的三个线程,优先级 A> B > C。线程 A,B 处于挂起状态,等待某一事件触发,线程 C 正在运行,此时线程 C 开始使用某一共享资源 M。在使用过程中,线程 A 等待的事件到来,线程 A 转为就绪态,因为它比线程 C 优先级高,所以立即执行。但是当线程 A 要使用共享资源 M 时,由于其正在被线程 C 使用,因此线程 A 被挂起切换到线程 C 运行。如果此时线程 B 等待的事件到来,则线程 B 转为就绪态。由于线程 B 的优先级比线程 C 高,且线程B没有用到共享资源 M ,因此线程 B 开始运行,直到其运行完毕,线程 C 才开始运行。只有当线程 C 释放共享资源 M 后,线程 A 才得以执行。在这种情况下,优先级发生了翻转:线程 B 先于线程 A 运行。这样便不能保证高优先级线程的响应时间。

优先级反转 (M 为信号量)

在 RT-Thread 操作系统中,互斥量可以解决优先级翻转问题,实现的是优先级继承协议 (Sha, 1990)。优先级继承是通过在线程 A 尝试获取共享资源而被挂起的期间内,将线程 C 的优先级提升到线程 A 的优先级别,从而解决优先级翻转引起的问题。这样能够防止 C(间接地防止 A)被 B 抢占,如下图所示。优先级继承是指,提高某个占有某种资源的低优先级线程的优先级,使之与所有等待该资源的线程中优先级最高的那个线程的优先级相等,然后执行,而当这个低优先级线程释放该资源时,优先级重新回到初始设定。因此,继承优先级的线程避免了系统资源被任何中间优先级的线程抢占。

优先级继承 (M 为互斥量)

注:在获得互斥量后,请尽快释放互斥量,并且在持有互斥量的过程中,不得再行更改持有互斥量线程的优先级,否则可能人为引入无界优先级反转的问题。

上边这一大片其实不太重要,我们只需要知道使用互斥量而不是二值信号量就行

互斥量控制块

在 RT-Thread 中,互斥量控制块是操作系统用于管理互斥量的一个数据结构,由结构体 struct rt_mutex 表示。另外一种 C 表达方式 rt_mutex_t,表示的是互斥量的句柄,在 C 语言中的实现是指互斥量控制块的指针。互斥量控制块结构的详细定义请见以下代码:

1
2
3
4
5
6
7
8
9
10
11
struct rt_mutex
{
struct rt_ipc_object parent; /* 继承自 ipc_object 类 */

rt_uint16_t value; /* 互斥量的值 */
rt_uint8_t original_priority; /* 持有线程的原始优先级 */
rt_uint8_t hold; /* 持有线程的持有次数 */
struct rt_thread *owner; /* 当前拥有互斥量的线程 */
};
/* rt_mutext_t 为指向互斥量结构体的指针类型 */
typedef struct rt_mutex* rt_mutex_t;

rt_mutex 对象从 rt_ipc_object 中派生,由 IPC 容器所管理。

互斥量的管理方式

互斥量控制块中含有互斥相关的重要参数,在互斥量功能的实现中起到重要的作用。互斥量相关接口如下图所示,对一个互斥量的操作包含:创建 / 初始化互斥量、获取互斥量、释放互斥量、删除 / 脱离互斥量。

互斥量相关接口

创建和删除互斥量

创建一个互斥量时,内核首先创建一个互斥量控制块,然后完成对该控制块的初始化工作。创建互斥量使用下面的函数接口:

1
rt_mutex_t rt_mutex_create (const char* name, rt_uint8_t flag);

可以调用 rt_mutex_create 函数创建一个互斥量,它的名字由 name 所指定。当调用这个函数时,系统将先从对象管理器中分配一个 mutex 对象,并初始化这个对象,然后初始化父类 IPC 对象以及与 mutex 相关的部分。互斥量的 flag 标志已经作废,无论用户选择 RT_IPC_FLAG_PRIO 还是 RT_IPC_FLAG_FIFO,内核均按照 RT_IPC_FLAG_PRIO 处理。下表描述了该函数的输入参数与返回值:

参数 描述
name 互斥量的名称
flag 该标志已经作废,无论用户选择 RT_IPC_FLAG_PRIO 还是 RT_IPC_FLAG_FIFO,内核均按照 RT_IPC_FLAG_PRIO 处理
返回 ——
互斥量句柄 创建成功
RT_NULL 创建失败

当不再使用互斥量时,通过删除互斥量以释放系统资源,适用于动态创建的互斥量。删除互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_delete (rt_mutex_t mutex);

当删除一个互斥量时,所有等待此互斥量的线程都将被唤醒,等待线程获得的返回值是 - RT_ERROR。然后系统将该互斥量从内核对象管理器链表中删除并释放互斥量占用的内存空间。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄
返回 ——
RT_EOK 删除成功

初始化和脱离互斥量

静态互斥量对象的内存是在系统编译时由编译器分配的,一般放于读写数据段或未初始化数据段中。在使用这类静态互斥量对象前,需要先进行初始化。初始化互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_init (rt_mutex_t mutex, const char* name, rt_uint8_t flag);

使用该函数接口时,需指定互斥量对象的句柄(即指向互斥量控制块的指针),互斥量名称以及互斥量标志。互斥量标志可用上面创建互斥量函数里提到的标志。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄,它由用户提供,并指向互斥量对象的内存块
name 互斥量的名称
flag 该标志已经作废,无论用户选择 RT_IPC_FLAG_PRIO 还是 RT_IPC_FLAG_FIFO,内核均按照 RT_IPC_FLAG_PRIO 处理
返回 ——
RT_EOK 初始化成功

脱离互斥量将把互斥量对象从内核对象管理器中脱离,适用于静态初始化的互斥量。脱离互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_detach (rt_mutex_t mutex);

使用该函数接口后,内核先唤醒所有挂在该互斥量上的线程(线程的返回值是 -RT_ERROR),然后系统将该互斥量从内核对象管理器中脱离。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄
返回 ——
RT_EOK 成功

获取互斥量

线程获取了互斥量,那么线程就有了对该互斥量的所有权,即某一个时刻一个互斥量只能被一个线程持有。获取互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_take (rt_mutex_t mutex, rt_int32_t time);

如果互斥量没有被其他线程控制,那么申请该互斥量的线程将成功获得该互斥量。如果互斥量已经被当前线程线程控制,则该互斥量的持有计数加 1,当前线程也不会挂起等待。如果互斥量已经被其他线程占有,则当前线程在该互斥量上挂起等待,直到其他线程释放它或者等待时间超过指定的超时时间。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄
time 指定等待的时间
返回 ——
RT_EOK 成功获得互斥量
-RT_ETIMEOUT 超时
-RT_ERROR 获取失败

无等待获取互斥量

当用户不想在申请的互斥量上挂起线程进行等待时,可以使用无等待方式获取互斥量,无等待获取互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_trytake(rt_mutex_t mutex);

这个函数与 rt_mutex_take(mutex, RT_WAITING_NO) 的作用相同,即当线程申请的互斥量资源实例不可用的时候,它不会等待在该互斥量上,而是直接返回 - RT_ETIMEOUT。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄
返回 ——
RT_EOK 成功获得互斥量
-RT_ETIMEOUT 获取失败

释放互斥量

当线程完成互斥资源的访问后,应尽快释放它占据的互斥量,使得其他线程能及时获取该互斥量。释放互斥量使用下面的函数接口:

1
rt_err_t rt_mutex_release(rt_mutex_t mutex);

使用该函数接口时,只有已经拥有互斥量控制权的线程才能释放它,每释放一次该互斥量,它的持有计数就减 1。当该互斥量的持有计数为零时(即持有线程已经释放所有的持有操作),它变为可用,等待在该互斥量上的线程将被唤醒。如果线程的运行优先级被互斥量提升,那么当互斥量被释放后,线程恢复为持有互斥量前的优先级。下表描述了该函数的输入参数与返回值:

参数 描述
mutex 互斥量对象的句柄
返回 ——
RT_EOK 成功

互斥量应用示例

这是一个互斥量的应用例程,互斥锁是一种保护共享资源的方法。当一个线程拥有互斥锁的时候,可以保护共享资源不被其他线程破坏。下面用一个例子来说明,有两个线程:线程 1 和线程 2,线程 1 对 2 个 number 分别进行加 1 操作;线程 2 也对 2 个 number 分别进行加 1 操作,使用互斥量保证线程改变 2 个 number 值的操作不被打断。如下代码所示:

互斥量例程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#include <rtthread.h>

#define THREAD_PRIORITY 8
#define THREAD_TIMESLICE 5

/* 指向互斥量的指针 */
static rt_mutex_t dynamic_mutex = RT_NULL;
static rt_uint8_t number1, number2 = 0;
/* 线程退出标志 */
static rt_bool_t thread_exit_flag = RT_FALSE;

ALIGN(RT_ALIGN_SIZE)
static char thread1_stack[1024];
static struct rt_thread thread1;

static void rt_thread_entry1(void *parameter)
{
while (1)
{
/* 线程 1 在获取互斥量前检查它是否存在 */
if (dynamic_mutex == RT_NULL || thread_exit_flag)
{
number1 = 0;
number2 = 0;

/* 重置退出标志 */
thread_exit_flag = RT_FALSE;
break; /* 退出线程 */
}

/* 获取互斥量并进行操作 */
if (rt_mutex_take(dynamic_mutex, RT_WAITING_FOREVER) == RT_EOK)
{
number1++;
number2++;
rt_kprintf("thread1 mutex protect, number1 = number2 is %d\n", number1);
rt_mutex_release(dynamic_mutex);
rt_thread_mdelay(10);
}
}
}

ALIGN(RT_ALIGN_SIZE)
static char thread2_stack[1024];
static struct rt_thread thread2;
static void rt_thread_entry2(void *parameter)
{
while (1)
{
/* 获取互斥量 */
if (rt_mutex_take(dynamic_mutex, RT_WAITING_FOREVER) == RT_EOK)
{
if (number1 != number2)
{
rt_kprintf("not protect. number1 = %d, number2 = %d\n", number1, number2);
}
else
{
rt_kprintf("mutex protect, number1 = number2 is %d\n", number1);
}

number1++;
number2++;
rt_mutex_release(dynamic_mutex);

/* 判断是否达到退出条件 */
if (number1 >= 50)
{
thread_exit_flag = RT_TRUE;

/* 删除互斥量 */
rt_mutex_delete(dynamic_mutex);
dynamic_mutex = RT_NULL;

break; /* 退出线程 */
}
}
}
}

/* 互斥量示例的初始化 */
int mutex_sample(void)
{
/* 创建一个动态互斥量 */
dynamic_mutex = rt_mutex_create("dmutex", RT_IPC_FLAG_PRIO);
if (dynamic_mutex == RT_NULL)
{
rt_kprintf("create dynamic mutex failed.\n");
return -1;
}

rt_thread_init(&thread1,
"thread1",
rt_thread_entry1,
RT_NULL,
&thread1_stack[0],
sizeof(thread1_stack),
THREAD_PRIORITY, THREAD_TIMESLICE);
rt_thread_startup(&thread1);

rt_thread_init(&thread2,
"thread2",
rt_thread_entry2,
RT_NULL,
&thread2_stack[0],
sizeof(thread2_stack),
THREAD_PRIORITY - 1, THREAD_TIMESLICE);
rt_thread_startup(&thread2);

return 0;
}

/* 导出到 MSH 命令列表中 */
MSH_CMD_EXPORT(mutex_sample, mutex sample);

线程 1 与线程 2 中均使用互斥量保护对 2 个 number 的操作(倘若将线程 1 中的获取、释放互斥量语句注释掉,线程 1 将对 number 不再做保护),仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
\ | /
- RT - Thread Operating System
/ | \ 4.1.1 build Sep 2 2024 19:21:00
2006 - 2022 Copyright by RT-Thread team
msh >mutex_sample
msh >mutex protect, number1 = number2 is 1
mutex protect, number1 = number2 is 2
mutex protect, number1 = number2 is 3
mutex protect, number1 = number2 is 4
mutex protect, number1 = number2 is 5
mutex protect, number1 = number2 is 6
mutex protect, number1 = number2 is 7
mutex protect, number1 = number2 is 8
mutex protect, number1 = number2 is 9
mutex protect, number1 = number2 is 10
mutex protect, number1 = number2 is 11
mutex protect, number1 = number2 is 12
mutex protect, number1 = number2 is 13
mutex protect, number1 = number2 is 14
mutex protect, number1 = number2 is 15
mutex protect, number1 = number2 is 16
mutex protect, number1 = number2 is 17
mutex protect, number1 = number2 is 18
mutex protect, number1 = number2 is 19
mutex protect, number1 = number2 is 20
mutex protect, number1 = number2 is 21
mutex protect, number1 = number2 is 22
mutex protect, number1 = number2 is 23
mutex protect, number1 = number2 is 24
mutex protect, number1 = number2 is 25
mutex protect, number1 = number2 is 26
mutex protect, number1 = number2 is 27
mutex protect, number1 = number2 is 28
mutex protect, number1 = number2 is 29
mutex protect, number1 = number2 is 30
mutex protect, number1 = number2 is 31
mutex protect, number1 = number2 is 32
mutex protect, number1 = number2 is 33
mutex protect, number1 = number2 is 34
mutex protect, number1 = number2 is 35
mutex protect, number1 = number2 is 36
mutex protect, number1 = number2 is 37
mutex protect, number1 = number2 is 38
mutex protect, number1 = number2 is 39
mutex protect, number1 = number2 is 40
mutex protect, number1 = number2 is 41
mutex protect, number1 = number2 is 42
mutex protect, number1 = number2 is 43
mutex protect, number1 = number2 is 44
mutex protect, number1 = number2 is 45
mutex protect, number1 = number2 is 46
mutex protect, number1 = number2 is 47
mutex protect, number1 = number2 is 48
mutex protect, number1 = number2 is 49
msh >mutex_sample
msh >mutex protect, number1 = number2 is 1
mutex protect, number1 = number2 is 2
mutex protect, number1 = number2 is 3
mutex protect, number1 = number2 is 4
mutex protect, number1 = number2 is 5
mutex protect, number1 = number2 is 6
mutex protect, number1 = number2 is 7
mutex protect, number1 = number2 is 8
mutex protect, number1 = number2 is 9
mutex protect, number1 = number2 is 10
mutex protect, number1 = number2 is 11
mutex protect, number1 = number2 is 12
mutex protect, number1 = number2 is 13
mutex protect, number1 = number2 is 14
mutex protect, number1 = number2 is 15
mutex protect, number1 = number2 is 16
mutex protect, number1 = number2 is 17
mutex protect, number1 = number2 is 18
mutex protect, number1 = number2 is 19
mutex protect, number1 = number2 is 20
mutex protect, number1 = number2 is 21
mutex protect, number1 = number2 is 22
mutex protect, number1 = number2 is 23
mutex protect, number1 = number2 is 24
mutex protect, number1 = number2 is 25
mutex protect, number1 = number2 is 26
mutex protect, number1 = number2 is 27
mutex protect, number1 = number2 is 28
mutex protect, number1 = number2 is 29
mutex protect, number1 = number2 is 30
mutex protect, number1 = number2 is 31
mutex protect, number1 = number2 is 32
mutex protect, number1 = number2 is 33
mutex protect, number1 = number2 is 34
mutex protect, number1 = number2 is 35
mutex protect, number1 = number2 is 36
mutex protect, number1 = number2 is 37
mutex protect, number1 = number2 is 38
mutex protect, number1 = number2 is 39
mutex protect, number1 = number2 is 40
mutex protect, number1 = number2 is 41
mutex protect, number1 = number2 is 42
mutex protect, number1 = number2 is 43
mutex protect, number1 = number2 is 44
mutex protect, number1 = number2 is 45
mutex protect, number1 = number2 is 46
mutex protect, number1 = number2 is 47
mutex protect, number1 = number2 is 48
mutex protect, number1 = number2 is 49

线程使用互斥量保护对两个 number 的操作,使 number 值保持一致。

互斥量的另一个例子见下面的代码,这个例子将创建 3 个动态线程以检查持有互斥量时,持有的线程优先级是否被调整到等待线程优先级中的最高优先级。

防止优先级翻转特性例程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#include <rtthread.h>

/* 指向线程控制块的指针 */
static rt_thread_t tid1 = RT_NULL;
static rt_thread_t tid2 = RT_NULL;
static rt_thread_t tid3 = RT_NULL;
static rt_mutex_t mutex = RT_NULL;


#define THREAD_PRIORITY 10
#define THREAD_STACK_SIZE 512
#define THREAD_TIMESLICE 5

/* 线程 1 入口 */
static void thread1_entry(void *parameter)
{
/* 先让低优先级线程运行 */
rt_thread_mdelay(100);

/* 此时 thread3 持有 mutex,并且 thread2 等待持有 mutex */

/* 检查 thread2 与 thread3 的优先级情况 */
if (tid2->current_priority != tid3->current_priority)
{
/* 优先级不相同,测试失败 */
rt_kprintf("the priority of thread2 is: %d\n", tid2->current_priority);
rt_kprintf("the priority of thread3 is: %d\n", tid3->current_priority);
rt_kprintf("test failed.\n");
return;
}
else
{
rt_kprintf("the priority of thread2 is: %d\n", tid2->current_priority);
rt_kprintf("the priority of thread3 is: %d\n", tid3->current_priority);
rt_kprintf("test OK.\n");
}
}

/* 线程 2 入口 */
static void thread2_entry(void *parameter)
{
rt_err_t result;

rt_kprintf("the priority of thread2 is: %d\n", tid2->current_priority);

/* 先让低优先级线程运行 */
rt_thread_mdelay(50);

/*
* 试图持有互斥锁,此时 thread3 持有,应把 thread3 的优先级提升
* 到 thread2 相同的优先级
*/
result = rt_mutex_take(mutex, RT_WAITING_FOREVER);

if (result == RT_EOK)
{
/* 释放互斥锁 */
rt_mutex_release(mutex);
}
}

/* 线程 3 入口 */
static void thread3_entry(void *parameter)
{
rt_tick_t tick;
rt_err_t result;

rt_kprintf("the priority of thread3 is: %d\n", tid3->current_priority);

result = rt_mutex_take(mutex, RT_WAITING_FOREVER);
if (result != RT_EOK)
{
rt_kprintf("thread3 take a mutex, failed.\n");
}

/* 做一个长时间的循环,500ms */
tick = rt_tick_get();
while (rt_tick_get() - tick < (RT_TICK_PER_SECOND / 2)) ;

rt_mutex_release(mutex);
}

int pri_inversion(void)
{
/* 创建互斥锁 */
mutex = rt_mutex_create("mutex", RT_IPC_FLAG_PRIO);
if (mutex == RT_NULL)
{
rt_kprintf("create dynamic mutex failed.\n");
return -1;
}

/* 创建线程 1 */
tid1 = rt_thread_create("thread1",
thread1_entry,
RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY - 1, THREAD_TIMESLICE);
if (tid1 != RT_NULL)
rt_thread_startup(tid1);

/* 创建线程 2 */
tid2 = rt_thread_create("thread2",
thread2_entry,
RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY, THREAD_TIMESLICE);
if (tid2 != RT_NULL)
rt_thread_startup(tid2);

/* 创建线程 3 */
tid3 = rt_thread_create("thread3",
thread3_entry,
RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY + 1, THREAD_TIMESLICE);
if (tid3 != RT_NULL)
rt_thread_startup(tid3);

return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(pri_inversion, prio_inversion sample);

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
 \ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 27 2018
2006 - 2018 Copyright by rt-thread team
msh >pri_inversion
the priority of thread2 is: 10
the priority of thread3 is: 11
the priority of thread2 is: 10
the priority of thread3 is: 10
test OK.

例程演示了互斥量的使用方法。线程 3 先持有互斥量,而后线程 2 试图持有互斥量,此时线程 3 的优先级被提升为和线程 2 的优先级相同。

Note

注:需要切记的是互斥量不能在中断服务例程中使用。

互斥量的使用场合

互斥量的使用比较单一,因为它是信号量的一种,并且它是以锁的形式存在。在初始化的时候,互斥量永远都处于开锁的状态,而被线程持有的时候则立刻转为闭锁的状态。互斥量更适合于:

(1)线程多次持有互斥量的情况下。这样可以避免同一线程多次递归持有而造成死锁的问题。

(2)可能会由于多线程同步而造成优先级翻转的情况。

任何操作系统都需要提供一个时钟节拍,以供系统处理所有和时间有关的事件,如线程的延时、线程的时间片轮转调度以及定时器超时等。时钟节拍是特定的周期性中断,这个中断可以看做是系统心跳,中断之间的时间间隔取决于不同的应用,一般是 1ms–100ms,时钟节拍率越快,系统的实时响应越快,但是系统的额外开销就越大,从系统启动开始计数的时钟节拍数称为系统时间。

可以类比之前蓝桥杯的时候用到systick实现的”系统心跳”,不过rt-thread将其进行了进一步封装。在不同的硬件平台上,这个时钟节拍的实现方式是不一样的。

在stm32上,一般是由一个配置为中断模式的硬件定时器来实现的(并且一般是systick,当systick被占用时,可以由其他定时器代替)。

这个中断可以看做是系统心跳,中断之间的时间间隔取决于不同的应用,一般是 1ms–100ms,时钟节拍率越快,系统的实时响应越快,但是系统的额外开销就越大,从系统启动开始计数的时钟节拍数称为系统时间。RT-Thread 中,时钟节拍的长度可以根据 RT_TICK_PER_SECOND 的定义来调整,等于 1/RT_TICK_PER_SECOND 秒。

时钟节拍的实现方式

时钟节拍由配置为中断触发模式的硬件定时器产生,当中断到来时,将调用一次:void rt_tick_increase(void),通知操作系统已经过去一个系统时钟;不同硬件定时器中断实现都不同,下面的中断函数以 STM32 定时器作为示例。

1
2
3
4
5
6
7
8
9
void SysTick_Handler(void)
{
/* 进入中断 */
rt_interrupt_enter();
……
rt_tick_increase();
/* 退出中断 */
rt_interrupt_leave();
}

在中断函数中调用 rt_tick_increase() 对全局变量 rt_tick 进行自加,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void rt_tick_increase(void)
{
struct rt_thread *thread;

/* 全局变量 rt_tick 自加 */
++ rt_tick;

/* 检查时间片 */
thread = rt_thread_self();

-- thread->remaining_tick;
if (thread->remaining_tick == 0)
{
/* 重新赋初值 */
thread->remaining_tick = thread->init_tick;

/* 线程挂起 */
rt_thread_yield();
}

/* 检查定时器 */
rt_timer_check();
}

可以看到全局变量 rt_tick 在每经过一个时钟节拍时,值就会加 1,rt_tick 的值表示了系统从启动开始总共经过的时钟节拍数,即系统时间。此外,每经过一个时钟节拍时,都会检查当前线程的时间片是否用完,以及是否有定时器超时。

注:中断中的 rt_timer_check() 用于检查系统硬件定时器链表,如果有定时器超时,将调用相应的超时函数。且所有定时器在定时超时后都会从定时器链表中被移除,而周期性定时器会在它再次启动时被加入定时器链表。

上头这句话是什么意思,我去问了一下Ai,它给了我这样的回复

你提到的“系统硬件定时器链表”是一个关键概念,但根据RT-Thread的实现,它其实更准确地说是软件定时器链表,并且这个链表是由硬件定时器驱动的。

让我来为你详细解释一下这个机制。

RT-Thread 的软件定时器系统

RT-Thread 并没有一个专门用于硬件定时器的链表。它只有一个全局的软件定时器链表,所有由 rt_timer_create() 创建的定时器,无论它们是单次定时还是周期性定时,都会被挂载到这个链表中。

这个链表是一个按超时时间排序的链表。系统在每次时钟节拍(Tick)中断中,都会检查这个链表。

rt_timer_check() 函数的作用

rt_timer_check() 这个函数在每个时钟节拍中断中被调用,它的主要职责是:

  1. 检查链表头部的定时器是否超时:它会遍历软件定时器链表,比较每个定时器的超时时间与当前的系统时钟节拍 (rt_tick)。
  2. 调用超时函数:如果发现有定时器超时,它会调用你为该定时器设定的回调函数。
  3. 移除定时器:无论是单次定时器还是周期性定时器,在超时并执行完回调函数后,rt_timer_check() 都会将它从链表中移除。

周期性定时器的特殊处理

这里就是你提到的“周期性定时器会在它再次启动时被加入定时器链表”的机制。对于一个周期性定时器,当它超时并被移除后,系统会:

  • 重新计算下一次超时时间:根据它的周期值,计算出它下一次的超时节拍。
  • 重新加入链表:将它重新插入到软件定时器链表中的正确位置(按照新的超时时间排序)。

通过这种方式,周期性定时器才能不断地重复执行。

总结

所以,你所说的“系统硬件定时器链表”更准确的理解应该是:

一个由硬件定时器中断驱动的、按超时时间排序的软件定时器链表。硬件定时器提供周期性的“心跳”,而 rt_timer_check() 函数在每次“心跳”中检查这个链表,并处理那些超时的软件定时器。这个链表是 RT-Thread 软件定时器系统的核心,实现了所有定时器的管理、调度和执行。

获取时钟节拍

由于全局变量 rt_tick 在每经过一个时钟节拍时,值就会加 1,通过调用 rt_tick_get 会返回当前 rt_tick 的值,即可以获取到当前的时钟节拍值。此接口可用于记录系统的运行时间长短,或者测量某任务运行的时间。接口函数如下:

1
rt_tick_t rt_tick_get(void);

下表描述了 rt_tick_get() 函数的返回值:

返回 描述
rt_tick 当前时钟节拍值

这个函数其实就类似于HAL库里头的HAL_GetTick();这个函数(函数名应该是这个),不过rt-thread对其进行了进一步封装

定时器管理

定时器,是指从指定的时刻开始,经过一定的指定时间后触发一个事件,例如定个时间提醒第二天能够按时起床。定时器有硬件定时器和软件定时器之分:

1)硬件定时器是芯片本身提供的定时功能。一般是由外部晶振提供给芯片输入时钟,芯片向软件模块提供一组配置寄存器,接受控制输入,到达设定时间值后芯片中断控制器产生时钟中断。硬件定时器的精度一般很高,可以达到纳秒级别,并且是中断触发方式。

2)软件定时器是由操作系统提供的一类系统接口,它构建在硬件定时器基础之上,使系统能够提供不受数目限制的定时器服务。

RT-Thread 操作系统提供软件实现的定时器,以时钟节拍(OS Tick)的时间长度为单位,即定时数值必须是 OS Tick 的整数倍,例如一个 OS Tick 是 10ms,那么上层软件定时器只能是 10ms,20ms,100ms 等,而不能定时为 15ms。RT-Thread 的定时器也基于系统的节拍,提供了基于节拍整数倍的定时能力。

RT-Thread 定时器介绍

RT-Thread 的定时器提供了两类触发机制和两种执行模式。

触发机制

  • 单次触发定时器:这类定时器在启动后只会触发一次事件,然后自动停止。
  • 周期触发定时器:这类定时器会周期性地触发事件,直到被手动停止。

执行模式

根据超时函数执行时的上下文环境,RT-Thread 的定时器可以分为 HARD_TIMERSOFT_TIMER 两种模式。

HARD_TIMER 模式

HARD_TIMER 模式是 RT-Thread 的默认定时器模式

  • 执行上下文:超时函数在**中断服务程序(ISR)**中执行。
  • 优点:响应速度快,实时性高。
  • 缺点:因为在中断中执行,超时函数的执行时间必须非常短。不能调用任何会导致当前上下文挂起的函数,例如 rt_thread_delay()rt_mutex_take() 等。
  • 使用场景:适合执行一些简单、快速的操作,如设置一个标志位或发送一个信号量。

SOFT_TIMER 模式

SOFT_TIMER 模式需要通过宏定义 RT_USING_TIMER_SOFT 来启用。

  • 执行上下文:超时函数在一个专门的定时器线程中执行。
  • 优点:超时函数可以在独立的线程中执行,因此可以调用几乎所有 RT-Thread API,包括会引起阻塞或挂起的函数。
  • 缺点:响应时间不如 HARD_TIMER 模式快,因为它需要等待线程调度。
  • 使用场景:适合执行一些复杂、耗时较长的操作,或者需要调用阻塞型 API 的任务。

SOFT_TIMER 模式可配置,通过宏定义 RT_USING_TIMER_SOFT 来决定是否启用该模式。该模式被启用后,系统会在初始化时创建一个 timer 线程,然后 SOFT_TIMER 模式的定时器超时函数在都会在 timer 线程的上下文环境中执行。可以在初始化 / 创建定时器时使用参数 RT_TIMER_FLAG_SOFT_TIMER 来指定设置 SOFT_TIMER 模式。

image-20250802082923845

定时器工作机制

下面以一个例子来说明 RT-Thread 定时器的工作机制。在 RT-Thread 定时器模块中维护着两个重要的全局变量:

(1)当前系统经过的 tick 时间 rt_tick(当硬件定时器中断来临时,它将加 1);

(2)定时器链表 rt_timer_list。系统新创建并激活的定时器都会按照以超时时间排序的方式插入到 rt_timer_list 链表中。

如下图所示,系统当前 tick 值为 20,在当前系统中已经创建并启动了三个定时器,分别是定时时间为 50 个 tick 的 Timer1、100 个 tick 的 Timer2 和 500 个 tick 的 Timer3,这三个定时器分别加上系统当前时间 rt_tick=20,从小到大排序链接在 rt_timer_list 链表中,形成如图所示的定时器链表结构。

定时器链表示意图

而 rt_tick 随着硬件定时器的触发一直在增长(每一次硬件定时器中断来临,rt_tick 变量会加 1),50 个 tick 以后,rt_tick 从 20 增长到 70,与 Timer1 的 timeout 值相等,这时会触发与 Timer1 定时器相关联的超时函数,同时将 Timer1 从 rt_timer_list 链表上删除。同理,100 个 tick 和 500 个 tick 过去后,与 Timer2 和 Timer3 定时器相关联的超时函数会被触发,接着将 Timer2 和 Timer3 定时器从 rt_timer_list 链表中删除。

如果系统当前定时器状态在 10 个 tick 以后(rt_tick=30)有一个任务新创建了一个 tick 值为 300 的 Timer4 定时器,由于 Timer4 定时器的 timeout=rt_tick+300=330, 因此它将被插入到 Timer2 和 Timer3 定时器中间,形成如下图所示链表结构:

定时器链表插入示意图

这块讲的非常清晰明了

定时器控制块

在 RT-Thread 操作系统中,定时器控制块由结构体 struct rt_timer 定义并形成定时器内核对象,再链接到内核对象容器中进行管理。它是操作系统用于管理定时器的一个数据结构,会存储定时器的一些信息,例如初始节拍数,超时时的节拍数,也包含定时器与定时器之间连接用的链表结构,超时回调函数等。

1
2
3
4
5
6
7
8
9
10
11
struct rt_timer
{
struct rt_object parent; //一些rt-thread中的对象所具有的默认属性
rt_list_t row[RT_TIMER_SKIP_LIST_LEVEL]; /* 定时器链表节点 */

void (*timeout_func)(void *parameter); /* 定时器超时调用的函数 */
void *parameter; /* 超时函数的参数 */
rt_tick_t init_tick; /* 定时器初始超时节拍数 */
rt_tick_t timeout_tick; /* 定时器实际超时时的节拍数 */
};
typedef struct rt_timer *rt_timer_t;

定时器控制块由 struct rt_timer 结构体定义并形成定时器内核对象,再链接到内核对象容器中进行管理,list 成员则用于把一个激活的(已经启动的)定时器链接到 rt_timer_list 链表中。

定时器跳表 (Skip List) 算法

这个其实直接理解成二叉树 + 链表就行了,只管用就完事了,算一种空间换时间

下面这块对于应用来说是比较重要的

定时器的管理方式

前面介绍了 RT-Thread 定时器并对定时器的工作机制进行了概念上的讲解,本节将深入到定时器的各个接口,帮助读者在代码层次上理解 RT-Thread 定时器。

在系统启动时需要初始化定时器管理系统。可以通过下面的函数接口完成:

1
void rt_system_timer_init(void);

如果需要使用 SOFT_TIMER,则系统初始化时,应该调用下面这个函数接口:

1
void rt_system_timer_thread_init(void);

如果想要使用SOFT_TIMER的话,两个函数都得调用一次

定时器控制块中含有定时器相关的重要参数,在定时器各种状态间起到纽带的作用。定时器的相关操作如下图所示,对定时器的操作包含:创建 / 初始化定时器、启动定时器、运行定时器、删除 / 脱离定时器,所有定时器在定时超时后都会从定时器链表中被移除,而周期性定时器会在它再次启动时被加入定时器链表,这与定时器参数设置相关。在每次的操作系统时钟中断发生时,都会对已经超时的定时器状态参数做改变。

定时器相关操作

创建和删除定时器

当动态创建一个定时器时,可使用下面的函数接口:

1
2
3
4
5
rt_timer_t rt_timer_create(const char* name,
void (*timeout)(void* parameter),
void* parameter,
rt_tick_t time,
rt_uint8_t flag);

调用该函数接口后,内核首先从动态内存堆中分配一个定时器控制块,然后对该控制块进行基本的初始化。其中的各参数和返回值说明详见下表:

rt_timer_create() 的输入参数和返回值

参数 描述
name 定时器的名称
void (timeout) (void parameter) 定时器超时函数指针(当定时器超时时,系统会调用这个函数)
parameter 定时器超时函数的入口参数(当定时器超时时,调用超时回调函数会把这个参数做为入口参数传递给超时函数)
time 定时器的超时时间,单位是时钟节拍
flag 定时器创建时的参数,支持的值包括单次定时、周期定时、硬件定时器、软件定时器等(可以用 “或” 关系取多个值)
返回 ——
RT_NULL 创建失败(通常会由于系统内存不够用而返回 RT_NULL)
定时器的句柄 定时器创建成功

include/rtdef.h 中定义了一些定时器相关的宏,如下:

1
2
3
4
5
#define RT_TIMER_FLAG_ONE_SHOT      0x0     /* 单次定时     */
#define RT_TIMER_FLAG_PERIODIC 0x2 /* 周期定时 */

#define RT_TIMER_FLAG_HARD_TIMER 0x0 /* 硬件定时器 */
#define RT_TIMER_FLAG_SOFT_TIMER 0x4 /* 软件定时器 */

上面 2 组值可以以 “或” 逻辑的方式赋给 flag。当指定的 flag 为 RT_TIMER_FLAG_HARD_TIMER 时,如果定时器超时,定时器的回调函数将在时钟中断的服务例程上下文中被调用;当指定的 flag 为 RT_TIMER_FLAG_SOFT_TIMER 时,如果定时器超时,定时器的回调函数将在系统时钟 timer 线程的上下文中被调用。

系统不再使用动态定时器时,可使用下面的函数接口:

1
rt_err_t rt_timer_delete(rt_timer_t timer);

调用这个函数接口后,系统会把这个定时器从 rt_timer_list 链表中删除,然后释放相应的定时器控制块占有的内存,其中的各参数和返回值说明详见下表:

rt_timer_delete() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要删除的定时器
返回 ——
RT_EOK 删除成功(如果参数 timer 句柄是一个 RT_NULL,将会导致一个 ASSERT 断言)

初始化和脱离定时器

当选择静态创建定时器时,可利用 rt_timer_init 接口来初始化该定时器,函数接口如下:

1
2
3
4
5
void rt_timer_init(rt_timer_t timer,
const char* name,
void (*timeout)(void* parameter),
void* parameter,
rt_tick_t time, rt_uint8_t flag);

使用该函数接口时会初始化相应的定时器控制块,初始化相应的定时器名称,定时器超时函数等等,其中的各参数和返回值说明详见下表:

rt_timer_init() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要初始化的定时器控制块
name 定时器的名称
void (timeout) (void parameter) 定时器超时函数指针(当定时器超时时,系统会调用这个函数)
parameter 定时器超时函数的入口参数(当定时器超时时,调用超时回调函数会把这个参数做为入口参数传递给超时函数)
time 定时器的超时时间,单位是时钟节拍
flag 定时器创建时的参数,支持的值包括单次定时、周期定时、硬件定时器、软件定时器(可以用 “或” 关系取多个值),详见创建定时器小节

当一个静态定时器不需要再使用时,可以使用下面的函数接口:

1
rt_err_t rt_timer_detach(rt_timer_t timer);

脱离定时器时,系统会把定时器对象从内核对象容器中脱离,但是定时器对象所占有的内存不会被释放,其中的各参数和返回值说明详见表下表:

rt_timer_detach() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要脱离的定时器控制块
返回 ——
RT_EOK 脱离成功

启动和停止定时器

当定时器被创建或者初始化以后,并不会被立即启动,必须在调用启动定时器函数接口后,才开始工作,启动定时器函数接口如下:

1
rt_err_t rt_timer_start(rt_timer_t timer);

调用定时器启动函数接口后,定时器的状态将更改为激活状态(RT_TIMER_FLAG_ACTIVATED),并按照超时顺序插入到 rt_timer_list 队列链表中,其中的各参数和返回值说明详见下表:

rt_timer_start() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要启动的定时器控制块
返回 ——
RT_EOK 启动成功

启动定时器的例子请参考后面的示例代码。

启动定时器以后,若想使它停止,可以使用下面的函数接口:

1
rt_err_t rt_timer_stop(rt_timer_t timer);

调用定时器停止函数接口后,定时器状态将更改为停止状态,并从 rt_timer_list 链表中脱离出来不参与定时器超时检查。当一个(周期性)定时器超时时,也可以调用这个函数接口停止这个(周期性)定时器本身,其中的各参数和返回值说明详见下表:

rt_timer_stop() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要停止的定时器控制块
返回 ——
RT_EOK 成功停止定时器
- RT_ERROR timer 已经处于停止状态

控制定时器

除了上述提供的一些编程接口,RT-Thread 也额外提供了定时器控制函数接口,以获取或设置更多定时器的信息。控制定时器函数接口如下:

1
rt_err_t rt_timer_control(rt_timer_t timer, rt_uint8_t cmd, void* arg);

控制定时器函数接口可根据命令类型参数,来查看或改变定时器的设置,其中的各参数和返回值说明详见下表:

rt_timer_control() 的输入参数和返回值

参数 描述
timer 定时器句柄,指向要控制的定时器控制块
cmd 用于控制定时器的命令,当前支持四个命令,分别是设置定时时间,查看定时时间,设置单次触发,设置周期触发
arg 与 cmd 相对应的控制命令参数 比如,cmd 为设定超时时间时,就可以将超时时间参数通过 arg 进行设定
返回 ——
RT_EOK 成功

函数参数 cmd 支持的命令:

1
2
3
4
#define RT_TIMER_CTRL_SET_TIME      0x0     /* 设置定时器超时时间       */
#define RT_TIMER_CTRL_GET_TIME 0x1 /* 获得定时器超时时间 */
#define RT_TIMER_CTRL_SET_ONESHOT 0x2 /* 设置定时器为单次定时器 */
#define RT_TIMER_CTRL_SET_PERIODIC 0x3 /* 设置定时器为周期型定时器 */

使用定时器控制接口的代码请见动态定时器例程。

定时器应用示例

这是一个创建定时器的例子,这个例程会创建两个动态定时器,一个是单次定时,一个是周期性定时并让周期定时器运行一段时间后停止运行,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <rtthread.h>

/* 定时器的控制块 */
static rt_timer_t timer1;
static rt_timer_t timer2;
static rt_uint8_t cnt = 0;

/* 定时器 1 超时函数 */
static void timeout1(void *parameter)
{
rt_kprintf("periodic timer is timeout %d\n", cnt);

/* 运行第 10 次,停止周期定时器 */
if (cnt++>= 9)
{
/* 清除计数值 */
cnt = 0;
rt_timer_stop(timer1);
rt_kprintf("periodic timer was stopped! \n");
}
}

/* 定时器 2 超时函数 */
static void timeout2(void *parameter)
{
rt_kprintf("one shot timer is timeout\n");
}

/* 检查,清理上次的定时器 */
static void check_timer_exist(void)
{
if (timer1 != RT_NULL)
{
rt_timer_delete(timer1);
timer1 = RT_NULL;
}
if (timer2 != RT_NULL)
{
rt_timer_delete(timer2);
timer2 = RT_NULL;
}
}

int timer_sample(void)
{
/* 检查,清理上次的定时器 */
check_timer_exist();

/* 创建定时器 1 周期定时器 */
timer1 = rt_timer_create("timer1", timeout1,
RT_NULL, 10,
RT_TIMER_FLAG_PERIODIC);

/* 启动定时器 1 */
if (timer1 != RT_NULL) rt_timer_start(timer1);

/* 创建定时器 2 单次定时器 */
timer2 = rt_timer_create("timer2", timeout2,
RT_NULL, 30,
RT_TIMER_FLAG_ONE_SHOT);

/* 启动定时器 2 */
if (timer2 != RT_NULL) rt_timer_start(timer2);
return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(timer_sample, timer sample);

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 \ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 24 2018
2006 - 2018 Copyright by rt-thread team
msh >timer_sample
msh >periodic timer is timeout 0
periodic timer is timeout 1
one shot timer is timeout
periodic timer is timeout 2
periodic timer is timeout 3
periodic timer is timeout 4
periodic timer is timeout 5
periodic timer is timeout 6
periodic timer is timeout 7
periodic timer is timeout 8
periodic timer is timeout 9
periodic timer was stopped!

周期性定时器 1 的超时函数,每 10 个 OS Tick 运行 1 次,共运行 10 次(10 次后调用 rt_timer_stop 使定时器 1 停止运行);单次定时器 2 的超时函数在第 30 个 OS Tick 时运行一次。

初始化定时器的例子与创建定时器的例子类似,这个程序会初始化 2 个静态定时器,一个是单次定时,一个是周期性的定时,如下代码所示:

初始化静态定时器例程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <rtthread.h>

/* 定时器的控制块 */
static struct rt_timer timer1;
static struct rt_timer timer2;
static int cnt = 0;

/* 定时器 1 超时函数 */
static void timeout1(void* parameter)
{
rt_kprintf("periodic timer is timeout\n");
/* 运行 10 次 */
if (cnt++>= 9)
{
cnt = 0;
rt_timer_stop(&timer1);
}
}

/* 定时器 2 超时函数 */
static void timeout2(void* parameter)
{
rt_kprintf("one shot timer is timeout\n");
}

/* 检查,清理上次的定时器 */
static void check_timer_exist(void)
{
if (rt_object_find("timer1", RT_Object_Class_Timer) != RT_NULL)
{
rt_timer_detach(&timer1);
}
if (rt_object_find("timer2", RT_Object_Class_Timer) != RT_NULL)
{
rt_timer_detach(&timer2);
}
}

int timer_static_sample(void)
{
/* 检查,清理上次的定时器 */
check_timer_exist();
/* 初始化定时器 */
rt_timer_init(&timer1, "timer1", /* 定时器名字是 timer1 */
timeout1, /* 超时时回调的处理函数 */
RT_NULL, /* 超时函数的入口参数 */
10, /* 定时长度,以 OS Tick 为单位,即 10 个 OS Tick */
RT_TIMER_FLAG_PERIODIC); /* 周期性定时器 */
rt_timer_init(&timer2, "timer2", /* 定时器名字是 timer2 */
timeout2, /* 超时时回调的处理函数 */
RT_NULL, /* 超时函数的入口参数 */
30, /* 定时长度为 30 个 OS Tick */
RT_TIMER_FLAG_ONE_SHOT); /* 单次定时器 */

/* 启动定时器 */
rt_timer_start(&timer1);
rt_timer_start(&timer2);
return 0;
}
/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(timer_static_sample, timer_static sample);

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
\ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 24 2018
2006 - 2018 Copyright by rt-thread team
msh >timer_static_sample
msh >periodic timer is timeout
periodic timer is timeout
one shot timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout
periodic timer is timeout

周期性定时器 1 的超时函数,每 10 个 OS Tick 运行 1 次,共运行 10 次(10 次后调用 rt_timer_stop 使定时器 1 停止运行);单次定时器 2 的超时函数在第 30 个 OS Tick 时运行一次。

高精度延时

RT-Thread 定时器的最小精度是由系统时钟节拍所决定的(1 OS Tick = 1/RT_TICK_PER_SECOND 秒,RT_TICK_PER_SECOND 值在 rtconfig.h 文件中定义),定时器设定的时间必须是 OS Tick 的整数倍。当需要实现更短时间长度的系统定时时,例如 OS Tick 是 10ms,而程序需要实现 1ms 的定时或延时,这种时候操作系统定时器将不能够满足要求,只能通过读取系统某个硬件定时器的计数器或直接使用硬件定时器的方式。

在 Cortex-M 系列中,SysTick 已经被 RT-Thread 用于作为 OS Tick 使用,它被配置成 1/RT_TICK_PER_SECOND 秒后触发一次中断的方式,中断处理函数使用 Cortex-M3 默认的 SysTick_Handler 名字。在 Cortex-M3 的 CMSIS(Cortex Microcontroller Software Interface Standard)规范中规定了 SystemCoreClock 代表芯片的主频,所以基于 SysTick 以及 SystemCoreClock,我们能够使用 SysTick 获得一个精确的延时函数,如下例所示,Cortex-M3 上的基于 SysTick 的精确延时(需要系统在使能 SysTick 后使用):

高精度延时的例程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <board.h>
void rt_hw_us_delay(rt_uint32_t us)
{
rt_uint32_t ticks;
rt_uint32_t told, tnow, tcnt = 0;
rt_uint32_t reload = SysTick->LOAD;

/* 获得延时经过的 tick 数 */
ticks = us * reload / (1000000 / RT_TICK_PER_SECOND);
/* 获得当前时间 */
told = SysTick->VAL;
while (1)
{
/* 循环获得当前时间,直到达到指定的时间后退出循环 */
tnow = SysTick->VAL;
if (tnow != told)
{
if (tnow < told)
{
tcnt += told - tnow;
}
else
{
tcnt += reload - tnow + told;
}
told = tnow;
if (tcnt >= ticks)
{
break;
}
}
}
}

其中入口参数 us 指示出需要延时的微秒数目,这个函数只能支持低于 1 OS Tick 的延时。

这个其实就是我们之前在蓝桥杯嵌入式的时候利用systick手写延时的ProMax版本,这种写法可以码一下

首先我们明确一个概念,生命周期也是一种泛型,生命周期是Rust中很特别的一个特性,它的存在直接从语言层面避免了很多可能出现的问题。

1
2
3
4
5
6
7
8
9
10
{
let r;
{
let x = 5;
r = &x;
}

println!("r: {}", r);
}

这段代码不能编译因为 r 引用的值在尝试使用之前就离开了作用域

这种悬垂引用在C语言中是可以编译成功的,但是在运行时会出现不可预知的结果,Rust直接在编译阶段就会报错,无法编译,因为借用检查器会阻止这种行为。

Rust 编译器有一个 借用检查器borrow checker),它比较作用域来确保所有的借用都是有效的。示例 10-18 展示了与示例 10-17 相同的例子,不过带有变量生命周期的注释:

1
2
3
4
5
6
7
8
9
10
{
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+

示例 10-18:rx 的生命周期标注,分别叫做 'a'b

这里将 r 的生命周期标记为 'a 并将 x 的生命周期标记为 'b。如你所见,内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,Rust 比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。

让我们看看示例 10-19 中这个并没有产生悬垂引用且可以正确编译的例子:

1
2
3
4
5
6
7
8
{
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
} // ----------+

示例 10-19:一个有效的引用,因为数据比引用有着更长的生命周期

这里 x 拥有生命周期 'b,比 'a 要大。这就意味着 r 可以引用 x:Rust 知道 r 中的引用在 x 有效的时候也总是有效的。

现在我们已经在一个具体的例子中展示了什么是引用的生命周期,并讨论了 Rust 如何分析生命周期来保证引用总是有效的,接下来让我们聊聊在函数的上下文中参数和返回值的泛型生命周期。

函数中的泛型生命周期

让我们来编写一个函数,返回两个字符串 slice 中较长的那一个。这个函数获取两个字符串 slice 并返回一个字符串 slice。一旦我们实现了 longest 函数,示例 10-20 中的代码应该会打印出 The longest string is abcd

文件名: src/main.rs

1
2
3
4
5
6
7
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";

let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}

示例 10-20:main 函数调用 longest 函数来寻找两个字符串 slice 中较长的一个

请注意,这个函数获取作为引用的字符串 slice,因为我们不希望 longest 函数获取参数的所有权。我们期望该函数接受 String 的 slice(参数 string1 的类型)和字符串字面量(包含于参数 string2

参考第 4 章中的 “字符串 slice 作为参数” 部分中更多关于为什么示例 10-20 的参数正符合我们期望的讨论。

如果尝试像示例 10-21 中那样实现 longest 函数,它并不能编译:

文件名: src/main.rs

1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}

示例 10-21:一个 longest 函数的实现,它返回两个字符串 slice 中较长者,现在还不能编译

相应地会出现如下有关生命周期的错误:

1
2
3
4
5
6
7
8
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `x` or `y`

提示文本揭示了返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向 xy。事实上我们也不知道,因为函数体中 if 块返回一个 x 的引用而 else 块返回一个 y 的引用!

当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if 还是 else 会被执行。我们也不知道传入的引用的具体生命周期,所以也就不能像示例 10-18 和 10-19 那样通过观察作用域来确定返回的引用是否总是有效。借用检查器自身同样也无法确定,因为它不知道 xy 的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。

生命周期标注语法

生命周期标注并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期标注描述了多个引用生命周期相互的关系,而不影响其生命周期。

生命周期标注有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。'a 是大多数人默认使用的名称。生命周期参数标注位于引用的 & 之后,并有一个空格来将引用类型与生命周期标注分隔开。

这里有一些例子:我们有一个没有生命周期参数的 i32 的引用,一个有叫做 'a 的生命周期参数的 i32 的引用,和一个生命周期也是 'ai32 的可变引用:

1
2
3
&i32        // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用

单个生命周期标注本身没有多少意义,因为生命周期标注告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。例如如果函数有一个生命周期 'ai32 的引用的参数 first。还有另一个同样是生命周期 'ai32 的引用的参数 second。这两个生命周期标注意味着引用 firstsecond 必须与这泛型生命周期存在得一样久。

函数签名中的生命周期标注

现在来看看 longest 函数的上下文中的生命周期。就像泛型类型参数,泛型生命周期参数需要声明在函数名和参数列表间的尖括号中。这里我们想要告诉 Rust 关于参数中的引用和返回值之间的限制是他们都必须拥有相同的生命周期,就像示例 10-22 中在每个引用中都加上了 'a 那样:

文件名: src/main.rs

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

示例 10-22:longest 函数定义指定了签名中所有的引用必须有相同的生命周期 'a

这段代码能够编译并会产生我们希望得到的示例 10-20 中的 main 函数的结果。

现在函数签名表明对于某些生命周期 'a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a 存在的一样长的字符串 slice。它的实际含义是 longest 函数返回的引用的生命周期与传入该函数的引用的生命周期的较小者一致。这就是我们告诉 Rust 需要其保证的约束条件。记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest 函数并不需要知道 xy 具体会存在多久,而只需要知道有某个可以被 'a 替代的作用域将会满足这个签名。

当在函数中使用生命周期标注时,这些标注出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust 能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,让 Rust 自身分析出参数或返回值的生命周期几乎是不可能的。这些生命周期在每次函数被调用时都可能不同。这也就是为什么我们需要手动标记生命周期。

当具体的引用被传递给 longest 时,被 'a 所替代的具体生命周期是 x 的作用域与 y 的作用域相重叠的那一部分。换一种说法就是泛型生命周期 'a 的具体生命周期等同于 xy 的生命周期中较小的那一个。因为我们用相同的生命周期参数 'a 标注了返回的引用值,所以返回的引用值就能保证在 xy 中较短的那个生命周期结束之前保持有效。

让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest 函数的使用。示例 10-23 是一个很直观的例子。

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");

{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
}

示例 10-23:通过拥有不同的具体生命周期的 String 值调用 longest 函数

在这个例子中,string1 直到外部作用域结束都是有效的,string2 则在内部作用域中是有效的,而 result 则引用了一些直到内部作用域结束都是有效的值。借用检查器认可这些代码;它能够编译和运行,并打印出 The longest string is long string is long

接下来,让我们尝试另外一个例子,该例子揭示了 result 的引用的生命周期必须是两个参数中较短的那个。以下代码将 result 变量的声明移动出内部作用域,但是将 resultstring2 变量的赋值语句一同留在内部作用域中。接着,使用了变量 resultprintln! 也被移动到内部作用域之外。注意示例 10-24 中的代码不能通过编译:

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}

示例 10-24:尝试在 string2 离开作用域之后使用 result

如果尝试编译会出现如下错误:

1
2
3
4
5
6
7
8
9
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
| ------ borrow later used here

错误表明为了保证 println! 中的 result 是有效的,string2 需要直到外部作用域结束都是有效的。Rust 知道这些是因为(longest)函数的参数和返回值都使用了相同的生命周期参数 'a

如果从人的角度读上述代码,我们可能会觉得这个代码是正确的。 string1 更长,因此 result 会包含指向 string1 的引用。因为 string1 尚未离开作用域,对于 println! 来说 string1 的引用仍然是有效的。然而,我们通过生命周期参数告诉 Rust 的是: longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许示例 10-24 中的代码,因为它可能会存在无效的引用。

请尝试更多采用不同的值和不同生命周期的引用作为 longest 函数的参数和返回值的实验。并在开始编译前猜想你的实验能否通过借用检查器,接着编译一下看看你的理解是否正确!

深入理解生命周期

指定生命周期参数的正确方式依赖函数实现的具体功能。例如,如果将 longest 函数的实现修改为总是返回第一个参数而不是最长的字符串 slice,就不需要为参数 y 指定一个生命周期。如下代码将能够编译:

文件名: src/main.rs

1
2
3
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}

在这个例子中,我们为参数 x 和返回值指定了生命周期参数 'a,不过没有为参数 y 指定,因为 y 的生命周期与参数 x 和返回值的生命周期没有任何关系。

当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用 没有 指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。尝试考虑这个并不能编译的 longest 函数实现:

文件名: src/main.rs

1
2
3
4
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}

即便我们为返回值指定了生命周期参数 'a,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。这里是会出现的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
error[E0597]: `result` does not live long enough
--> src/main.rs:3:5
|
3 | result.as_str()
| ^^^^^^ does not live long enough
4 | }
| - borrowed value only lives until here
|
note: borrowed value must be valid for the lifetime 'a as defined on the
function body at 1:1...
--> src/main.rs:1:1
|
1 | / fn longest<'a>(x: &str, y: &str) -> &'a str {
2 | | let result = String::from("really long string");
3 | | result.as_str()
4 | | }
| |_^

出现的问题是 resultlongest 函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result 的引用。无法指定生命周期参数来改变悬垂引用,而且 Rust 也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。

类似于C语言中不能返回一个局部变量的值,二者是比较类似的

综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。

结构体定义中的生命周期标注

目前为止,我们只定义过有所有权类型的结构体。接下来,我们将定义包含引用的结构体,不过这需要为结构体定义中的每一个引用添加生命周期标注。示例 10-25 中有一个存放了一个字符串 slice 的结构体 ImportantExcerpt

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.')
.next()
.expect("Could not find a '.'");
let i = ImportantExcerpt { part: first_sentence };
}

示例 10-25:一个存放引用的结构体,所以其定义需要生命周期标注

这个结构体有一个字段,part,它存放了一个字符串 slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个标注意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在的更久。

这里的 main 函数创建了一个 ImportantExcerpt 的实例,它存放了变量 novel 所拥有的 String 的第一个句子的引用。novel 的数据在 ImportantExcerpt 实例创建之前就存在。另外,直到 ImportantExcerpt 离开作用域之后 novel 都不会离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。

生命周期省略(Lifetime Elision)

现在我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。然而,第 4 章的示例 4-9 中有一个函数,如示例 10-26 所示,它没有生命周期标注却能编译成功:

文件名: src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}

&s[..]
}

示例 10-26:示例 4-9 定义了一个没有使用生命周期标注的函数,即便其参数和返回值都是引用

这个函数没有生命周期标注却能编译是由于一些历史原因:在早期版本(pre-1.0)的 Rust 中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:

1
fn first_word<'a>(s: &'a str) -> &'a str {

在编写了很多 Rust 代码后,Rust 团队发现在特定情况下 Rust 开发者们总是重复地编写一模一样的生命周期标注。这些场景是可预测的并且遵循几个明确的模式。接着 Rust 团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制开发者显式的增加标注。

这里我们提到一些 Rust 的历史是因为更多的明确的模式被合并和添加到编译器中是完全可能的。未来只会需要更少的生命周期标注。

被编码进 Rust 引用分析的模式被称为 生命周期省略规则lifetime elision rules)。这并不是需要开发者遵守的规则;这些规则是一系列特定的场景,此时编译器会考虑,如果代码符合这些场景,就无需明确指定生命周期。

省略规则并不提供完整的推断:如果 Rust 在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。在这种情况,编译器会给出一个错误,这可以通过增加对应引用之间相联系的生命周期标注来解决。

函数或方法的参数的生命周期被称为 输入生命周期input lifetimes),而返回值的生命周期被称为 输出生命周期output lifetimes)。

编译器采用三条规则来判断引用何时不需要明确的标注。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于 fn 定义,以及 impl 块。

第一条规则是每一个是引用的参数都有它自己的生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。

第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32

第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 &self&mut self,说明是个对象的方法(method)(译者注: 这里涉及 Rust 的面向对象,参见第 17 章), 那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。

假设我们自己就是编译器。并应用这些规则来计算示例 10-26 中 first_word 函数签名中的引用的生命周期。开始时签名中的引用并没有关联任何生命周期:

1
fn first_word(s: &str) -> &str {

接着编译器应用第一条规则,也就是每个引用参数都有其自己的生命周期。我们像往常一样称之为 'a,所以现在签名看起来像这样:

1
fn first_word<'a>(s: &'a str) -> &str {

对于第二条规则,因为这里正好只有一个输入生命周期参数所以是适用的。第二条规则表明输入参数的生命周期将被赋予输出生命周期参数,所以现在签名看起来像这样:

1
fn first_word<'a>(s: &'a str) -> &'a str {

现在这个函数签名中的所有引用都有了生命周期,如此编译器可以继续它的分析而无须开发者标记这个函数签名中的生命周期。

让我们再看看另一个例子,这次我们从示例 10-21 中没有生命周期参数的 longest 函数开始:

1
fn longest(x: &str, y: &str) -> &str {

再次假设我们自己就是编译器并应用第一条规则:每个引用参数都有其自己的生命周期。这次有两个参数,所以就有两个(不同的)生命周期:

1
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

再来应用第二条规则,因为函数存在多个输入生命周期,它并不适用于这种情况。再来看第三条规则,它同样也不适用,这是因为没有 self 参数。应用了三个规则之后编译器还没有计算出返回值类型的生命周期。这就是我们在尝试编译示例 10-21 中的代码时出现错误的原因:编译器使用所有已知的生命周期省略规则,仍不能计算出签名中所有引用的生命周期。

因为第三条规则真正能够适用的就只有方法签名,现在就让我们看看那种情况中的生命周期,并看看为什么这条规则意味着我们经常不需要在方法签名中标注生命周期。

关于这几条规则,AI是这么说的

你描述的“生命周期省略规则”(Lifetime Elision Rules)是 Rust 编译器的一项便利功能,它旨在减少你在编写 Rust 代码时手动添加生命周期注解的次数。简单来说,这些规则是编译器内置的一些模式识别能力,当你的代码符合这些模式时,编译器就能自动推断引用的生命周期,而不需要你显式地写出来。

生命周期的核心问题回顾

在深入规则之前,我们先快速回顾一下为什么需要生命周期:

  • 安全保证: Rust 的核心优势之一是内存安全,没有垃圾回收器。它通过所有权系统和借用检查器来实现这一点。
  • 引用的有效性: 当你创建一个引用(例如 &str),这个引用不能比它所指向的数据“活”得更久。如果引用比数据活得久,就会变成悬垂引用,指向一片无效的内存。
  • 生命周期注解: 为了确保引用的有效性,Rust 引入了生命周期注解(如 'a),它们不是改变引用的生命周期,而是告诉借用检查器:这个引用需要和它所指的数据一样长寿,或者更短寿。当函数接收引用参数并返回引用时,编译器需要知道输入引用和输出引用之间的生命周期关系,以确保返回的引用是有效的。

生命周期省略规则的由来

在 Rust 的早期版本中,每个引用都必须有明确的生命周期注解,这导致代码中充斥着大量的 'a'b 等,使得代码显得冗长且难以阅读。Rust 团队发现,在大多数常见场景下,开发者总是以相同的方式标注生命周期。于是,他们将这些常见的、可预测的模式“硬编码”到编译器中,让编译器在这些情况下自动推断。这些模式就是“生命周期省略规则”。

注意:

  • 这些规则是编译器内部的逻辑,而不是你需要遵循的编码规范。你不需要刻意去记住它们并应用到你的代码中。
  • 它们不提供完整的推断。如果根据这些规则,引用的生命周期仍然模棱两可,编译器会报错,要求你手动添加注解。

三条生命周期省略规则详解

编译器在判断何时可以省略生命周期注解时,会按照以下三条规则顺序检查。如果所有规则都应用完了,仍然有不明确的生命周期,编译器就会报错。

1. 输入生命周期规则 (针对函数或方法的参数)

规则: 函数或方法中,每个是引用类型的参数都拥有自己独立的生命周期参数。

例子:

  • 如果函数有一个引用参数 s: &str,它会变成 s: &’a str。

    fn first_word(s: &str) → fn first_word<’a>(s: &’a str)

  • 如果函数有两个引用参数 x: &str, y: &str,它们会分别获得不同的生命周期。

    fn longest(x: &str, y: &str) → fn longest<’a, ‘b>(x: &’a str, y: &’b str)

  • 如果有一个引用参数和一个非引用参数,只有引用参数会获得生命周期。

    fn foo(x: &’a i32, y: i32) → fn foo<’a>(x: &’a i32, y: i32)

2. 输出生命周期规则 (针对函数或方法的返回值)

规则: 如果函数或方只有一个输入生命周期参数**,那么这个唯一的输入生命周期参数会被赋给所有输出生命周期参数。

例子:

  • 考虑 first_word 函数:fn first_word<’a>(s: &’a str) -> &str。

    因为只有一个输入生命周期 ‘a,根据这条规则,返回值 &str 也会被赋予 ‘a。

    最终签名变为:fn first_word<’a>(s: &’a str) -> &’a str。

    这意味着 first_word 返回的引用和输入引用 s 具有相同的生命周期。

3. 方法的输出生命周期规则 (针对带有 &self&mut self 的方法)

规则: 如果方法有多个输入生命周期参数,并且其中一个参数是 &self&mut self(即这是一个方法,而不是普通的函数),那么所有输出生命周期参数都被赋予 self 的生命周期。

例子:

假设有一个结构体 struct MyStruct<’a> { data: &’a str },以及它的一个方法:

impl<’a> MyStruct<’a> { fn get_data(&self) -> &str { self.data } }

  • 应用第一条规则:fn get_data<’b>(&’b self) -> &str

    (注意,MyStruct 可能已经有自己的生命周期 ‘a,但 &self 自身作为参数会获得一个新的生命周期 ‘b。实际上,Rust 会聪明地把 &self 的生命周期和结构体本身的生命周期关联起来。)

  • 由于有 &self 参数,并且可能有其他输入生命周期(尽管在这个简单例子中没有),第三条规则适用。它会将 self 的生命周期赋给返回值。

    最终签名(实际上是编译器推断出的):fn get_data(&’a self) -> &’a str

    这意味着方法返回的引用和 self 实例的生命周期相同。 这对于对象方法来说非常方便,因为它们通常返回对自身数据的引用。

first_wordlongest 函数的推断过程:

first_word 函数 (示例 10-26):

1
fn first_word(s: &str) -> &str
  1. 应用第一条规则: s: &str 是一个引用参数,所以它获得自己的生命周期。

    fn first_word<’a>(s: &’a str) -> &str

  2. 应用第二条规则: 只有一个输入生命周期参数 (‘a),所以它被赋给输出生命周期。

    fn first_word<’a>(s: &’a str) -> &’a str

  3. 结果: 所有引用都有了明确的生命周期,编译器可以成功编译。

longest 函数 (示例 10-21):

1
fn longest(x: &str, y: &str) -> &str
  1. 应用第一条规则: x: &str 和 y: &str 都是引用参数,所以它们分别获得自己的生命周期。

    fn longest<’a, ‘b>(x: &’a str, y: &’b str) -> &str

  2. 应用第二条规则: 无法适用,因为有多个输入生命周期 ('a'b')。

  3. 应用第三条规则: 无法适用,因为这不是一个方法(没有 &self&mut self 参数)。

  4. 结果: 编译器无法推断出返回值 &str 的生命周期。它不知道返回的引用是和 ‘a 一样长寿,还是和 ‘b 一样长寿。这会导致一个编译错误,要求你手动添加生命周期注解,比如:

    fn longest<’a>(x: &’a str, y: &’a str) -> &’a str (这表示返回的引用和 x、y 中生命周期较短的那个一样长)

总结

生命周期省略规则极大地简化了 Rust 代码的编写,减少了不必要的生命周期注解,使得代码更加清晰易读。它们是编译器为了方便开发者而提供的“智能推断”,而非必须手动遵守的规则。当你遇到编译器报错提示生命周期不明确时,通常就是因为你的代码不符合这三条省略规则,你需要手动添加相应的生命周期注解来帮助编译器理解引用之间的关系。

方法定义中的生命周期标注

当为带有生命周期的结构体实现方法时,其语法依然类似示例 10-11 中展示的泛型类型参数的语法。声明和使用生命周期参数的位置依赖于生命周期参数是否同结构体字段或方法参数和返回值相关。

(实现方法时)结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期标注。让我们看看一些使用示例 10-25 中定义的结构体 ImportantExcerpt 的例子。

首先,这里有一个方法 level。其唯一的参数是 self 的引用,而且返回值只是一个 i32,并不引用任何值:

1
2
3
4
5
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}

impl 之后和类型名称之后的生命周期参数是必要的,不过因为第一条生命周期规则我们并不必须标注 self 引用的生命周期。

这里是一个适用于第三条生命周期省略规则的例子:

1
2
3
4
5
6
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}

这里有两个输入生命周期,所以 Rust 应用第一条生命周期省略规则并给予 &selfannouncement 他们各自的生命周期。接着,因为其中一个参数是 &self,返回值类型被赋予了 &self 的生命周期,这样所有的生命周期都被计算出来了。

静态生命周期

这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面量都拥有 'static 生命周期,我们也可以选择像下面这样标注出来:

1
let s: &'static str = "I have a static lifetime.";

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面量都是 'static 的。

你可能在错误信息的帮助文本中见过使用 'static 生命周期的建议,不过将引用指定为 'static 之前,思考一下这个引用是否真的在整个程序的生命周期里都有效。你也许要考虑是否希望它存在得这么久,即使这是可能的。大部分情况,代码中的问题是尝试创建一个悬垂引用或者可用的生命周期不匹配,请解决这些问题而不是指定一个 'static 的生命周期。

结合泛型类型参数、trait bounds 和生命周期

让我们简要的看一下在同一函数中指定泛型类型参数、trait bounds 和生命周期的语法!

1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}

这个是示例 10-22 中那个返回两个字符串 slice 中较长者的 longest 函数,不过带有一个额外的参数 annann 的类型是泛型 T,它可以被放入任何实现了 where 从句中指定的 Display trait 的类型。这个额外的参数会在函数比较字符串 slice 的长度之前被打印出来,这也就是为什么 Display trait bound 是必须的。因为生命周期也是泛型,所以生命周期参数 'a 和泛型类型参数 T 都位于函数名后的同一尖括号列表中。

在之前的STM32开发过程中,我一直是使用裸机开发,裸机开发对MCU性能占用低,但是不够灵活,没法真正意义上实现并行,就算实现了简单的调度器,也是非阻塞式的,不够好用。

线程管理的功能特点

RT-Thread 线程管理的主要功能是对线程进行管理和调度,系统中总共存在两类线程,分别是系统线程和用户线程,系统线程是由 RT-Thread 内核创建的线程,用户线程是由应用程序创建的线程,这两类线程都会从内核对象容器中分配线程对象,当线程被删除时,也会被从对象容器中删除,如下图所示,每个线程都有重要的属性,如线程控制块、线程栈、入口函数等。

image-20250730222612572

RT-Thread 的线程调度器是抢占式的,主要的工作就是从就绪线程列表中查找最高优先级线程,保证最高优先级的线程能够被运行,最高优先级的任务一旦就绪,总能得到 CPU 的使用权。

当一个运行着的线程使一个比它优先级高的线程满足运行条件,当前线程的 CPU 使用权就被剥夺了,或者说被让出了,高优先级的线程立刻得到了 CPU 的使用权。

如果是中断服务程序使一个高优先级的线程满足运行条件,中断完成时,被中断的线程挂起,优先级高的线程开始运行。

当调度器调度线程切换时,先将当前线程上下文保存起来,当再切回到这个线程时,线程调度器将该线程的上下文信息恢复。

线程的工作机制

线程控制块

在 RT-Thread 中,线程控制块由结构体 struct rt_thread 表示,线程控制块是操作系统用于管理线程的一个数据结构,它会存放线程的一些信息,例如优先级、线程名称、线程状态等,也包含线程与线程之间连接用的链表结构,线程等待事件集合等,详细定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* 线程控制块 */
struct rt_thread
{
/* rt 对象 */
char name[RT_NAME_MAX]; /* 线程名称 */
rt_uint8_t type; /* 对象类型 */
rt_uint8_t flags; /* 标志位 */

rt_list_t list; /* 对象列表 */
rt_list_t tlist; /* 线程列表 */

/* 栈指针与入口指针 */
void *sp; /* 栈指针 */
void *entry; /* 入口函数指针 */
void *parameter; /* 参数 */
void *stack_addr; /* 栈地址指针 */
rt_uint32_t stack_size; /* 栈大小 */

/* 错误代码 */
rt_err_t error; /* 线程错误代码 */
rt_uint8_t stat; /* 线程状态 */

/* 优先级 */
rt_uint8_t current_priority; /* 当前优先级 */
rt_uint8_t init_priority; /* 初始优先级 */
rt_uint32_t number_mask;

......

rt_ubase_t init_tick; /* 线程初始化计数值 */
rt_ubase_t remaining_tick; /* 线程剩余计数值 */

struct rt_timer thread_timer; /* 内置线程定时器 */

void (*cleanup)(struct rt_thread *tid); /* 线程退出清除函数 */
rt_uint32_t user_data; /* 用户数据 */
};

其中 init_priority 是线程创建时指定的线程优先级,在线程运行过程当中是不会被改变的(除非用户执行线程控制函数进行手动调整线程优先级)。cleanup 会在线程退出时,被空闲线程回调一次以执行用户设置的清理现场等工作。最后的一个成员 user_data 可由用户挂接一些数据信息到线程控制块中,以提供一种类似线程私有数据的实现方式。

线程重要属性

线程栈

RT-Thread 线程具有独立的栈,当进行线程切换时,会将当前线程的上下文存在栈中,当线程要恢复运行时,再从栈中读取上下文信息,进行恢复。

线程栈还用来存放函数中的局部变量:函数中的局部变量从线程栈空间中申请;函数中局部变量初始时从寄存器中分配(ARM 架构),当这个函数再调用另一个函数时,这些局部变量将放入栈中。

对于线程第一次运行,可以以手工的方式构造这个上下文来设置一些初始的环境:入口函数(PC 寄存器)、入口参数(R0 寄存器)、返回位置(LR 寄存器)、当前机器运行状态(CPSR 寄存器)。

线程栈的增长方向是芯片构架密切相关的,RT-Thread 3.1.0 以前的版本,均只支持栈由高地址向低地址增长的方式,对于 ARM Cortex-M 架构,线程栈可构造如下图所示。

image-20250730223159009

线程栈大小可以这样设定,对于资源相对较大的 MCU,可以适当设计较大的线程栈;也可以在初始时设置较大的栈,例如指定大小为 1K 或 2K 字节,然后在 FinSH 中用 list_thread 命令查看线程运行的过程中线程所使用的栈的大小,通过此命令,能够看到从线程启动运行时,到当前时刻点,线程使用的最大栈深度,而后加上适当的余量形成最终的线程栈大小,最后对栈空间大小加以修改。

关于FinSH,ai给了以下解释


FinSH 是 RT-Thread 实时操作系统中一个非常有用的命令行调试组件。你可以把它想象成嵌入式设备上的一个交互式命令行终端

FinSH 是什么?

在 RT-Thread 中,FinSH(或者更常被称为 FinSH 控制台FinSH Shell)提供了一个与运行在微控制器上的 RT-Thread 系统进行交互的接口。通过这个接口,开发者可以在运行时执行各种命令,查询系统状态,调试程序,甚至调用应用程序中的函数。

就像你在电脑上使用命令提示符(Windows 的 CMD 或 PowerShell)或终端(Linux/macOS 的 Bash)一样,FinSH 让你能够通过串口(通常是 UART)或其他通信接口,向运行在 MCU 上的 RT-Thread 系统发送命令并接收输出。

FinSH 的主要功能和作用

  1. 系统信息查询:
    • list_thread: 正如你提到的,这是 FinSH 最常用的命令之一。它可以列出系统中所有线程的信息,包括线程名、状态、优先级、当前已使用的栈大小以及历史最大栈深度等。这对于调试线程栈溢出问题和优化栈空间非常关键。
    • list_mem: 查看内存使用情况。
    • list_sem: 查看信号量信息。
    • list_mq: 查看消息队列信息。
    • version: 查看 RT-Thread 版本信息。
  2. 动态调试:
    • 线程管理: 可以暂停、恢复、启动或停止线程。
    • 变量/内存查看: 某些 FinSH 配置允许你查看或修改内存地址上的值。
  3. 函数调用:
    • FinSH 支持注册 C/C++ 函数,然后可以直接在命令行中调用这些函数。这对于测试特定的功能模块或者改变程序运行时的一些参数非常方便,无需重新编译和烧录固件。
  4. 文件系统操作:
    • 如果 RT-Thread 配置了文件系统(如 DFS),FinSH 可以用来执行文件操作命令,比如 ls (列出文件)、cd (切换目录)、mkdir (创建目录)、rm (删除文件) 等。
  5. 设备操作:
    • 可以查看或控制通过 RT-Thread 设备框架注册的设备。

为什么 FinSH 对线程栈大小设定很重要?

你引用的这段话,恰恰说明了 FinSH 在线程栈大小优化中的关键作用:

  1. 初始设定: 在开发初期,你可能对每个线程需要多少栈空间没有准确概念,所以会设置一个相对宽松(大一些)的值,例如 1KB 或 2KB。
  2. 运行时监测: 当程序在实际环境中运行一段时间后,通过 FinSH 的 list_thread 命令,你可以精确地看到每个线程在运行过程中实际使用过的最大栈深度。这个值非常重要,因为它反映了线程在最“忙碌”或调用链最深时的栈使用情况。
  3. 优化与调整: 根据 list_thread 报告的最大栈深度,你可以为线程栈添加一个适当的余量(通常是 10%~20% 或一个固定大小),然后将线程栈大小调整为这个更精确的值。这样既可以避免栈溢出(如果初始值太小),又能节省宝贵的 RAM 资源(如果初始值太大)。

总结

所以,FinSH 就是 RT-Thread 的一个交互式控制台工具,它让你能够在不重新烧录固件的情况下,深入了解和控制运行中的嵌入式系统。对于调试线程栈大小这样的内存优化任务,list_thread 命令通过提供线程的历史最大栈深度,成为了不可或缺的利器。

线程状态

线程运行的过程中,同一时间内只允许一个线程在处理器中运行,从运行的过程上划分,线程有多种不同的运行状态,如初始状态、挂起状态、就绪状态等。在 RT-Thread 中,线程包含五种状态,操作系统会自动根据它运行的情况来动态调整它的状态。 RT-Thread 中线程的五种状态,如下表所示:

状态 描述
初始状态 当线程刚开始创建还没开始运行时就处于初始状态;在初始状态下,线程不参与调度。此状态在 RT-Thread 中的宏定义为 RT_THREAD_INIT
就绪状态 在就绪状态下,线程按照优先级排队,等待被执行;一旦当前线程运行完毕让出处理器,操作系统会马上寻找最高优先级的就绪态线程运行。此状态在 RT-Thread 中的宏定义为 RT_THREAD_READY
运行状态 线程当前正在运行。在单核系统中,只有 rt_thread_self() 函数返回的线程处于运行状态;在多核系统中,可能就不止这一个线程处于运行状态。此状态在 RT-Thread 中的宏定义为 RT_THREAD_RUNNING
挂起状态 也称阻塞态。它可能因为资源不可用而挂起等待,或线程主动延时一段时间而挂起。在挂起状态下,线程不参与调度。此状态在 RT-Thread 中的宏定义为 RT_THREAD_SUSPEND
关闭状态 当线程运行结束时将处于关闭状态。关闭状态的线程不参与线程的调度。此状态在 RT-Thread 中的宏定义为 RT_THREAD_CLOSE

线程优先级

RT-Thread 线程的优先级是表示线程被调度的优先程度。每个线程都具有优先级,线程越重要,赋予的优先级就应越高,线程被调度的可能才会越大。

RT-Thread 最大支持 256 个线程优先级 (0~255),数值越小的优先级越高,0 为最高优先级。在一些资源比较紧张的系统中,可以根据实际情况选择只支持 8 个或 32 个优先级的系统配置;对于 ARM Cortex-M 系列,普遍采用 32 个优先级。最低优先级默认分配给空闲线程使用,用户一般不使用。在系统中,当有比当前线程优先级更高的线程就绪时,当前线程将立刻被换出,高优先级线程抢占处理器运行。

时间片

每个线程都有时间片这个参数,但时间片仅对优先级相同的就绪态线程有效。系统对优先级相同的就绪态线程采用时间片轮转的调度方式进行调度时,时间片起到约束线程单次运行时长的作用,其单位是一个系统节拍(OS Tick),详见《时钟管理》章节。假设有 2 个优先级相同的就绪态线程 A 与 B,A 线程的时间片设置为 10,B 线程的时间片设置为 5,那么当系统中不存在比 A 优先级高的就绪态线程时,系统会在 A、B 线程间来回切换执行,并且每次对 A 线程执行 10 个节拍的时长,对 B 线程执行 5 个节拍的时长。

这个时间节拍应该是来自于SysTick这个外设

线程的入口函数

线程控制块中的 entry 是线程的入口函数,它是线程实现预期功能的函数。线程的入口函数由用户设计实现,一般有以下两种代码形式:

-无限循环模式:

在实时系统中,线程通常是被动式的:这个是由实时系统的特性所决定的,实时系统通常总是等待外界事件的发生,而后进行相应的服务:

大概就是你这个函数的执行完全是由操作系统来决定的,所以内部的while(1)循环并不会真的一直执行下去

1
2
3
4
5
6
7
8
9
void thread_entry(void* paramenter)
{
while (1)
{
/* 等待事件的发生 */

/* 对事件进行服务、进行处理 */
}
}

线程看似没有什么限制程序执行的因素,似乎所有的操作都可以执行。但是作为一个实时系统,一个优先级明确的实时系统,如果一个线程中的程序陷入了死循环操作,那么比它优先级低的线程都将不能够得到执行。所以在实时操作系统中必须注意的一点就是:线程中不能陷入死循环操作,**必须要有让出 CPU 使用权的动作,如循环中调用延时函数或者主动挂起。**用户设计这种无限循环的线程的目的,就是为了让这个线程一直被系统循环调度运行,永不删除。

-顺序执行或有限次循环模式:

如简单的顺序语句、do while() 或 for()循环等,此类线程不会循环或不会永久循环,可谓是 “一次性” 线程,一定会被执行完毕。在执行完毕后,线程将被系统自动删除。

1
2
3
4
5
6
7
8
static void thread_entry(void* parameter)
{
/* 处理事务 #1 */

/* 处理事务 #2 */

/* 处理事务 #3 */
}

线程错误码

一个线程就是一个执行场景,错误码是与执行环境密切相关的,所以每个线程配备了一个变量用于保存错误码,线程的错误码有以下几种:

1
2
3
4
5
6
7
8
9
10
11
#define RT_EOK           0 /* 无错误     */
#define RT_ERROR 1 /* 普通错误 */
#define RT_ETIMEOUT 2 /* 超时错误 */
#define RT_EFULL 3 /* 资源已满 */
#define RT_EEMPTY 4 /* 无资源 */
#define RT_ENOMEM 5 /* 无内存 */
#define RT_ENOSYS 6 /* 系统不支持 */
#define RT_EBUSY 7 /* 系统忙 */
#define RT_EIO 8 /* IO 错误 */
#define RT_EINTR 9 /* 中断系统调用 */
#define RT_EINVAL 10 /* 非法参数 */

C语言的错误处理还是相对原始,也没有Rust那种传递错误之类的特性,目前来看Rt-Thread也是通过返回错误码来解决错误处理的

线程状态切换

image-20250730224231420

Rt-Thread提供了一系列函数供线程使用

线程通过调用函数 rt_thread_create/init() 进入到初始状态(RT_THREAD_INIT);初始状态的线程通过调用函数 rt_thread_startup() 进入到就绪状态(RT_THREAD_READY);就绪状态的线程被调度器调度后进入运行状态(RT_THREAD_RUNNING);当处于运行状态的线程调用 rt_thread_delay(),rt_sem_take(),rt_mutex_take(),rt_mb_recv() 等函数或者获取不到资源时,将进入到挂起状态(RT_THREAD_SUSPEND);处于挂起状态的线程,如果等待超时依然未能获得资源或由于其他线程释放了资源,那么它将返回到就绪状态。挂起状态的线程,如果调用 rt_thread_delete/detach() 函数,将更改为关闭状态(RT_THREAD_CLOSE);而运行状态的线程,如果运行结束,就会在线程的最后部分执行 rt_thread_exit() 函数,将状态更改为关闭状态。

系统线程

前文中已提到,系统线程是指由系统创建的线程,用户线程是由用户程序调用线程管理接口创建的线程,在 RT-Thread 内核中的系统线程有空闲线程和主线程。

空闲线程

空闲线程(idle)是系统创建的最低优先级的线程,线程状态永远为就绪态。当系统中无其他就绪线程存在时,调度器将调度到空闲线程,它通常是一个死循环,且永远不能被挂起。另外,空闲线程在 RT-Thread 也有着它的特殊用途:

若某线程运行完毕,系统将自动删除线程:自动执行 rt_thread_exit() 函数,先将该线程从系统就绪队列中删除,再将该线程的状态更改为关闭状态,不再参与系统调度,然后挂入 rt_thread_defunct 僵尸队列(资源未回收、处于关闭状态的线程队列)中,最后空闲线程会回收被删除线程的资源。

空闲线程也提供了接口来运行用户设置的钩子函数,在空闲线程运行时会调用该钩子函数,适合处理功耗管理、看门狗喂狗等工作。空闲线程必须有得到执行的机会,即其他线程不允许一直while(1)死卡,必须调用具有阻塞性质的函数;否则例如线程删除、回收等操作将无法得到正确执行。

主线程

在系统启动时,系统会创建 main 线程,它的入口函数为 main_thread_entry(),用户的应用入口函数 main() 就是从这里真正开始的,系统调度器启动后,main 线程就开始运行,过程如下图,用户可以在 main() 函数里添加自己的应用程序初始化代码。

image-20250730225228737

线程的管理方式

本章前面 2 节对线程的功能与工作机制进行了概念上的讲解,相信大家对线程已经不再陌生。本节将深入到 RT-Thread 线程的各个接口,并给出部分源码,帮助读者在代码层次上理解线程。

下图描述了线程的相关操作,包含:创建 / 初始化线程、启动线程、运行线程、删除 / 脱离线程。可以使用 rt_thread_create() 创建一个动态线程,使用 rt_thread_init() 初始化一个静态线程,动态线程与静态线程的区别是:动态线程是系统自动从动态内存堆上分配栈空间与线程句柄(初始化 heap 之后才能使用 create 创建动态线程),静态线程是由用户分配栈空间与线程句柄。

类似于在普通windows上拿C语言写东西时,直接声明使用的变量在栈上,通过malloc申请出来的内存空间在堆上

线程相关操作

创建和删除线程

一个线程要成为可执行的对象,就必须由操作系统的内核来为它创建一个线程。可以通过如下的接口创建一个动态线程:

1
2
3
4
5
6
rt_thread_t rt_thread_create(const char* name,
void (*entry)(void* parameter),
void* parameter,
rt_uint32_t stack_size,
rt_uint8_t priority,
rt_uint32_t tick);

调用这个函数时,系统会从动态堆内存中分配一个线程句柄以及按照参数中指定的栈大小从动态堆内存中分配相应的空间。分配出来的栈空间是按照 rtconfig.h 中配置的 RT_ALIGN_SIZE 方式对齐。线程创建 rt_thread_create() 的参数和返回值见下表:

参数 描述
name 线程的名称;线程名称的最大长度由 rtconfig.h 中的宏 RT_NAME_MAX 指定,多余部分会被自动截掉
entry 线程入口函数
parameter 线程入口函数参数
stack_size 线程栈大小,单位是字节
priority 线程的优先级。优先级范围根据系统配置情况(rtconfig.h 中的 RT_THREAD_PRIORITY_MAX 宏定义),如果支持的是 256 级优先级,那么范围是从 0~255,数值越小优先级越高,0 代表最高优先级
tick 线程的时间片大小。时间片(tick)的单位是操作系统的时钟节拍。当系统中存在相同优先级线程时,这个参数指定线程一次调度能够运行的最大时间长度。这个时间片运行结束时,调度器自动选择下一个就绪态的同优先级线程进行运行
返回 ——
thread 线程创建成功,返回线程句柄
RT_NULL 线程创建失败

对于栈空间,Ai给了我如下解释

虽然动态线程的内存(包括栈空间)是由系统从动态内存堆中分配的,但系统在分配时依然需要知道具体分配多大的栈空间。这个“多大”就是由 stack_size 参数来决定的。

可以这样理解:

  • 动态线程: 你告诉系统“我需要创建一个线程,它的栈要这么大(stack_size)”。系统就会去动态内存堆上找一块足够大的内存来作为这个线程的栈。
  • 静态线程: 你自己预先准备好一块内存,然后告诉系统“这是我的线程,它的栈就在这里,大小是这么多(stack_size)”。

所以,无论是动态线程还是静态线程,stack_size 都是一个必不可少的参数,因为它定义了线程运行时的栈空间上限,以防止栈溢出并帮助系统进行资源管理。两者的区别仅在于这块栈内存是由谁来分配和管理:动态线程由系统动态分配,静态线程由用户静态分配。

对于一些使用 rt_thread_create() 创建出来的线程,当不需要使用,或者运行出错时,我们可以使用下面的函数接口来从系统中把线程完全删除掉:

1
rt_err_t rt_thread_delete(rt_thread_t thread);

调用该函数后,线程对象将会被移出线程队列并且从内核对象管理器中删除,线程占用的堆栈空间也会被释放,收回的空间将重新用于其他的内存分配。实际上,用 rt_thread_delete() 函数删除线程接口,仅仅是把相应的线程状态更改为 RT_THREAD_CLOSE 状态,然后放入到 rt_thread_defunct 队列中;而真正的删除动作(释放线程控制块和释放线程栈)需要到下一次执行空闲线程时,由空闲线程完成最后的线程删除动作。线程删除 rt_thread_delete() 接口的参数和返回值见下表:

参数 描述
thread 要删除的线程句柄
返回 ——
RT_EOK 删除线程成功
-RT_ERROR 删除线程失败

Note

注:rt_thread_create() 和 rt_thread_delete() 函数仅在使能了系统动态堆时才有效(即 RT_USING_HEAP 宏定义已经定义了)。

初始化和脱离线程

线程的初始化可以使用下面的函数接口完成,来初始化静态线程对象:

1
2
3
4
5
rt_err_t rt_thread_init(struct rt_thread* thread,
const char* name,
void (*entry)(void* parameter), void* parameter,
void* stack_start, rt_uint32_t stack_size,
rt_uint8_t priority, rt_uint32_t tick);

静态线程的线程句柄(或者说线程控制块指针)、线程栈由用户提供。静态线程是指线程控制块、线程运行栈一般都设置为全局变量,在编译时就被确定、被分配处理,内核不负责动态分配内存空间。需要注意的是,用户提供的栈首地址需做系统对齐(例如 ARM 上需要做 4 字节对齐)。线程初始化接口 rt_thread_init() 的参数和返回值见下表:

参数 描述
thread 线程句柄。线程句柄由用户提供出来,并指向对应的线程控制块内存地址
name 线程的名称;线程名称的最大长度由 rtconfig.h 中定义的 RT_NAME_MAX 宏指定,多余部分会被自动截掉
entry 线程入口函数
parameter 线程入口函数参数
stack_start 线程栈起始地址
stack_size 线程栈大小,单位是字节。在大多数系统中需要做栈空间地址对齐(例如 ARM 体系结构中需要向 4 字节地址对齐)
priority 线程的优先级。优先级范围根据系统配置情况(rtconfig.h 中的 RT_THREAD_PRIORITY_MAX 宏定义),如果支持的是 256 级优先级,那么范围是从 0 ~ 255,数值越小优先级越高,0 代表最高优先级
tick 线程的时间片大小。时间片(tick)的单位是操作系统的时钟节拍。当系统中存在相同优先级线程时,这个参数指定线程一次调度能够运行的最大时间长度。这个时间片运行结束时,调度器自动选择下一个就绪态的同优先级线程进行运行
返回 ——
RT_EOK 线程创建成功
-RT_ERROR 线程创建失败

对于用 rt_thread_init() 初始化的线程,使用 rt_thread_detach() 将使线程对象在线程队列和内核对象管理器中被脱离。线程脱离函数如下:

1
rt_err_t rt_thread_detach (rt_thread_t thread);

线程脱离接口 rt_thread_detach() 的参数和返回值见下表:

参数 描述
thread 线程句柄,它应该是由 rt_thread_init 进行初始化的线程句柄。
返回 ——
RT_EOK 线程脱离成功
-RT_ERROR 线程脱离失败

这个函数接口是和 rt_thread_delete() 函数相对应的, rt_thread_delete() 函数操作的对象是 rt_thread_create() 创建的句柄,而 rt_thread_detach() 函数操作的对象是使用 rt_thread_init() 函数初始化的线程控制块。同样,线程本身不应调用这个接口脱离线程本身。

就是不能自己删自己的意思

启动线程

创建(初始化)的线程状态处于初始状态,并未进入就绪线程的调度队列,我们可以在线程初始化 / 创建成功后调用下面的函数接口让该线程进入就绪态:

1
rt_err_t rt_thread_startup(rt_thread_t thread);

当调用这个函数时,将把线程的状态更改为就绪状态,并放到相应优先级队列中等待调度。如果新启动的线程优先级比当前线程优先级高,将立刻切换到这个线程。线程启动接口 rt_thread_startup() 的参数和返回值见下表:

参数 描述
thread 线程句柄
返回 ——
RT_EOK 线程启动成功
-RT_ERROR 线程启动失败

获得当前线程

在程序的运行过程中,相同的一段代码可能会被多个线程执行,在执行的时候可以通过下面的函数接口获得当前执行的线程句柄:

1
rt_thread_t rt_thread_self(void);

该接口的返回值见下表:

返回 描述
thread 当前运行的线程句柄
RT_NULL 失败,调度器还未启动

使线程让出处理器资源

当前线程的时间片用完或者该线程主动要求让出处理器资源时,它将不再占有处理器,调度器会选择相同优先级的下一个线程执行。线程调用这个接口后,这个线程仍然在就绪队列中。线程让出处理器使用下面的函数接口:

1
rt_err_t rt_thread_yield(void);

调用该函数后,当前线程会把自己挂到这个优先级队列链表的尾部,然后激活调度器进行线程上下文切换(如果当前优先级只有这一个线程,则这个线程继续执行,不进行上下文切换动作)。

rt_thread_yield() 函数和 rt_schedule() 函数比较相像,但有相同优先级的其他就绪态线程存在且没有更高优先级的线程存在时,系统的行为却完全不一样。执行 rt_thread_yield() 函数后,当前线程肯定会被换出,相同优先级的下一个就绪线程将被执行。而执行 rt_schedule() 函数后,当前线程并不一定被换出,即使被换出,也不会被放到就绪线程链表的尾部。

注:rt_schedule()是更加底层的函数,rt_thread_yield()还做了上锁,修改当前线程状态的工作,最后调用了rt_schedule() 。rt_thread_yield()是面向用户的接口,rt_schedule()是内核实现调度的内部接口。一般情况下应调用rt_thread_yield()。

使线程睡眠

在实际应用中,我们有时需要让运行的当前线程延迟一段时间,在指定的时间到达后重新运行,这就叫做 “线程睡眠”。线程睡眠可使用以下三个函数接口:

1
2
3
rt_err_t rt_thread_sleep(rt_tick_t tick);
rt_err_t rt_thread_delay(rt_tick_t tick);
rt_err_t rt_thread_mdelay(rt_int32_t ms);

这三个函数接口的作用相同,调用它们可以使当前线程挂起一段指定的时间,当这个时间过后,线程会被唤醒并再次进入就绪状态。这个函数接受一个参数,该参数指定了线程的休眠时间。线程睡眠接口 rt_thread_sleep/delay/mdelay() 的参数和返回值见下表:

参数 描述
tick/ms 线程睡眠的时间: sleep/delay 的传入参数 tick 以 1 个 OS Tick 为单位 ; mdelay 的传入参数 ms 以 1ms 为单位;
返回 ——
RT_EOK 操作成功

挂起和恢复线程

当线程调用 rt_thread_delay() 时,线程将主动挂起;当调用 rt_sem_take(),rt_mb_recv() 等函数时,资源不可使用也将导致线程挂起。处于挂起状态的线程,如果其等待的资源超时(超过其设定的等待时间),那么该线程将不再等待这些资源,并返回到就绪状态;或者,当其他线程释放掉该线程所等待的资源时,该线程也会返回到就绪状态。

rt_thread_mdelay() 内部已经包含了线程挂起和调度器的激活操作,所以您无需手动调用 rt_schedule()

线程挂起使用下面的函数接口:

1
rt_err_t rt_thread_suspend (rt_thread_t thread);

线程挂起接口 rt_thread_suspend() 的参数和返回值见下表:

参数 描述
thread 线程句柄
返回 ——
RT_EOK 线程挂起成功
-RT_ERROR 线程挂起失败,因为该线程的状态并不是就绪状态

注:一个线程尝试挂起另一个线程是一个非常危险的行为,因此RT-Thread对此函数有严格的使用限制:该函数只能使用来挂起当前线程(即自己挂起自己),不可以在线程A中尝试挂起线程B。而且在挂起线程自己后,需要立刻调用 rt_schedule() 函数进行手动的线程上下文切换。这是因为A线程在尝试挂起B线程时,A线程并不清楚B线程正在运行什么程序,一旦B线程正在使用例如互斥量、信号量等影响、阻塞其他线程(如C线程)的内核对象,如果此时其他线程也在等待这个内核对象,那么A线程尝试挂起B线程的操作将会引发其他线程(如C线程)的饥饿,严重危及系统的实时性。

恢复线程就是让挂起的线程重新进入就绪状态,并将线程放入系统的就绪队列中;如果被恢复线程在所有就绪态线程中,位于最高优先级链表的第一位,那么系统将进行线程上下文的切换。线程恢复使用下面的函数接口:

1
rt_err_t rt_thread_resume (rt_thread_t thread);

线程恢复接口 rt_thread_resume() 的参数和返回值见下表:

参数 描述
thread 线程句柄
返回 ——
RT_EOK 线程恢复成功
-RT_ERROR 线程恢复失败,因为该个线程的状态并不是 RT_THREAD_SUSPEND 状态

控制线程

当需要对线程进行一些其他控制时,例如动态更改线程的优先级,可以调用如下函数接口:

1
rt_err_t rt_thread_control(rt_thread_t thread, rt_uint8_t cmd, void* arg);

线程控制接口 rt_thread_control() 的参数和返回值见下表:

函数参数 描述
thread 线程句柄
cmd 指示控制命令
arg 控制参数
返回 ——
RT_EOK 控制执行正确
-RT_ERROR 失败

指示控制命令 cmd 当前支持的命令包括:

  • RT_THREAD_CTRL_CHANGE_PRIORITY:动态更改线程的优先级;
  • RT_THREAD_CTRL_STARTUP:开始运行一个线程,等同于 rt_thread_startup() 函数调用;
  • RT_THREAD_CTRL_CLOSE:关闭一个线程,等同于 rt_thread_delete() 或 rt_thread_detach() 函数调用。

关于钩子函数,Ai给了我们如下解释

在 RT-Thread 中,钩子函数(Hook Function) 是一种特殊的机制,它允许你在操作系统内核的特定事件发生时,插入并执行自己的代码。

可以把它想象成在系统的“关键时刻”预留的“插座”,当这个时刻到来时,系统就会自动调用你插入的“插头”(也就是你的钩子函数)。

钩子函数的作用

钩子函数的主要作用是:

  1. 监控和调试:你可以通过钩子函数来实时监控线程的运行状态、内存使用情况、调度器的行为等。例如,你可以在线程创建、删除、切换时执行代码,以便进行性能分析或记录日志。
  2. 扩展和定制:如果你想在 RT-Thread 的标准行为之外增加一些自定义功能,钩子函数是一个很好的方法。比如,在线程挂起时自动保存一些数据,或者在空闲时执行一些低功耗操作。
  3. 统计和分析:利用钩子函数,你可以轻松地统计线程的 CPU 占用率、上下文切换次数等关键数据,为系统的优化提供依据。

常见的钩子函数类型

RT-Thread 提供了多种钩子函数,覆盖了系统运行的多个重要环节:

  • 线程钩子:用于监控线程的生命周期,例如:

    • rt_thread_inited_hook:在线程初始化时调用。
    • rt_thread_suspend_hook:在线程挂起时调用。
    • rt_thread_resume_hook:在线程恢复时调用。
  • 调度器钩子:用于监控线程切换,例如:

    • rt_scheduler_hook:在调度器进行线程上下文切换时调用。
  • 空闲线程钩子:用于在系统无事可做时执行任务,例如:

    • rt_thread_idle_hook:在空闲线程运行时调用。这通常是实现低功耗或执行后台任务的好地方。

    这是一个钩子点,rt-thread并不提供一个默认的空闲钩子函数,你需要自己注册一个才能使用

如何使用钩子函数?

使用钩子函数非常简单,通常分为两步:

  1. 编写钩子函数:根据你的需求,编写一个符合 RT-Thread 要求的 C 函数。
  2. 注册钩子函数:调用 RT-Thread 提供的注册接口,将你的函数注册到特定的钩子点上。例如,使用 rt_scheduler_sethook() 来注册调度器钩子。

总之,钩子函数是一种非常强大和灵活的机制,它让你可以深入到 RT-Thread 内核,实现高级的监控、调试和功能扩展,而无需修改内核源代码。

设置和删除空闲钩子

空闲钩子函数是空闲线程的钩子函数,如果设置了空闲钩子函数,就可以在系统执行空闲线程时,自动执行空闲钩子函数来做一些其他事情,比如系统指示灯。设置 / 删除空闲钩子的接口如下:

1
2
rt_err_t rt_thread_idle_sethook(void (*hook)(void));
rt_err_t rt_thread_idle_delhook(void (*hook)(void));

设置空闲钩子函数 rt_thread_idle_sethook() 的输入参数和返回值如下表所示:

函数参数 描述
hook 设置的钩子函数
返回 ——
RT_EOK 设置成功
-RT_EFULL 设置失败

删除空闲钩子函数 rt_thread_idle_delhook() 的输入参数和返回值如下表所示:

函数参数 描述
hook 删除的钩子函数
返回 ——
RT_EOK 删除成功
-RT_ENOSYS 删除失败

注:空闲线程是一个线程状态永远为就绪态的线程,因此设置的钩子函数必须保证空闲线程在任何时刻都不会处于挂起状态,例如 rt_thread_delay(),rt_sem_take() 等可能会导致线程挂起的函数都不能使用。并且,由于 malloc、free 等内存相关的函数内部使用了信号量作为临界区保护,因此在钩子函数内部也不允许调用此类函数!

设置调度器钩子

在整个系统的运行时,系统都处于线程运行、中断触发 - 响应中断、切换到其他线程,甚至是线程间的切换过程中,或者说系统的上下文切换是系统中最普遍的事件。有时用户可能会想知道在一个时刻发生了什么样的线程切换,可以通过调用下面的函数接口设置一个相应的钩子函数。在系统线程切换时,这个钩子函数将被调用:

1
void rt_scheduler_sethook(void (*hook)(struct rt_thread* from, struct rt_thread* to));

设置调度器钩子函数的输入参数如下表所示:

函数参数 描述
hook 表示用户定义的钩子函数指针

钩子函数 hook() 的声明如下:

1
void hook(struct rt_thread* from, struct rt_thread* to);

调度器钩子函数 hook() 的输入参数如下表所示:

函数参数 描述
from 表示系统所要切换出的线程控制块指针
to 表示系统所要切换到的线程控制块指针

Note

注:请仔细编写你的钩子函数,稍有不慎将很可能导致整个系统运行不正常(在这个钩子函数中,基本上不允许调用系统 API,更不应该导致当前运行的上下文挂起)。

线程应用示例

下面给出在 Keil 模拟器环境下的应用示例。

创建线程示例

这个例子会创建一个动态线程和一个静态线程,当这个静态线程完成其任务并被系统自动回收后,具有较低优先级的动态线程才能开始运行并打印信息。

注意:RT-Thread 5.0 及更高的版本将 ALIGN 关键字改成了 rt_align,使用时注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <rtthread.h>

#define THREAD_PRIORITY 25
#define THREAD_STACK_SIZE 512
#define THREAD_TIMESLICE 5

static rt_thread_t tid1 = RT_NULL;

/* 线程 1 的入口函数 */
static void thread1_entry(void *parameter)
{
rt_uint32_t count = 0;

for (count = 0; count < 10 ; count++)
{
/* 线程 1 采用低优先级运行 */
rt_kprintf("thread1 count: %d\n", count);
rt_thread_mdelay(500);
}
rt_kprintf("thread1 exit\n");
/* 线程 1 运行结束后也将自动被系统脱离 */
}

#if defined(RT_VERSION_CHECK) && (RTTHREAD_VERSION >= RT_VERSION_CHECK(5, 0, 1))
rt_align(RT_ALIGN_SIZE)
#else
ALIGN(RT_ALIGN_SIZE)
#endif
static char thread2_stack[1024];
static struct rt_thread thread2;
/* 线程 2 入口 */
static void thread2_entry(void *param)
{
rt_uint32_t count = 0;

/* 线程 2 拥有较高的优先级,以抢占线程 1 而获得执行 */
for (count = 0; count < 10 ; count++)
{
/* 线程 2 打印计数值 */
rt_kprintf("thread2 count: %d\n", count);
}
rt_kprintf("thread2 exit\n");
/* 线程 2 运行结束后也将自动被系统脱离 */
}

/* 线程示例 */
int thread_sample(void)
{
/* 创建线程 1,名称是 thread1,入口是 thread1_entry*/
tid1 = rt_thread_create("thread1",
thread1_entry, RT_NULL,
THREAD_STACK_SIZE,
THREAD_PRIORITY, THREAD_TIMESLICE);

/* 如果获得线程控制块,启动这个线程 */
if (tid1 != RT_NULL)
rt_thread_startup(tid1);

/* 初始化线程 2,名称是 thread2,入口是 thread2_entry */
rt_thread_init(&thread2,
"thread2",
thread2_entry,
RT_NULL,
&thread2_stack[0],
sizeof(thread2_stack),
THREAD_PRIORITY - 1, THREAD_TIMESLICE);
rt_thread_startup(&thread2);

return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(thread_sample, thread sample);

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 \ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 24 2018
2006 - 2018 Copyright by rt-thread team
msh >thread_sample
msh >thread2 count: 0
thread2 count: 1
thread2 count: 2
thread2 count: 3
thread2 count: 4
thread2 count: 5
thread2 count: 6
thread2 count: 7
thread2 count: 8
thread2 count: 9
thread2 exit
thread1 count: 0
thread1 count: 1
thread1 count: 2
thread1 count: 3

线程 2 计数到一定值会执行完毕,线程 2 被系统自动删除,计数停止。线程 1 才会打印计数。

注:关于删除线程:大多数线程是循环执行的,无需删除;而能运行完毕的线程,RT-Thread 在线程运行完毕后,自动删除线程,在 rt_thread_exit() 里完成删除动作。用户只需要了解该接口的作用,不推荐使用该接口(可以由其他线程调用此接口或在定时器超时函数中调用此接口删除一个线程,但是这种使用非常少)。

线程时间片轮转调度示例

这个例子创建两个线程,在执行时会一直打印计数,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <rtthread.h>

#define THREAD_STACK_SIZE 1024
#define THREAD_PRIORITY 20
#define THREAD_TIMESLICE 10

/* 线程入口 */
static void thread_entry(void* parameter)
{
rt_uint32_t value;
rt_uint32_t count = 0;

value = (rt_uint32_t)parameter;
while (1)
{
if(0 == (count % 5))
{
rt_kprintf("thread %d is running ,thread %d count = %d\n", value , value , count);
if(count> 200)
return;
}
count++;
}
}

int timeslice_sample(void)
{
rt_thread_t tid = RT_NULL;
/* 创建线程 1 */
tid = rt_thread_create("thread1",
thread_entry, (void*)1,
THREAD_STACK_SIZE,
THREAD_PRIORITY, THREAD_TIMESLICE);
if (tid != RT_NULL)
rt_thread_startup(tid);


/* 创建线程 2 */
tid = rt_thread_create("thread2",
thread_entry, (void*)2,
THREAD_STACK_SIZE,
THREAD_PRIORITY, THREAD_TIMESLICE-5);
if (tid != RT_NULL)
rt_thread_startup(tid);
return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(timeslice_sample, timeslice sample);

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 \ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 27 2018
2006 - 2018 Copyright by rt-thread team
msh >timeslice_sample
msh >thread 1 is running ,thread 1 count = 0
thread 1 is running ,thread 1 count = 5
thread 1 is running ,thread 1 count = 10
thread 1 is running ,thread 1 count = 15

thread 1 is running ,thread 1 count = 125
thread 1 is rthread 2 is running ,thread 2 count = 0
thread 2 is running ,thread 2 count = 5
thread 2 is running ,thread 2 count = 10
thread 2 is running ,thread 2 count = 15
thread 2 is running ,thread 2 count = 20
thread 2 is running ,thread 2 count = 25
thread 2 is running ,thread 2 count = 30
thread 2 is running ,thread 2 count = 35
thread 2 is running ,thread 2 count = 40
thread 2 is running ,thread 2 count = 45
thread 2 is running ,thread 2 count = 50
thread 2 is running ,thread 2 count = 55
thread 2 is running ,thread 2 count = 60
thread 2 is running ,thread 2 cunning ,thread 2 count = 65
thread 1 is running ,thread 1 count = 135

thread 2 is running ,thread 2 count = 205

由运行的计数结果可以看出,线程 2 的运行时间是线程 1 的一半。

线程调度器钩子示例

在线程进行调度切换时,会执行调度,我们可以设置一个调度器钩子,这样可以在线程切换时,做一些额外的事情,这个例子是在调度器钩子函数中打印线程间的切换信息,如下代码:

注意:RT-Thread5.0 及更高版本将 struct rt_thread 结构体的 name 成员移到了 parent 里,使用时代码需要由 thread->name 更改为 thread->parent.name,否则编译会报错!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <rtthread.h>

#define THREAD_STACK_SIZE 1024
#define THREAD_PRIORITY 20
#define THREAD_TIMESLICE 10

/* 针对每个线程的计数器 */
volatile rt_uint32_t count[2];

/* 线程 1、2 共用一个入口,但入口参数不同 */
static void thread_entry(void* parameter)
{
rt_uint32_t value;

value = (rt_uint32_t)parameter;
for (int count = 0; count < 10 ; count++)
{
rt_kprintf("thread %d is running\n", value);
rt_thread_mdelay(1000); // 延时一段时间
}
}

static rt_thread_t tid1 = RT_NULL;
static rt_thread_t tid2 = RT_NULL;

static void hook_of_scheduler(struct rt_thread* from, struct rt_thread* to)
{
#if defined(RT_VERSION_CHECK) && (RTTHREAD_VERSION >= RT_VERSION_CHECK(5, 0, 1))
rt_kprintf("from: %s --> to: %s \n", from->parent.name ,to->parent.name);
#else
rt_kprintf("from: %s --> to: %s \n", from->name , to->name);
#endif
}

int scheduler_hook(void)
{
/* 设置调度器钩子 */
rt_scheduler_sethook(hook_of_scheduler);

/* 创建线程 1 */
tid1 = rt_thread_create("thread1",
thread_entry, (void*)1,
THREAD_STACK_SIZE,
THREAD_PRIORITY, THREAD_TIMESLICE);
if (tid1 != RT_NULL)
rt_thread_startup(tid1);

/* 创建线程 2 */
tid2 = rt_thread_create("thread2",
thread_entry, (void*)2,
THREAD_STACK_SIZE,
THREAD_PRIORITY,THREAD_TIMESLICE - 5);
if (tid2 != RT_NULL)
rt_thread_startup(tid2);
return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(scheduler_hook, scheduler_hook sample);

int scheduler_del(void)
{
rt_scheduler_sethook(RT_NULL);
return 0;
}

/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(scheduler_del, scheduler_del sample);

static void hook_of_scheduler(struct rt_thread* from, struct rt_thread* to);

调度器钩子函数的返回值跟参数列表是写死的,但是函数名应该是可以改的。

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 \ | /
- RT - Thread Operating System
/ | \ 3.1.0 build Aug 27 2018
2006 - 2018 Copyright by rt-thread team
msh > scheduler_hook
msh >from: tshell --> to: thread1
thread 1 is running
from: thread1 --> to: thread2
thread 2 is running
from: thread2 --> to: tidle
from: tidle --> to: thread1
thread 1 is running
from: thread1 --> to: tidle
from: tidle --> to: thread2
thread 2 is running
from: thread2 --> to: tidle

由仿真的结果可以看出,对线程进行切换时,设置的调度器钩子函数是在正常工作的,一直在打印线程切换的信息,包含切换到空闲线程。可以使用scheduler_del取消调度器的钩子函数。

Trait类似于java语言中的接口

定义 trait

一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。

例如,这里有多个存放了不同类型和属性文本的结构体:结构体 NewsArticle 用于存放发生于世界各地的新闻故事,而结构体 Tweet 最多只能存放 280 个字符的内容,以及像是否转推或是否是对推友的回复这样的元数据。

我们想要创建一个多媒体聚合库用来显示可能储存在 NewsArticleTweet 实例中的数据的总结。每一个结构体都需要的行为是他们是能够被总结的,这样的话就可以调用实例的 summarize 方法来请求总结。示例 10-12 中展示了一个表现这个概念的 Summary trait 的定义:

文件名: src/lib.rs

1
2
3
pub trait Summary {
fn summarize(&self) -> String;
}

示例 10-12:Summary trait 定义,它包含由 summarize 方法提供的行为

这里使用 trait 关键字来声明一个 trait,后面是 trait 的名字,在这个例子中是 Summary。在大括号中声明描述实现这个 trait 的类型所需要的行为的方法签名,在这个例子中是 fn summarize(&self) -> String

在方法签名后跟分号,而不是在大括号中提供其实现。接着每一个实现这个 trait 的类型都需要提供其自定义行为的方法体,编译器也会确保任何实现 Summary trait 的类型都拥有与这个签名的定义完全一致的 summarize 方法。

trait 体中可以有多个方法:一行一个方法签名且都以分号结尾。

为类型实现 trait

现在我们定义了 Summary trait,接着就可以在多媒体聚合库中需要拥有这个行为的类型上实现它了。示例 10-13 中展示了 NewsArticle 结构体上 Summary trait 的一个实现,它使用标题、作者和创建的位置作为 summarize 的返回值。对于 Tweet 结构体,我们选择将 summarize 定义为用户名后跟推文的全部文本作为返回值,并假设推文内容已经被限制为 280 字符以内。

文件名: src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}

impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}

pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}

impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}

示例 10-13:在 NewsArticleTweet 类型上实现 Summary trait

在类型上实现 trait 类似于实现与 trait 无关的方法。区别在于 impl 关键字之后,我们提供需要实现 trait 的名称,接着是 for 和需要实现 trait 的类型的名称。在 impl 块中,使用 trait 定义中的方法签名,不过不再后跟分号,而是需要在大括号中编写函数体来为特定类型实现 trait 方法所拥有的行为。

一旦实现了 trait,我们就可以用与 NewsArticleTweet 实例的非 trait 方法一样的方式调用 trait 方法了:

1
2
3
4
5
6
7
8
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from("of course, as you probably already know, people"),
reply: false,
retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

这会打印出 1 new tweet: horse_ebooks: of course, as you probably already know, people

注意因为示例 10-13 中我们在相同的 lib.rs 里定义了 Summary trait 和 NewsArticleTweet 类型,所以他们是位于同一作用域的。如果这个 lib.rs 是对应 aggregator crate 的,而别人想要利用我们 crate 的功能为其自己的库作用域中的结构体实现 Summary trait。首先他们需要将 trait 引入作用域。这可以通过指定 use aggregator::Summary; 实现,这样就可以为其类型实现 Summary trait 了。Summary 还必须是公有 trait 使得其他 crate 可以实现它,这也是为什么示例 10-12 中将 pub 置于 trait 之前。

Trait 的可见性

当你定义一个 trait 时,你需要考虑它是否应该对其他 crate 可用。

  • 如果一个 trait 只是在你自己的 crate 内部使用,那么它不需要是公共的。
  • 但如果你希望其他 crate 能够为它们自己的类型实现你定义的 trait,那么这个 trait 必须是 pub(公共的)。这就是为什么在示例 10-12 中,Summary trait 前面有 pub 关键字,表示它是公共的,其他 crate 才能看到并实现它。

实现 Trait 的作用域规则

当你想要为一个类型实现某个 trait 时,有一个重要的限制:你只能为你自己的 crate 本地定义的类型实现 trait,或者为你自己的 crate 本地定义的 trait 实现类型。

让我们用例子来说明:

  • 可以实现的情况:
    • 为你自己的类型实现标准库中的 trait 比如,你在 aggregator 这个 crate 中定义了一个自定义类型 Tweet。你可以为这个 Tweet 类型实现标准库中的 Display trait(这个 trait 用于控制类型如何打印输出)。这是因为 Tweet 是你 aggregator crate 本地定义的类型。
    • 为标准库类型实现你自己的 trait 同样地,你可以在 aggregator crate 中为标准库类型 Vec<T>(一个向量类型)实现你自定义的 Summary trait。这是因为 Summary trait 是你 aggregator crate 本地定义的 trait
  • 不能实现的情况(外部类型实现外部 trait):
    • 不能aggregator crate 中为标准库类型 Vec<T> 实现标准库中的 Display trait。为什么呢?因为 DisplayVec<T> 这两个东西都不是aggregator crate 本地定义的。它们都来自标准库,对于你的 aggregator crate 来说,它们都是“外部”的。

孤儿规则(Orphan Rule)的意义

这个限制被称为相干性(coherence),更具体地说是孤儿规则(orphan rule)。这条规则是为了避免潜在的冲突和混乱:

  • 避免冲突: 如果没有这条规则,想象一下:crate AVec<T> 实现了 Display,而 crate B 也为 Vec<T> 实现了 Display。当你的代码同时依赖 crate Acrate B 时,Rust 就会感到困惑,不知道当你想打印 Vec<T> 时,到底应该使用 crate ADisplay 实现还是 crate BDisplay 实现。
  • 保证代码稳定性: 孤儿规则确保了“别人编写的代码不会破坏你的代码,反之亦然”。它防止了不同 crate 对同一外部类型实现相同外部 trait 导致的行为不确定性。

简而言之,孤儿规则保证了每个 trait 实现都必须至少有一个“亲属”在当前 crate 中,要么是你实现了这个 trait 的类型是你自己定义的,要么是你实现的这个 trait 是你自己定义的。

默认实现

有时为 trait 中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。

示例 10-14 中展示了如何为 Summary trait 的 summarize 方法指定一个默认的字符串值,而不是像示例 10-12 中那样只是定义方法签名:

文件名: src/lib.rs

1
2
3
4
5
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}

示例 10-14:Summary trait 的定义,带有一个 summarize 方法的默认实现

如果想要对 NewsArticle 实例使用这个默认实现,而不是定义一个自己的实现,则可以通过 impl Summary for NewsArticle {} 指定一个空的 impl 块。

虽然我们不再直接为 NewsArticle 定义 summarize 方法了,但是我们提供了一个默认实现并且指定 NewsArticle 实现 Summary trait。因此,我们仍然可以对 NewsArticle 实例调用 summarize 方法,如下所示:

1
2
3
4
5
6
7
8
9
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from("The Pittsburgh Penguins once again are the best
hockey team in the NHL."),
};

println!("New article available! {}", article.summarize());

这段代码会打印 New article available! (Read more...)

summarize 创建默认实现并不要求对示例 10-13 中 Tweet 上的 Summary 实现做任何改变。其原因是重载一个默认实现的语法与实现没有默认实现的 trait 方法的语法一样。

默认实现允许调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。如此,trait 可以提供很多有用的功能而只需要实现指定一小部分内容。例如,我们可以定义 Summary trait,使其具有一个需要实现的 summarize_author 方法,然后定义一个 summarize 方法,此方法的默认实现调用 summarize_author 方法:

1
2
3
4
5
6
7
pub trait Summary {
fn summarize_author(&self) -> String;

fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}

为了使用这个版本的 Summary,只需在实现 trait 时定义 summarize_author 即可:

1
2
3
4
5
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}

一旦定义了 summarize_author,我们就可以对 Tweet 结构体的实例调用 summarize 了,而 summarize 的默认实现会调用我们提供的 summarize_author 定义。因为实现了 summarize_authorSummary trait 就提供了 summarize 方法的功能,且无需编写更多的代码。

1
2
3
4
5
6
7
8
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from("of course, as you probably already know, people"),
reply: false,
retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

这会打印出 1 new tweet: (Read more from @horse_ebooks...)

请注意,无法从相同方法的重载实现中调用默认方法。

核心思想是:trait 定义了行为,trait bound 则限制了泛型类型必须拥有这些行为。

1. 将 Trait 作为函数参数

想象一下,你有一个 Summary trait,它规定了任何实现它的类型都应该有一个 summarize 方法。

  • impl Trait 语法(简单写法):

    1
    2
    3
    pub fn notify(item: impl Summary) {
    println!("Breaking news! {}", item.summarize());
    }

    这就像在说:“嘿,notify 函数,我不在乎你收到的是 NewsArticle 还是 Tweet只要它能 summarize 就行!” 这种写法简洁明了,编译器会确保你传递进来的类型确实实现了 Summary。如果传了 Stringi32,编译就会失败,因为它们没有 summarize 方法。

2. Trait Bound 语法(更详细的写法)

impl Trait 只是一个语法糖,它的背后是更正式的 Trait Bound 语法。

  • 基本等价写法:

    1
    2
    3
    pub fn notify<T: Summary>(item: T) {
    println!("Breaking news! {}", item.summarize());
    }

    这和上面的 impl Trait 效果一样,但是明确引入了一个泛型类型 T,并用 <T: Summary> 表示 T 必须实现 Summary

  • 强制多个参数类型一致: Trait Bound 的强大之处在于它可以让你强制多个泛型参数是同一个具体类型

    • 如果你用 impl Trait

      1
      pub fn notify(item1: impl Summary, item2: impl Summary) { /* ... */ }

      item1 可以是 NewsArticleitem2 可以是 Tweet,只要它们都能 summarize 就行。它们可以是不同类型

    • 如果你用 Trait Bound

      1
      pub fn notify<T: Summary>(item1: T, item2: T) { /* ... */ }

      这里 item1item2 都被指定为泛型类型 T。这意味着,如果你给 item1 传了一个 NewsArticle,那么 item2 也必须NewsArticle。它们必须是相同类型

3. + 指定多个 Trait Bound

如果一个类型需要同时实现多个 trait 呢?用 + 符号连接它们!

  • 使用 impl Trait

    1
    pub fn notify(item: impl Summary + Display) { /* ... */ }

    这表示 item 既要能 summarize,也要能被 Display(也就是可以被格式化打印出来)。

  • 使用 Trait Bound

    1
    pub fn notify<T: Summary + Display>(item: T) { /* ... */ }

    效果一样,只是写法不同。

4. where 从句简化 Trait Bound

当你的函数有很多泛型参数,并且每个参数都有多个 trait bound 时,函数签名会变得非常长,难以阅读。where 从句就是为了解决这个问题。

  • 没有 where 从句:

    1
    fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 { /* ... */ }

    你看,尖括号里一堆东西,函数名和参数列表离得好远。

  • 使用 where 从句:

    1
    2
    3
    4
    fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
    U: Clone + Debug
    { /* ... */ }

    这样就清晰多了!trait bound 被移到了单独的 where 从句中,函数签名本身就简洁了。

5. 返回实现了 Trait 的类型

你也可以让函数返回一个实现了特定 trait 的类型,而不需要暴露具体的类型是什么。

  • 语法:

    1
    2
    3
    fn returns_summarizable() -> impl Summary {
    Tweet { /* ... */ }
    }

    这个函数承诺它会返回一个可以被“总结”的东西(impl Summary),但具体是 Tweet 还是 NewsArticle,函数调用方并不需要知道。这对于闭包迭代器特别有用,因为它们的实际类型可能非常复杂,用 impl Trait 就能大大简化代码。

  • 重要限制: returns_summarizable() 这种写法只能返回单一的具体类型

    1
    2
    3
    4
    5
    6
    7
    8
    // 错误示例!不能编译!
    fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
    NewsArticle { /* ... */ }
    } else {
    Tweet { /* ... */ } // 这里返回了不同的类型
    }
    }

    **你不能根据条件返回不同的实现了 Summary 的类型(比如有时返回 NewsArticle,有时返回 Tweet)。如果需要这样做,你得使用 trait object(特性对象),这是第 17 章会讲到的更高级概念。 **

6. 修复 largest 函数

回到最开始 largest 函数的错误:

  • 问题一:不能比较 T 类型(> 运算符)。

    • 因为 > 运算符是 std::cmp::PartialOrd 这个 trait 提供的。

    • 解决方案:T 加上 PartialOrdtrait bound

      1
      fn largest<T: PartialOrd>(list: &[T]) -> T { /* ... */ }
  • 问题二:不能移动非 Copy 类型的值(list[0]for &item)。

    • list[0] 会把第一个元素“移动”出来,for &item 也尝试“解引用并移动”。但如果 T 没有实现 Copy trait,这种移动是不被允许的(因为移动后原位置就“空”了,而切片 &[T] 不允许这种操作)。像 i32char 这种栈上数据默认是 Copy 的,但其他复杂类型可能不是。

    • 解决方案一(简单粗暴):T 再加上 Copytrait bound。这样就限制了 largest 只能用于那些可以简单复制的类型。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
      let mut largest = list[0]; // 现在 T 保证是 Copy 的
      for &item in list.iter() { // 现在 &item 可以拷贝一份
      if item > largest {
      largest = item;
      }
      }
      largest
      }
    • 解决方案二(更通用但可能慢): 如果不想限制 Copy,可以要求 T 实现 Clone,然后在需要的时候显式地克隆数据。但这可能会涉及堆内存分配,效率较低。

    • 解决方案三(最佳实践): 返回一个引用 &T!这样就避免了移动或拷贝数据,直接操作原始数据的引用。你会被鼓励尝试自己实现这个版本。

7. 有条件地实现方法和 Blanket Implementations

  • 有条件实现方法:

    • 你可以给一个泛型结构体(比如 Pair<T>)的 impl 块加上 trait bound

    • 这意味着:Pair<T> 总是有一个 new 方法,但它只有在 T 类型同时实现了 DisplayPartialOrd 时,才会有 cmp_display 方法。

      1
      2
      3
      impl<T: Display + PartialOrd> Pair<T> {
      fn cmp_display(&self) { /* ... */ } // 只有当 T 满足条件时才有
      }
  • Blanket Implementations(毯子实现/覆盖实现):

    • 这是一个非常强大的功能!它允许你为所有满足特定 trait bound 的类型实现另一个 trait

    • 标准库中就有很多例子,比如:

      1
      impl<T: Display> ToString for T { /* ... */ }

      这句话的意思是:“任何实现了 Display trait 的类型,都自动实现 ToString trait。”

    • 这解释了为什么你可以直接对一个整数(比如 3)调用 .to_string()3i32 类型,i32 实现了 Display,所以根据这个“毯子实现”,i32 也就自动实现了 ToString

总结一下:

traittrait bound 是 Rust 泛型系统的基石。它们让你能够编写出:

  • 灵活的代码: 可以处理多种不同类型。
  • 安全的代码: 编译器在编译时就检查类型是否满足所需行为,而不是等到运行时才报错。
  • 高性能的代码: 因为编译时已经确定了类型和行为,运行时不需要额外的检查开销。

这就像是给你的泛型函数或类型打上了“能力标签”,只有拥有这些标签的类型才能被使用,确保了代码的正确性。

之前拿C语言写泛型,可以说是格外吃力并且不好用,因为C语言是相对更低级的语言,语言特性上就没有泛型这么个东西,但是RUST是原生支持了泛型的,现在我们就来看一下。

泛型是什么我这边就不介绍了,依旧是先来看一下官方文档的高速神言。

在函数定义中使用泛型

当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。采用这种技术,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。

回到 largest 函数,示例 10-4 中展示了两个函数,它们的功能都是寻找 slice 中最大值。

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest = item;
}
}

largest
}

fn largest_char(list: &[char]) -> char {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest = item;
}
}

largest
}

fn main() {
let number_list = vec![34, 50, 25, 100, 65];

let result = largest_i32(&number_list);
println!("The largest number is {}", result);

let char_list = vec!['y', 'm', 'a', 'q'];

let result = largest_char(&char_list);
println!("The largest char is {}", result);
}

示例 10-4:两个函数,不同点只是名称和签名类型

largest_i32 函数是从示例 10-3 中摘出来的,它用来寻找 slice 中最大的 i32largest_char 函数寻找 slice 中最大的 char。因为两者函数体的代码一致,我们可以定义一个函数,再引进泛型参数来消除这种重复。

为了参数化新函数中的这些类型,我们也需要为类型参数取个名字,道理和给函数的形参起名一样。任何标识符都可以作为类型参数的名字。这里选用 T,因为传统上来说,Rust 的参数名字都比较短,通常就只有一个字母,同时,Rust 类型名的命名规范是骆驼命名法(CamelCase)。T 作为 “type” 的缩写是大部分 Rust 开发者的首选。

如果要在函数体中使用参数,就必须在函数签名中声明它的名字,好让编译器知道这个名字指代的是什么。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。为了定义泛型版本的 largest 函数,类型参数声明位于函数名称与参数列表中间的尖括号 <> 中,像这样:

1
fn largest<T>(list: &[T]) -> T {

可以这样理解这个定义:函数 largest 有泛型类型 T。它有个参数 list,其类型是元素为 T 的 slice。largest 函数的返回值类型也是 T

示例 10-5 中的 largest 函数在它的签名中使用了泛型,统一了两个实现。该示例也展示了如何调用 largest 函数,把 i32 值的 slice 或 char 值的 slice 传给它。请注意这些代码还不能编译,不过稍后在本章会解决这个问题。

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest = item;
}
}

largest
}

fn main() {
let number_list = vec![34, 50, 25, 100, 65];

let result = largest(&number_list);
println!("The largest number is {}", result);

let char_list = vec!['y', 'm', 'a', 'q'];

let result = largest(&char_list);
println!("The largest char is {}", result);
}

示例 10-5:一个使用泛型参数的 largest 函数定义,尚不能编译

如果现在就编译这个代码,会出现如下错误:

1
2
3
4
5
6
7
error[E0369]: binary operation `>` cannot be applied to type `T`
--> src/main.rs:5:12
|
5 | if item > largest {
| ^^^^^^^^^^^^^^
|
= note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

注释中提到了 std::cmp::PartialOrd,这是一个 trait。下一部分会讲到 trait。不过简单来说,这个错误表明 largest 的函数体不能适用于 T 的所有可能的类型。因为在函数体需要比较 T 类型的值,不过它只能用于我们知道如何排序的类型。为了开启比较功能,标准库中定义的 std::cmp::PartialOrd trait 可以实现类型的比较功能(查看附录 C 获取该 trait 的更多信息)。

标准库中定义的 std::cmp::PartialOrd trait 可以实现类型的比较功能。在 “trait 作为参数” 部分会讲解如何指定泛型实现特定的 trait,不过让我们先探索其他使用泛型参数的方法。

别的地方都还挺好理解的,最后这个的意思大概是说可能有的数据类型没法使用 >= 之类的符号进行运算,然后因为Rust很严格,所以我们没法直接编译,需要告知编译器只有实现了对应特性的数据类型才能使用这个泛型函数

结构体定义中的泛型

同样也可以用 <> 语法来定义结构体,它包含一个或多个泛型参数类型字段。示例 10-6 展示了如何定义和使用一个可以存放任何类型的 xy 坐标值的结构体 Point

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
struct Point<T> {
x: T,
y: T,
}

fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}

示例 10-6:Point 结构体存放了两个 T 类型的值 xy

其语法类似于函数定义中使用泛型。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。

注意 Point<T> 的定义中只使用了一个泛型类型,这个定义表明结构体 Point<T> 对于一些类型 T 是泛型的,而且字段 xy 都是 相同类型的,无论它具体是何类型。如果尝试创建一个有不同类型值的 Point<T> 的实例,像示例 10-7 中的代码就不能编译:

文件名: src/main.rs

1
2
3
4
5
6
7
8
struct Point<T> {
x: T,
y: T,
}

fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}

示例 10-7:字段 xy 的类型必须相同,因为他们都有相同的泛型类型 T

在这个例子中,当把整型值 5 赋值给 x 时,就告诉了编译器这个 Point<T> 实例中的泛型 T 是整型的。接着指定 y 为 4.0,它被定义为与 x 相同类型,就会得到一个像这样的类型不匹配错误:

1
2
3
4
5
6
7
8
9
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found
floating-point number
|
= note: expected type `{integer}`
found type `{float}`

如果想要定义一个 xy 可以有不同类型且仍然是泛型的 Point 结构体,我们可以使用多个泛型类型参数。在示例 10-8 中,我们修改 Point 的定义为拥有两个泛型类型 TU。其中字段 xT 类型的,而字段 yU 类型的:

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
struct Point<T, U> {
x: T,
y: U,
}

fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}

示例 10-8:使用两个泛型的 Point,这样 xy 可能是不同类型

现在所有的 Point 实例都合法了!你可以在定义中使用任意多的泛型类型参数,不过太多的话,代码将难以阅读和理解。当你的代码中需要许多泛型类型时,它可能表明你的代码需要重构,分解成更小的结构。

这一段还是挺好理解的,就不再展开阐述了

枚举定义中的泛型

和结构体类似,枚举也可以在成员中存放泛型数据类型。第 6 章我们曾用过标准库提供的 Option<T> 枚举,这里再回顾一下:

1
2
3
4
enum Option<T> {
Some(T),
None,
}

现在这个定义应该更容易理解了。如你所见 Option<T> 是一个拥有泛型 T 的枚举,它有两个成员:Some,它存放了一个类型 T 的值,和不存在任何值的 None。通过 Option<T> 枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T> 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。

枚举也可以拥有多个泛型类型。第 9 章使用过的 Result 枚举定义就是一个这样的例子:

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

Result 枚举有两个泛型类型,TEResult 有两个成员:Ok,它存放一个类型 T 的值,而 Err 则存放一个类型 E 的值。这个定义使得 Result 枚举能很方便的表达任何可能成功(返回 T 类型的值)也可能失败(返回 E 类型的值)的操作。实际上,这就是我们在示例 9-3 用来打开文件的方式:当成功打开文件的时候,T 对应的是 std::fs::File 类型;而当打开文件出现问题时,E 的值则是 std::io::Error 类型。

当你意识到代码中定义了多个结构体或枚举,它们不一样的地方只是其中的值的类型的时候,不妨通过泛型类型来避免重复。

这个其实也还好

方法定义中的泛型

在为结构体和枚举实现方法时(像第 5 章那样),一样也可以用泛型。示例 10-9 中展示了示例 10-6 中定义的结构体 Point<T>,和在其上实现的名为 x 的方法。

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

fn main() {
let p = Point { x: 5, y: 10 };

println!("p.x = {}", p.x());
}

示例 10-9:在 Point<T> 结构体上实现方法 x,它返回 T 类型的字段 x 的引用

这里在 Point<T> 上定义了一个叫做 x 的方法来返回字段 x 中数据的引用:

注意必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用它了。在 impl 之后声明泛型 T ,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。

例如,可以选择为 Point<f32> 实例实现方法,而不是为泛型 Point 实例。示例 10-10 展示了一个没有在 impl 之后(的尖括号)声明泛型的例子,这里使用了一个具体类型,f32

1
2
3
4
5
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

示例 10-10:构建一个只用于拥有泛型参数 T 的结构体的具体类型的 impl

这段代码意味着 Point<f32> 类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point<T> 实例则没有定义此方法。这个方法计算点实例与坐标 (0.0, 0.0) 之间的距离,并使用了只能用于浮点型的数学运算符。

结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。示例 10-11 中在示例 10-8 中的结构体 Point<T, U> 上定义了一个方法 mixup。这个方法获取另一个 Point 作为参数,而它可能与调用 mixupself 是不同的 Point 类型。这个方法用 selfPoint 类型的 x 值(类型 T)和参数的 Point 类型的 y 值(类型 W)来创建一个新 Point 类型的实例:

文件名: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Point<T, U> {
x: T,
y: U,
}

impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}

fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c'};

let p3 = p1.mixup(p2);

println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

示例 10-11:方法使用了与结构体定义中不同类型的泛型

main 函数中,定义了一个有 i32 类型的 x(其值为 5)和 f64y(其值为 10.4)的 Pointp2 则是一个有着字符串 slice 类型的 x(其值为 "Hello")和 char 类型的 y(其值为 c)的 Point。在 p1 上以 p2 作为参数调用 mixup 会返回一个 p3,它会有一个 i32 类型的 x,因为 x 来自 p1,并拥有一个 char 类型的 y,因为 y 来自 p2println! 会打印出 p3.x = 5, p3.y = c

这个例子的目的是展示一些泛型通过 impl 声明而另一些通过方法定义声明的情况。这里泛型参数 TU 声明于 impl 之后,因为他们与结构体定义相对应。而泛型参数 VW 声明于 fn mixup 之后,因为他们只是相对于方法本身的。

这段稍微有点绕,但是等到自己写代码用到的时候倒腾倒腾应该也还好

以前一直是写C的,对于现代语言的错误处理一直是搞不太懂,不过目前看来Rust的错误处理看起来还是比Java顺眼一点的

panic与不可恢复的错误

panic就类似于程序崩溃,程序会直接崩溃掉,这种错误是无法被程序处理的

有的时候代码出问题了,而你对此束手无策。对于这种情况,Rust 有 panic!宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种情况的场景通常是检测到一些类型的 bug,而且开发者并不清楚该如何处理它。

当出现 panic 时,程序默认会开始 展开unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml[profile] 部分增加 panic = 'abort',可以由展开切换为终止。例如,如果你想要在release模式中 panic 时直接终止:

1
2
[profile.release]
panic = 'abort'

让我们在一个简单的程序中调用 panic!

1
2
3
fn main() {
panic!("crash and burn");
}

运行程序将会出现类似这样的输出:

1
2
3
4
5
6
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.

最后两行包含 panic! 调用造成的错误信息。第一行显示了 panic 提供的信息并指明了源码中 panic 出现的位置:src/main.rs:2:5 表明这是 src/main.rs 文件的第二行第五个字符。

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic! 宏的调用。在其他情况下,panic! 可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic! 宏调用,而不是我们代码中最终导致 panic! 的那一行。我们可以使用 panic! 被调用的函数的 backtrace 来寻找代码中出问题的地方。下面我们会详细介绍 backtrace 是什么。

backtrace宏的作用就是让我们可以找到“我们编写的代码中导致程序崩溃的地方”

1
2
3
4
5
fn main() {
let v = vec![1, 2, 3];

v[99];
}

这是一个越界调用,在C语言中,这段代码会运行,然后给你一个错误的值,这是相当危险的

为了使程序远离这类漏洞,如果尝试读取一个索引不存在的元素,Rust 会停止执行并拒绝继续。尝试运行上面的程序会出现如下:

1
2
3
4
5
6
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', libcore/slice/mod.rs:2448:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

这指向了一个不是我们编写的文件,libcore/slice/mod.rs。其为 Rust 源码中 slice 的实现。这是当对 vector v 使用 []libcore/slice/mod.rs 中会执行的代码,也是真正出现 panic! 的地方。

接下来的几行提醒我们可以设置 RUST_BACKTRACE 环境变量来得到一个 backtrace。backtrace 是一个执行到目前位置所有被调用的函数的列表。Rust 的 backtrace 跟其他语言中的一样:阅读 backtrace 的关键是从头开始读直到发现你编写的文件。这就是问题的发源地。这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心 Rust 代码,标准库代码或用到的 crate 代码。让我们将 RUST_BACKTRACE 环境变量设置为任何不是 0 的值来获取 backtrace 看看。示例 9-2 展示了与你看到类似的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
$ RUST_BACKTRACE=1 cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', libcore/slice/mod.rs:2448:10
stack backtrace:
0: std::sys::unix::backtrace::tracing::imp::unwind_backtrace
at libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
1: std::sys_common::backtrace::print
at libstd/sys_common/backtrace.rs:71
at libstd/sys_common/backtrace.rs:59
2: std::panicking::default_hook::{{closure}}
at libstd/panicking.rs:211
3: std::panicking::default_hook
at libstd/panicking.rs:227
4: <std::panicking::begin_panic::PanicPayload<A> as core::panic::BoxMeUp>::get
at libstd/panicking.rs:476
5: std::panicking::continue_panic_fmt
at libstd/panicking.rs:390
6: std::panicking::try::do_call
at libstd/panicking.rs:325
7: core::ptr::drop_in_place
at libcore/panicking.rs:77
8: core::ptr::drop_in_place
at libcore/panicking.rs:59
9: <usize as core::slice::SliceIndex<[T]>>::index
at libcore/slice/mod.rs:2448
10: core::slice::<impl core::ops::index::Index<I> for [T]>::index
at libcore/slice/mod.rs:2316
11: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
at liballoc/vec.rs:1653
12: panic::main
at src/main.rs:4
13: std::rt::lang_start::{{closure}}
at libstd/rt.rs:74
14: std::panicking::try::do_call
at libstd/rt.rs:59
at libstd/panicking.rs:310
15: macho_symbol_search
at libpanic_unwind/lib.rs:102
16: std::alloc::default_alloc_error_hook
at libstd/panicking.rs:289
at libstd/panic.rs:392
at libstd/rt.rs:58
17: std::rt::lang_start
at libstd/rt.rs:74
18: panic::main

这种信息一般是从下往上读,直到找到我们自己编写的代码位置

RUST_BACKTRACE=1 cargo run

我们可以通过终端传递这个环境变量的值来进入BACKTRACE。


现在让我们回到C语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>  // 用于 printf
#include <limits.h> // 用于 INT_MIN

/**
* @brief 执行两个整数的除法。
*
* @param numerator 被除数。
* @param denominator 除数。
* @return int 如果除数不为零,返回计算出的商。
* 如果除数为零,返回 INT_MIN 表示错误。
*/
int safe_divide(int numerator, int denominator) {
if (denominator == 0) {
fprintf(stderr, "Error: Division by zero is not allowed.\n");
return INT_MIN; // 传播错误:返回一个特殊值表示失败
}
return numerator / denominator; // 成功:返回计算结果
}

int main() {
int result;

printf("--- 正常除法示例 ---\n");
result = safe_divide(10, 2);
if (result != INT_MIN) { // 检查返回值是否是错误码
printf("10 / 2 = %d\n", result); // 成功处理
} else {
printf("Error occurred during division.\n"); // 错误处理
}

printf("\n--- 除数为零示例 ---\n");
result = safe_divide(10, 0);
if (result != INT_MIN) { // 检查返回值是否是错误码
printf("10 / 0 = %d\n", result); // 理论上不会执行到这里
} else {
printf("Error occurred during division. Cannot divide by zero.\n"); // 错误处理
}

printf("\n--- 另一个正常除法示例 ---\n");
result = safe_divide(-15, 3);
if (result != INT_MIN) {
printf("-15 / 3 = %d\n", result);
} else {
printf("Error occurred during division.\n");
}

return 0;
}

当我们写一个除法程序,当我们传”0”为除数,程序很明显不应该崩溃,而是该返回一个标记值来提醒调用者出错了,然后调用者再进行处理,这就是可恢复的错误以及错误的传播

C语言中错误的传播相对原始,一般都是通过返回值层层传递,一旦有一层忘记处理这种情况,整个程序的稳定性就会受到极大的影响。

在讨论Rust中错误的传播前,我们先来讨论Rust对于这种可恢复错误的处理方式。


在 Rust 中,Option<T>Result<T, E> 是两个非常核心的枚举(enum),它们是 Rust 强大的错误处理存在性(presence)管理机制的基石。它们的设计理念是强制你在编译时处理可能缺失的值或可能发生的错误,从而避免了其他语言中常见的空指针异常和未处理的运行时错误。

Option<T>:处理值可能缺失的情况

Option<T> 枚举用来表示一个值可能存在,也可能不存在的情况。它的定义如下:

1
2
3
4
enum Option<T> {
None, // 值不存在
Some(T), // 值存在,并包含类型 T 的数据
}

它解决什么问题?

在许多其他语言(如 C++、Java、Python 等)中,你可能会使用 NULLnullNone 来表示一个变量没有值。然而,直接使用这些“空”值往往会导致运行时错误,比如著名的空指针异常(Null Pointer Exception)。因为你可能会忘记检查一个值是否为 null,然后尝试对其进行操作。

Option<T> 强制你在编译时就处理值存在或不存在的两种情况。如果你尝试直接使用一个 Option<T> 中的值而不先确定它是否是 Some(T),编译器会报错。

Result<T, E>:处理可能发生的错误

Result<T, E> 枚举用来表示一个操作可能成功并返回一个值,也可能失败并返回一个错误。它的定义如下:

1
2
3
4
enum Result<T, E> {
Ok(T), // 操作成功,并包含类型 T 的结果数据
Err(E), // 操作失败,并包含类型 E 的错误数据
}

它解决什么问题?

在 C 语言中,你通常通过返回值和错误码来表示函数成功或失败。在 Java/Python 等语言中,则通常使用异常(exceptions)。然而,这些方式都有其弊端:

  • C 语言的错误码:容易被忽略,需要手动检查,且错误信息有限。
  • 异常:虽然方便,但可能会导致控制流难以预测(“goto 式的错误处理”),且编译器通常不会强制你捕获或声明异常,可能导致未处理的运行时崩溃。

Result<T, E> 强制你在编译时就考虑并处理成功和失败的两种情况,使得错误处理成为你代码类型系统的一部分。

如何使用?

Option 类似,你通常会使用 match 表达式、if letResult 提供的各种方法(如 is_ok(), is_err(), unwrap(), expect(), map_err(), and_then(), ? 运算符等)来处理 Result 值。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File; // 引入文件系统模块

fn main() {
// 尝试打开一个不存在的文件
// File::open 返回一个 Result<File, std::io::Error>
let greeting_file = File::open("hello.txt");
let greeting_file = match greeting_file {
Ok(file) => file,
Err(error) => {
panic!("Probled opening the file {:?}",error)
},
};

// 如果上面一行没有 panic,说明文件成功打开了
println!("文件 'hello.txt' 已成功打开!");

// 注意:如果 hello.txt 不存在,上面的 println! 永远不会执行
}

这是Rust中最简单的错误处理,通过match匹配Result<>成员来实现对于错误的处理,但是在上面那段程序中,当打开文件失败时,程序直接崩溃掉了,这很明显不是我们想要的结果,接下来我们引入下一个知识点 错误的匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};
}

File::open 返回的 Err 成员中的值类型 io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind 值的 kind 方法可供调用。io::ErrorKind 是一个标准库提供的枚举,它的成员对应 io 操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound,它代表尝试打开的文件并不存在。这样,match 就匹配完 f 了,不过对于 error.kind() 还有一个内层 match

我们希望在内层 match 中检查的条件是 error.kind() 的返回值是否为 ErrorKindNotFound 成员。如果是,则尝试通过 File::create 创建文件。然而因为 File::create 也可能会失败,还需要增加一个内层 match 语句。当文件不能被打开,会打印出一个不同的错误信息。外层 match 的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序 panic。

match确实是强大的,但是有时候我们确实是希望当出现错误时直接panic!掉,此时再写match会有点麻烦,Rust为我们提供了两个简写方法。

unwrapexpect

1
2
3
4
5
use std::fs::File;

fn main() {
let f = File::open("hello.txt").unwrap();
}

如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap 调用 panic! 时提供的错误信息:

1
2
3
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

还有另一个类似于 unwrap 的方法它还允许我们选择 panic! 的错误信息:expect。使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect 的语法看起来像这样:

1
2
3
4
5
use std::fs::File;

fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap 的使用方式一样:返回文件句柄或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。它看起来像这样:

1
2
thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

因为这个错误信息以我们指定的文本开始,Failed to open hello.txt,将会更容易找到代码中的错误信息来自何处。如果在多处使用 unwrap,则需要花更多的时间来分析到底是哪一个 unwrap 造成了 panic,因为所有的 unwrap 调用都打印相同的信息。

接下来我们就可以进入到下一个知识点 错误的传播,C语言一般都是通过返回值的层层传递来实现错误的传播。传播错误的好处就是这样能更好地控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

简单点说错误的传播就是把可能发生的错误返回给调用者,让调用者来处理而不是由被调用的函数来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#![allow(unused)]
fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");

let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut s = String::new();

match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
}

首先让我们看看函数的返回值:Result<String, io::Error>。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 的具体类型是 String,而 E 的具体类型是 io::Error。如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 StringOk 值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err 值,它储存了一个包含更多这个问题相关信息的 io::Error 实例。这里选择 io::Error 作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open 函数和 read_to_string 方法。

函数体以 File::open 函数开头。接着使用 match 处理返回值 Result,类似于示例 9-4 中的 match,唯一的区别是当 Err 时不再调用 panic!,而是提早返回并将 File::open 返回的错误值作为函数的错误返回值传递给调用者。如果 File::open 成功了,我们将文件句柄储存在变量 f 中并继续。

接着我们在变量 s 中创建了一个新 String 并调用文件句柄 fread_to_string 方法来将文件的内容读取到 s 中。read_to_string 方法也返回一个 Result 因为它也可能会失败:哪怕是 File::open 已经成功了。所以我们需要另一个 match 来处理这个 Result:如果 read_to_string 成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Oks 中。如果 read_to_string 失败了,则像之前处理 File::open 的返回值的 match 那样返回错误值。不过并不需要显式的调用 return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的 Ok 值,或者一个包含 io::ErrorErr 值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err 值,他们可能会选择 panic! 并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

这种写法是相当常见的,Rust也为我们提供了这种情况下可供使用的简写

传播错误的简写:? 运算符

示例 9-7 展示了一个 read_username_from_file 的实现,它实现了与示例 9-6 中的代码相同的功能,不过这个实现使用了 ? 运算符:

1
2
3
4
5
6
7
8
9
10
use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

示例 9-7:一个使用 ? 运算符向调用者返回错误的函数

Result 值之后的 ? 被定义为与示例 9-6 中定义的处理 Result 值的 match 表达式有着完全相同的工作方式。如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。如果值是 ErrErr 将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

示例 9-6 中的 match 表达式与问号运算符所做的有一点不同:? 运算符所使用的错误值被传递给了 from 函数,它定义于标准库的 From trait 中,其用来将错误从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。只要每一个错误类型都实现了 from 函数来定义如何将自身转换为返回的错误类型,? 运算符会自动处理这些转换。

在示例 9-7 的上下文中,File::open 调用结尾的 ? 将会把 Ok 中的值返回给变量 f。如果出现了错误,? 运算符会提早返回整个函数并将一些 Err 值传播给调用者。同理也适用于 read_to_string 调用结尾的 ?

当你在一个不返回 Result 的函数中需要调用返回 Result 的函数时,文本提供了两种主要的修复方法:

  1. 修改当前函数的返回值类型为 Result<T, E>: 这是最常见和推荐的方法,特别是当你的函数确实需要传播错误时。你将函数的签名从默认的 () 修改为 Result<T, E>,使得它能够兼容 ? 运算符传播的错误。

    示例:

    Rust

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    use std::error::Error; // 引入 Error trait
    use std::fs::File;

    // 将 main 函数的返回值类型修改为 Result<(), Box<dyn Error>>
    fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?; // 现在 ? 运算符可以正常工作了

    // ... 其他操作 ...

    Ok(()) // 如果所有操作成功,返回 Ok(())
    }
    • -> Result<(), Box<dyn Error>>:这里 main 函数被声明为返回一个 Result
      • Ok(()) 表示成功,没有具体返回值。
      • Err(Box<dyn Error>) 表示失败,并包含一个错误对象
    • Box<dyn Error>:这被称为 “trait 对象”。它的作用是允许你返回任何实现了 std::error::Error 这个 trait 的错误类型。这是 Rust 处理“多种可能错误类型”的一种通用方法。在这里,你可以简单地理解为 main 函数现在可以返回任何类型的错误,只要这个错误实现了 Error trait。
  2. 在当前函数内使用 matchResult 的其他方法处理错误: 如果你不希望函数传播错误,或者函数不能修改返回值类型(例如,一些回调函数),那么你就必须在当前函数内部显式地处理 Result,而不是使用 ? 运算符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    use std::fs::File;

    fn main() { // main 函数的返回值仍然是 ()
    // 使用 match 显式处理 File::open 返回的 Result
    let f = match File::open("hello.txt") {
    Ok(file) => file,
    Err(e) => {
    eprintln!("Error opening file: {}", e); // 打印错误到标准错误输出
    return; // 如果发生错误,直接从 main 函数返回,程序终止
    }
    };

    println!("File opened successfully!");
    // ... 继续使用 f ...
    }

    在这个例子中,我们使用 match 语句来检查 File::open 的结果。如果它返回 Err,我们就打印错误信息并使用 return; 提前退出 main 函数。这样就没有错误需要被“传播”出 main 函数了。

RUST标准库中给我们提供了很多集合,这里介绍三种最常见的:

  1. vector
  2. String
  3. hash map

可能是类似于CPP中的STL,标准库提供了一些别的数据类型,方便我们使用.

Vector

第一个类型是 Vec<T>,也被称为 vector。vector 允许我们在一个单独的数据结构中储存多个值,所有值在内存中彼此相邻排列。vector 只能储存相同类型的值。它们在拥有一系列项的场景下非常实用,例如文件中的文本行或购物车中商品的价格。

Vector的初始化

  1. ler v:Vec[i32] = Vec::new(); 新建一个空的Vec来存储i32类型的值.
  2. let v = vec![1,2,3]; 利用vec!宏来使用初始值来创建一个Vec.此时Rust可以自动推断出v的类型.

Vector的更新

1
2
3
4
5
6
7
8
9
#![allow(unused)]
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}

很明显,v.push是将值推入Vec中的方法

我们需要注意的一点是:与其他结构类似,当vector离开他的作用域时会被销毁,并且当存在一个有效的不可变引用时,是没办法通过push向vec中新增值的.

1
2
3
4
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {}", first);

Vector元素的读取

  1. 通过索引来读取值
  2. 通过.get来读取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14

#![allow(unused)]
fn main() {
let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
println!("The third element is {}", third);

match v.get(2) {
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
}
}

这里有两个需要注意的地方。首先,我们使用索引值 2 来获取第三个元素,索引是从 0 开始的。其次,这两个不同的获取第三个元素的方式分别为:使用 &[] 返回一个引用;或者使用 get 方法以索引作为参数来返回一个 Option<&T>

使用.get方法可能是更优的,因为当索引越界时,.get方法会返回None,然后你就可以处理而不至于使程序崩溃

遍历Vector中的元素

如果想要依次访问 vector 中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问。

1
2
3
4
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}

我们也可以遍历可变 vector 的每一个元素的可变引用以便能改变他们。

1
2
3
4
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

为了修改可变引用所指向的值,在使用 += 运算符之前必须使用解引用运算符(*)获取 i 中的值。

使用枚举来储存多种类型

在本章的开始,我们提到 vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。幸运的是,枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!

例如,假如我们想要从电子表格的一行中获取值,而这一行的有些列包含数字,有些包含浮点值,还有些是字符串。我们可以定义一个枚举,其成员会存放这些不同类型的值,同时所有这些枚举成员都会被当作相同类型,那个枚举的类型。接着可以创建一个储存枚举值的 vector,这样最终就能够储存不同类型的值了。

1
2
3
4
5
6
7
8
9
10
11
12

enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];

Rust 在编译时就必须准确的知道 vector 中类型的原因在于它需要知道储存每个元素到底需要多少内存。第二个好处是可以准确的知道这个 vector 中允许什么类型。如果 Rust 允许 vector 存放任意类型,那么当对 vector 元素执行操作时一个或多个类型的值就有可能会造成错误。使用枚举外加 match 意味着 Rust 能在编译时就保证总是会处理所有可能的情况,正如第 6 章讲到的那样。

如果在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,第 17 章会讲到它。

现在我们了解了一些使用 vector 的最常见的方式,请一定去看看标准库中 Vec 定义的很多其他实用方法的 API 文档。例如,除了 push 之外还有一个 pop 方法,它会移除并返回 vector 的最后一个元素。

String类型

在 Rust 中,字符串并非单一类型,而是通常指两种主要的 UTF-8 编码类型:str(字符串 slice)String

具体来说,它涵盖了以下几个要点:

  • str (字符串 slice)
    • 这是 Rust 核心语言中唯一的字符串类型。
    • 它通常以 被借用的形式 &str 出现。
    • &str 是对储存在别处(例如程序二进制输出中的字符串字面量)的 UTF-8 编码字符串数据的引用。它们是不可变且固定大小的。
  • String
    • 这是由 Rust 标准库提供的类型,不属于核心语言。
    • 它是一种可增长的、可变的、有所有权的 UTF-8 编码字符串类型。
    • String 类型的数据存储在堆上,可以根据需要进行扩展和修改。
  • “字符串”的通用指代
    • 当 Rust 开发者(Rustacean)提到“字符串”时,通常指的是 String&str 这两种类型,而不是特指其中一个。
    • 这两种类型在 Rust 标准库中都非常常用,且都使用 UTF-8 编码
  • 其他字符串类型
    • 除了 String&str,Rust 标准库还提供了其他字符串类型,如 OsStringOsStrCStringCStr
    • 这些类型通常以 StringStr 结尾,分别对应其所有权(String 变体)和可借用(Str 变体)的特性。
    • 它们可能用于存储不同编码或内存表示形式的文本内容(例如与操作系统或 C 语言交互时)。

官方文档下头这段写得还是很像人话的,我们之间复制过来,需要注意的是,rust中的字符串默认是UTF-8而不是ASCII,所以会出现一些在C语言中不会出现的问题,C语言中与之对应的知识是wchar,即宽字符.

新建字符串

很多 Vec 可用的操作在 String 中同样可用,从 new 函数创建字符串开始,如示例 8-11 所示。

1
let mut s = String::new();

示例 8-11:新建一个空的 String

这新建了一个叫做 s 的空的字符串,接着我们可以向其中装载数据。通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string 方法,它能用于任何实现了 Display trait 的类型,字符串字面量也实现了它。示例 8-12 展示了两个例子。

1
2
3
4
5
6
let data = "initial contents";

let s = data.to_string();

// 该方法也可直接用于字符串字面量:
let s = "initial contents".to_string();

示例 8-12:使用 to_string 方法从字符串字面量创建 String

这些代码会创建包含 initial contents 的字符串。

也可以使用 String::from 函数来从字符串字面量创建 String。示例 8-13 中的代码等同于使用 to_string

1
let s = String::from("initial contents");

示例 8-13:使用 String::from 函数从字符串字面量创建 String

因为字符串应用广泛,这里有很多不同的用于字符串的通用 API 可供选择。其中一些可能看起来多余,不过都有其用武之地!在这个例子中,String::fromto_string 最终做到了完全相同的事情,所以如何选择,就是风格问题了。

请记住,字符串是 UTF-8 编码的,所以可以包含任何正确编码的数据,如示例 8-14 所示。

1
2
3
4
5
6
7
8
9
10
11
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

示例 8-14:在字符串中储存不同语言的问候语

所有这些都是有效的 String 值。

更新字符串

String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。

使用 push_strpush 附加字符串

可以通过 push_str 方法来附加字符串 slice,从而使 String 变长,如示例 8-15 所示。

1
2
let mut s = String::from("foo");
s.push_str("bar");

示例 8-15:使用 push_str 方法向 String 附加字符串 slice

执行这两行代码之后,s 将会包含 foobarpush_str 方法采用字符串 slice,因为我们并不需要获取参数的所有权。例如,示例 8-16 展示了如果将 s2 的内容附加到 s1 之后,自身不能被使用就糟糕了。

1
2
3
4
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);

示例 8-16:将字符串 slice 的内容附加到 String 后使用它

如果 push_str 方法获取了 s2 的所有权,就不能在最后一行打印出其值了。好在代码如我们期望那样工作!

push 方法被定义为获取一个单独的字符作为参数,并附加到 String 中。示例 8-17 展示了使用 push 方法将字母 l 加入 String 的代码。

1
2
let mut s = String::from("lo");
s.push('l');

示例 8-17:使用 push 将一个字符加入 String 值中

执行这些代码之后,s 将会包含 lol

使用 + 运算符或 format! 宏拼接字符串

通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符,如示例 8-18 所示。

1
2
3
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用

示例 8-18:使用 + 运算符将两个 String 值合并到一个新的 String 值中

执行完这些代码之后,字符串 s3 将会包含 Hello, world!s1 在相加后不再有效的原因,和使用 s2 的引用的原因,与使用 + 运算符时调用的函数签名有关。+ 运算符使用了 add 函数,这个函数签名看起来像这样:

1
fn add(self, s: &str) -> String {

这并不是标准库中实际的签名;标准库中的 add 使用泛型定义。这里我们看到的 add 的签名使用具体类型代替了泛型,这也正是当使用 String 值调用这个方法会发生的。第 10 章会讨论泛型。这个签名提供了理解 + 运算那微妙部分的线索。

首先,s2 使用了 &,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为 add 函数的 s 参数:只能将 &strString 相加,不能将两个 String 值相加。不过等一下 —— 正如 add 的第二个参数所指定的,&s2 的类型是 &String 而不是 &str。那么为什么示例 8-18 还能编译呢?

之所以能够在 add 调用中使用 &s2 是因为 &String 可以被 强转coerced)成 &str。当 add 函数被调用时,Rust 使用了一个被称为 解引用强制转换deref coercion)的技术,你可以将其理解为它把 &s2 变成了 &s2[..]。第 15 章会更深入的讨论解引用强制转换。因为 add 没有获取参数的所有权,所以 s2 在这个操作后仍然是有效的 String

其次,可以发现签名中 add 获取了 self 的所有权,因为 self 没有 使用 &。这意味着示例 8-18 中的 s1 的所有权将被移动到 add 调用中,之后就不再有效。虽然 let s3 = s1 + &s2; 看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。

如果想要级联多个字符串,+ 的行为就显得笨重了:

1
2
3
4
5
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

这时 s 的内容会是 tic-tac-toe。在有这么多 +" 字符的情况下,很难理解具体发生了什么。对于更为复杂的字符串连接,可以使用 format! 宏:

1
2
3
4
5
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3);

这些代码也会将 s 设置为 “tic-tac-toe”。format!println! 的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String。这个版本就好理解的多,并且不会获取任何参数的所有权。

索引字符串

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现一个错误。考虑一下如示例 8-19 中所示的无效代码。

1
2
let s1 = String::from("hello");
let h = s1[0];

示例 8-19:尝试对字符串使用索引语法

这段代码会导致如下错误:

1
2
3
4
5
6
7
error[E0277]: the trait bound `std::string::String: std::ops::Index<{integer}>` is not satisfied
-->
|
3 | let h = s1[0];
| ^^^^^ the type `std::string::String` cannot be indexed by `{integer}`
|
= help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

错误和提示说明了全部问题:Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust 是如何在内存中储存字符串的。

内部表现

String 是一个 Vec<u8> 的封装。让我们看看示例 8-14 中一些正确编码的字符串的例子。首先是这一个:

1
let len = String::from("Hola").len();

在这里,len 的值是 4 ,这意味着储存字符串 “Hola” 的 Vec 的长度是 4 个字节:这里每一个字母的 UTF-8 编码都占用 1 个字节。那下面这个例子又如何呢?(注意这个字符串中的首字母是西里尔字母的 Ze,而不是阿拉伯数字 3 。)

1
let len = String::from("Здравствуйте").len();

当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要 2 个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。作为演示,考虑如下无效的 Rust 代码:

1
2
let hello = "Здравствуйте";
let answer = &hello[0];

answer 的值应该是什么呢?它应该是第一个字符 З 吗?当使用 UTF-8 编码时,З 的第一个字节 208,第二个是 151,所以 answer 实际上应该是 208,不过 208 自身并不是一个有效的字母。返回 208 可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引 0 位置所能提供的唯一数据。用户通常不会想要一个字节值被返回,即便这个字符串只有拉丁字母: 即便 &"hello"[0] 是返回字节值的有效代码,它也应当返回 104 而不是 h。为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。

字节、标量值和字形簇!天呐!

这引起了关于 UTF-8 的另外一个问题:从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。

比如这个用梵文书写的印度语单词 “नमस्ते”,最终它储存在 vector 中的 u8 值看起来像这样:

1
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

这里有 18 个字节,也就是计算机最终会储存的数据。如果从 Unicode 标量值的角度理解它们,也就像 Rust 的 char 类型那样,这些字节看起来像这样:

1
['न', 'म', 'स', '्', 'त', 'े']

这里有六个 char,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:

1
["न", "म", "स्", "ते"]

Rust 提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。

最后一个 Rust 不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间 (O(1))。但是对于 String 不可能保证这样的性能,因为 Rust 必须从开头到索引位置遍历来确定有多少有效的字符。

字符串 slice

索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引创建字符串 slice 时,Rust 会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 [] 和单个值的索引,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:

1
2
let hello = "Здравствуйте";
let s = &hello[0..4];

这里,s 会是一个 &str,它包含字符串的头 4 个字节。早些时候,我们提到了这些字母都是 2 个字节长的,所以这意味着 s 将会是 “Зд”。

如果获取 &hello[0..1] 会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样:

1
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/libcore/str/mod.rs:2188:4

你应该小心谨慎的使用这个操作,因为这么做可能会使你的程序崩溃。

遍历字符串的方法

幸运的是,这里还有其他获取字符串元素的方式。

如果你需要操作单独的 Unicode 标量值,最好的选择是使用 chars 方法。对 “नमस्ते” 调用 chars 方法会将其分开并返回六个 char 类型的值,接着就可以遍历其结果来访问每一个元素了:

1
2
3
for c in "नमस्ते".chars() {
println!("{}", c);
}

这些代码会打印出如下内容:

1
2
3
4
5
6






bytes 方法返回每一个原始字节,这可能会适合你的使用场景:

1
2
3
for b in "नमस्ते".bytes() {
println!("{}", b);
}

这些代码会打印出组成 String 的 18 个字节:

1
2
3
4
5
224
164
// --snip--
165
135

不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。

从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。

字符串并不简单

总而言之,字符串还是很复杂的。不同的语言选择了不同的向开发者展示其复杂性的方式。Rust 选择了以准确的方式处理 String 数据作为所有 Rust 程序的默认行为,这意味着开发者们必须更多的思考如何预先处理 UTF-8 数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发生命周期后期免于处理涉及非 ASCII 字符的错误。

哈希map,我在C语言编程时没怎么接触过这个东西,所以不太熟悉,等用到的时候再回来看吧

哈希 map 储存键值对

最后介绍的常用集合类型是 哈希 maphash map)。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个 哈希函数hashing function)来实现映射,决定如何将键和值放入内存中。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表或者关联数组,仅举几例。

哈希 map 可以用于需要任何类型作为键来寻找数据的情况,而不是像 vector 那样通过索引。例如,在一个游戏中,你可以将每个团队的分数记录到哈希 map 中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。

本章我们会介绍哈希 map 的基本 API,不过还有更多吸引人的功能隐藏于标准库在 HashMap<K, V> 上定义的函数中。一如既往请查看标准库文档来了解更多信息。

新建一个哈希 map

可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素。在示例 8-20 中我们记录两支队伍的分数,分别是蓝队和黄队。蓝队开始有 10 分而黄队开始有 50 分:

1
2
3
4
5
6
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

示例 8-20:新建一个哈希 map 并插入一些键值对

注意必须首先 use 标准库中集合部分的 HashMap。在这三个常用集合中,HashMap 是最不常用的,所以并没有被 prelude 自动引用。标准库中对 HashMap 的支持也相对较少,例如,并没有内建的构建宏。

像 vector 一样,哈希 map 将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。

另一个构建哈希 map 的方法是使用一个元组的 vector 的 collect 方法,其中每个元组包含一个键值对。collect 方法可以将数据收集进一系列的集合类型,包括 HashMap。例如,如果队伍的名字和初始分数分别在两个 vector 中,可以使用 zip 方法来创建一个元组的 vector,其中 “Blue” 与 10 是一对,依此类推。接着就可以使用 collect 方法将这个元组 vector 转换成一个 HashMap,如示例 8-21 所示:

1
2
3
4
5
6
use std::collections::HashMap;

let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

示例 8-21:用队伍列表和分数列表创建哈希 map

这里 HashMap<_, _> 类型标注是必要的,因为 collect 有可能当成多种不同的数据结构,而除非显式指定否则 Rust 无从得知你需要的类型。但是对于键和值的类型参数来说,可以使用下划线占位,而 Rust 能够根据 vector 中数据的类型推断出 HashMap 所包含的类型。

哈希 map 和所有权

对于像 i32 这样的实现了 Copy trait 的类型,其值可以拷贝进哈希 map。对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者,如示例 8-22 所示:

1
2
3
4
5
6
7
8
9
use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// 这里 field_name 和 field_value 不再有效,
// 尝试使用它们看看会出现什么编译错误!

示例 8-22:展示一旦键值对被插入后就为哈希 map 所拥有

insert 调用将 field_namefield_value 移动到哈希 map 中后,将不能使用这两个绑定。

如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。第 10 章 “生命周期与引用有效性” 部分将会更多的讨论这个问题。

访问哈希 map 中的值

可以通过 get 方法并提供对应的键来从哈希 map 中获取值,如示例 8-23 所示:

1
2
3
4
5
6
7
8
9
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name);

示例 8-23:访问哈希 map 中储存的蓝队分数

这里,score 是与蓝队分数相关的值,应为 Some(10)。因为 get 返回 Option<V>,所以结果被装进 Some;如果某个键在哈希 map 中没有对应的值,get 会返回 None。这时就要用某种第 6 章提到的方法之一来处理 Option

可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是 for 循环:

1
2
3
4
5
6
7
8
9
10
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
println!("{}: {}", key, value);
}

这会以任意顺序打印出每一个键值对:

1
2
Yellow: 50
Blue: 10

更新哈希 map

尽管键值对的数量是可以增长的,不过任何时候,每个键只能关联一个值。当我们想要改变哈希 map 中的数据时,必须决定如何处理一个键已经有值了的情况。可以选择完全无视旧值并用新值代替旧值。可以选择保留旧值而忽略新值,并只在键 没有 对应值时增加新值。或者可以结合新旧两值。让我们看看这分别该如何处理!

覆盖一个值

如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。即便示例 8-24 中的代码调用了两次 insert,哈希 map 也只会包含一个键值对,因为两次都是对蓝队的键插入的值:

1
2
3
4
5
6
7
8
9

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

println!("{:?}", scores);

示例 8-24:替换以特定键储存的值

这会打印出 {"Blue": 25}。原始的值 10 则被覆盖了。

只在键没有对应值时插入

我们经常会检查某个特定的键是否有值,如果没有就插入一个值。为此哈希 map 有一个特有的 API,叫做 entry,它获取我们想要检查的键作为参数。entry 函数的返回值是一个枚举,Entry,它代表了可能存在也可能不存在的值。比如说我们想要检查黄队的键是否关联了一个值。如果没有,就插入值 50,对于蓝队也是如此。使用 entry API 的代码看起来像示例 8-25 这样:

1
2
3
4
5
6
7
8
9
10

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

println!("{:?}", scores);

示例 8-25:使用 entry 方法只在键没有对应一个值时插入

Entryor_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。这比编写自己的逻辑要简明的多,另外也与借用检查器结合得更好。

运行示例 8-25 的代码会打印出 {"Yellow": 50, "Blue": 10}。第一个 entry 调用会插入黄队的键和值 50,因为黄队并没有一个值。第二个 entry 调用不会改变哈希 map 因为蓝队已经有了值 10

根据旧值更新一个值

另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。例如,示例 8-26 中的代码计数一些文本中每一个单词分别出现了多少次。我们使用哈希 map 以单词作为键并递增其值来记录我们遇到过几次这个单词。如果是第一次看到某个单词,就插入值 0

1
2
3
4
5
6
7
8
9
10
11
12
13

use std::collections::HashMap;

let text = "hello world wonderful world";

let mut map = HashMap::new();

for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}

println!("{:?}", map);

示例 8-26:通过哈希 map 储存单词和计数来统计出现次数

这会打印出 {"world": 2, "hello": 1, "wonderful": 1}or_insert 方法事实上会返回这个键的值的一个可变引用(&mut V)。这里我们将这个可变引用储存在 count 变量中,所以为了赋值必须首先使用星号(*)解引用 count。这个可变引用在 for 循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。

哈希函数

HashMap 默认使用一种 “密码学安全的”(“cryptographically strong” )1 哈希函数,它可以抵抗拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。hasher 是一个实现了 BuildHasher trait 的类型。第 10 章会讨论 trait 和如何实现它们。你并不需要从头开始实现你自己的 hasher;crates.io 有其他人分享的实现了许多常用哈希算法的 hasher 的库。

我们依旧先来欣赏一下官方的高速神言,这一节官方主要就是讲了一下 use as还有pub use这三个东西

使用 use 关键字将名称引入作用域

到目前为止,似乎我们编写的用于调用函数的路径都很冗长且重复,并不方便。例如,示例 7-7 中,无论我们选择 add_to_waitlist 函数的绝对路径还是相对路径,每次我们想要调用 add_to_waitlist 时,都必须指定 front_of_househosting。幸运的是,有一种方法可以简化这个过程。我们可以使用 use 关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。

在示例 7-11 中,我们将 crate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。

1
2
3
4
5
6
7
8
9
10
11
12

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

示例 7-11: 使用 use 将模块引入作用域

在作用域中增加 use 和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting 模块被定义于 crate 根一样。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。

你还可以使用 use 和相对路径来将一个项引入作用域。示例 7-12 展示了如何指定相对路径来取得与示例 7-11 中一样的行为。

1
2
3
4
5
6
7
8
9
10
11
12

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

示例 7-12: 使用 use 和相对路径将模块引入作用域

创建惯用的 use 路径

在示例 7-11 中,你可能会比较疑惑,为什么我们是指定 use crate::front_of_house::hosting,然后在 eat_at_restaurant 中调用 hosting::add_to_waitlist,而不是通过指定一直到 add_to_waitlist 函数的 use 路径来得到相同的结果,如示例 7-13 所示。

1
2
3
4
5
6
7
8
9
10
11
12

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
add_to_waitlist();
}

示例 7-13: 使用 useadd_to_waitlist 函数引入作用域,这并不符合习惯

虽然示例 7-11 和 7-13 都完成了相同的任务,但示例 7-11 是使用 use 将函数引入作用域的习惯用法。使用 use 将函数的父模块引入作用域意味着我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。示例 7-13 中的代码则未表明 add_to_waitlist 是在哪里被定义的。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。示例 7-14 展示了将 HashMap 结构体引入二进制 crate 作用域的习惯用法。

1
2
3
4
5
6
7

use std::collections::HashMap;

fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}

示例 7-14: 将 HashMap 引入作用域的习惯用法

这种习惯用法背后没有什么硬性要求:它只是一种惯例,人们已经习惯了以这种方式阅读和编写 Rust 代码。

其实也就说了这么些内容

使用 use 引入函数时,习惯上是将函数的父模块引入作用域。 这样,在调用函数时,你需要写 父模块::函数名(),比如 hosting::add_to_waitlist()。这样做的好处是,可以清晰地表明这个函数不是当前模块定义的,同时避免了每次都写完整的冗长路径。

使用 use 引入结构体、枚举或其他项时,习惯上是直接引入到该项本身。 这样,你可以直接使用该项的名称,比如 HashMap::new()

这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,因为 Rust 不允许这样做。示例 7-15 展示了如何将两个具有相同名称但不同父模块的 Result 类型引入作用域,以及如何引用它们。

1
2
3
4
5
6
7
8
9
10
11

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
// --snip--
}

fn function2() -> io::Result<()> {
// --snip--
}

示例 7-15: 使用父模块将两个具有相同名称的类型引入同一作用域

如你所见,使用父模块可以区分这两个 Result 类型。如果我们是指定 use std::fmt::Resultuse std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,Rust 则不知道我们要用的是哪个。

接下来就是引入 as

1
2
3
4
5
6
7
8
9
10
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
// --snip--
}

fn function2() -> IoResult<()> {
// --snip--
}

 这个其实看两眼也就明白了,不用什么复杂的解释

然后还有pub use,我的理解就是你自己编写的模块中通过use引用了别的模块的内容,想要使这些内容可以被引用你模块的人使用的话,就需要pub use语句

以下是ai给出的解释

pub use 的核心作用,正是你描述的这个场景:当你编写一个模块(或者库)时,你可能会在内部 use 其他模块或类型来方便自己使用。但是,如果你希望使用你这个模块的外部代码也能通过你的模块路径来访问这些被你 use 进来的内容,那么你就需要使用 pub use

简单来说:

  • use 仅仅是你自己模块内部的“快捷方式”,方便你在本模块中使用。外部模块无法通过你的模块来访问这些被 use 进来的东西。
  • pub use 不仅创建了内部的“快捷方式”,还公开了这个快捷方式。这样,外部模块就可以通过你的模块路径来访问这个被重导出的内容,就好像这些内容就是你模块自己定义的一样。

这就像是你开了一家商店(你的模块),你从批发商那里进了些商品(其他模块的内容)。

  • 如果你只是自己用(use),这些商品就在你的库房里,只有你能看到。
  • 但如果你想把这些商品摆在你的店里卖(pub use),那么顾客(外部代码)就可以通过你的商店来购买这些商品了,他们甚至不需要知道你最初是从哪个批发商那里进的货。

pub use 允许你提供一个更清晰、更简洁的 API 给你的用户,同时隐藏了你内部的组织结构,语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
fn main() {}

还剩下一些杂七杂八的东西

下面是这段内容讲的三个主要“玩意儿”:

  • 你问得很好!这段内容主要围绕着如何在 Rust 项目中使用外部包(crate)以及如何优化 use 语句来管理这些引入的依赖。

    下面是这段内容讲的三个主要“玩意儿”:

    1. 如何使用外部包(Crate)

    Rust 项目使用 Cargo 这个构建系统和包管理器来管理依赖。

    • 声明依赖: 当你需要使用一个外部包时(比如 rand),你需要在项目的 Cargo.toml 文件中的 [dependencies] 部分添加这个包的名称和版本号。Cargo 会自动从 crates.io(Rust 社区的包注册表)下载这个包及其所有必要的依赖。

      Ini, TOML

      1
      2
      [dependencies]
      rand = "0.8.3" # 示例:声明对rand包的依赖
    • 引入作用域: 仅仅声明依赖还不够。你还需要使用 use 关键字,以 绝对路径 的方式将外部包中你想要使用的特定项(函数、结构体、trait 等)引入到你的代码的作用域中,这样你才能直接使用它们。

      Rust

      1
      use rand::Rng; // 将rand包中的Rng trait引入作用域

      值得注意的是,标准库 (std) 也是一个特殊的外部 crate。虽然你不需要在 Cargo.toml 中声明它,但你仍然需要使用 use 语句来引入 std 中你想要使用的项,例如 use std::collections::HashMap;

    2. 优化 use 语句:嵌套路径

    当你的代码需要从同一个模块或同一个包中引入多个项时,逐行 use 会让代码变得冗长。Rust 提供了 嵌套路径 的语法来简化这个过程。

    • 基本嵌套: 如果多个项共享同一个路径前缀,你可以将它们用大括号 {} 包裹起来。

      Rust

      1
      2
      3
      4
      5
      6
      // 之前:
      // use std::cmp::Ordering;
      // use std::io;

      // 之后:一行搞定
      use std::{cmp::Ordering, io};
    • self 关键字: 当你想引入一个模块本身,同时又想引入这个模块下的某个子项时,可以在嵌套路径中使用 self 关键字。

      Rust

      1
      2
      3
      4
      5
      6
      // 之前:
      // use std::io;
      // use std::io::Write;

      // 之后:一行搞定,同时引入了io模块和io::Write
      use std::io::{self, Write};

    3. 引入所有公有定义:Glob 运算符 *

    如果你想将一个模块下所有 公有 的项都引入到当前作用域,可以使用 glob 运算符 \*

    • 用法: 在路径的末尾加上 *

      Rust

      1
      use std::collections::*; // 引入std::collections模块中所有的公有项
    • 注意事项: 尽管方便,但使用 glob 运算符时需要谨慎。它会使代码的可读性降低,因为你很难一眼看出某个名称是来自哪里,可能会导致名称冲突。它通常在测试模块(为了方便测试所有功能)或特定模式(如 prelude 模式)中使用。

    这里还有,并提供了两种技巧 ({} 嵌套路径和 * glob 运算符) 来更简洁地管理 use 语句,从而提高代码的可读性和维护性。

    这段代码展示了 Rust 中更细粒度的可见性控制,也就是如何使用 pub(in path)pub(self)pub(super) 来限制一个项(比如函数)的可见范围。

    深入理解 Rust 的可见性控制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    // 一个名为 `my_mod` 的模块
    mod my_mod {
    // 模块中的项默认具有私有的可见性
    fn private_function() {
    println!("called `my_mod::private_function()`");
    }

    // 使用 `pub` 修饰语来改变默认可见性。
    pub fn function() {
    println!("called `my_mod::function()`");
    }

    // 在同一模块中,项可以访问其它项,即使它是私有的。
    pub fn indirect_access() {
    print!("called `my_mod::indirect_access()`, that\n> ");
    private_function();
    }

    // 模块也可以嵌套
    pub mod nested {
    pub fn function() {
    println!("called `my_mod::nested::function()`");
    }

    #[allow(dead_code)]
    fn private_function() {
    println!("called `my_mod::nested::private_function()`");
    }

    // 使用 `pub(in path)` 语法定义的函数只在给定的路径中可见。
    // `path` 必须是父模块(parent module)或祖先模块(ancestor module)
    pub(in crate::my_mod) fn public_function_in_my_mod() {
    print!("called `my_mod::nested::public_function_in_my_mod()`, that\n > ");
    public_function_in_nested()
    }

    // 使用 `pub(self)` 语法定义的函数则只在当前模块中可见。
    pub(self) fn public_function_in_nested() {
    println!("called `my_mod::nested::public_function_in_nested");
    }

    // 使用 `pub(super)` 语法定义的函数只在父模块中可见。
    pub(super) fn public_function_in_super_mod() {
    println!("called my_mod::nested::public_function_in_super_mod");
    }
    }

    pub fn call_public_function_in_my_mod() {
    print!("called `my_mod::call_public_funcion_in_my_mod()`, that\n> ");
    nested::public_function_in_my_mod();
    print!("> ");
    nested::public_function_in_super_mod();
    }

    // `pub(crate)` 使得函数只在当前 crate 中可见
    pub(crate) fn public_function_in_crate() {
    println!("called `my_mod::public_function_in_crate()");
    }

    // 嵌套模块的可见性遵循相同的规则
    mod private_nested {
    #[allow(dead_code)]
    pub fn function() {
    println!("called `my_mod::private_nested::function()`");
    }
    }
    }

    fn function() {
    println!("called `function()`");
    }

    fn main() {
    // 模块机制消除了相同名字的项之间的歧义。
    function();
    my_mod::function();

    // 公有项,包括嵌套模块内的,都可以在父模块外部访问。
    my_mod::indirect_access();
    my_mod::nested::function();
    my_mod::call_public_function_in_my_mod();

    // pub(crate) 项可以在同一个 crate 中的任何地方访问
    my_mod::public_function_in_crate();

    // pub(in path) 项只能在指定的模块中访问
    // 报错!函数 `public_function_in_my_mod` 是私有的
    //my_mod::nested::public_function_in_my_mod();
    // 试一试 ^ 取消该行的注释

    // 模块的私有项不能直接访问,即便它是嵌套在公有模块内部的

    // 报错!`private_function` 是私有的
    //my_mod::private_function();
    // 试一试 ^ 取消此行注释

    // 报错!`private_function` 是私有的
    //my_mod::nested::private_function();
    // 试一试 ^ 取消此行的注释

    // Error! `private_nested` is a private module
    //my_mod::private_nested::function();
    // 试一试 ^ 取消此行的注释
    }

    默认情况下,Rust 中的所有项(函数、结构体、枚举、模块等)都是私有的。要让它们在当前作用域之外可见,你需要使用 pub 关键字。然而,简单的 pub 意味着对所有外部代码都可见,这在某些情况下可能过于宽松。

    pub(in path)pub(self)pub(super) 提供了一种方式,让你能够更精确地控制项的可见性,而不是简单地“完全公开”或“完全私有”。

    1. pub(in crate::my_mod):指定路径可见性

    这个语法允许你将一个项的可见性限制在指定的路径(模块)内部。

    • pub(in crate::my_mod) fn public_function_in_my_mod()

      • 这意味着 public_function_in_my_mod 这个函数只在 crate::my_mod 模块及其子模块内部可见和可调用
      • my_mod 外部的代码,即使 my_mod 本身是公开的,也无法直接调用 public_mod::nested::public_function_in_my_mod
      • path 必须是该项的父模块或祖先模块。你不能指定一个与当前项没有继承关系的模块。

      想象一下: 你有一个家族企业,my_mod 是总公司。这个函数就像是只有总公司内部的员工(包括子公司员工)才能使用的特定工具。外部客户即使知道总公司存在,也无法直接使用这个工具。

    2. pub(self):当前模块可见性

    pub(self) 将可见性限制在定义该项的当前模块内部。

    • pub(self) fn public_function_in_nested()

      • 这意味着 public_function_in_nested 这个函数只在 my_mod::nested 模块内部可见
      • 即使在 my_mod 模块内部(nested 的父模块),也无法直接调用 public_function_in_nested。只有在 nested 模块内部的代码才能调用它。

      想象一下: nested 是公司里的一个特定部门。public_function_in_nested 就像是这个部门内部的专用流程,只有这个部门的员工才能使用。总公司或其他部门的员工都不能直接调用这个流程。

    3. pub(super):父模块可见性

    pub(super) 将可见性限制在定义该项的父模块内部。

    • pub(super) fn public_function_in_super_mod()

      • 这意味着 public_function_in_super_mod 这个函数只在 my_mod 模块内部可见
      • 尽管它定义在 nested 模块中,但它只对 nested 的父模块(即 my_mod)可见。nested 模块内部也能调用它,因为父模块的可见性范围包含了子模块。

      想象一下: 仍然是公司里的一个部门 nestedpublic_function_in_super_mod 就像是这个部门为总公司(my_mod)提供的内部服务,总公司可以直接调用,但其他部门或外部客户不能直接调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    fn function() {
    println!("called `function()`");
    }

    mod cool {
    pub fn function() {
    println!("called `cool::function()`");
    }
    }

    mod my {
    fn function() {
    println!("called `my::function()`");
    }

    mod cool {
    pub fn function() {
    println!("called `my::cool::function()`");
    }
    }

    pub fn indirect_call() {
    // 让我们从这个作用域中访问所有名为 `function` 的函数!
    print!("called `my::indirect_call()`, that\n> ");

    // `self` 关键字表示当前的模块作用域——在这个例子是 `my`。
    // 调用 `self::function()` 和直接调用 `function()` 都得到相同的结果,
    // 因为他们表示相同的函数。
    self::function();
    function();

    // 我们也可以使用 `self` 来访问 `my` 内部的另一个模块:
    self::cool::function();

    // `super` 关键字表示父作用域(在 `my` 模块外面)。
    super::function();

    // 这将在 *crate* 作用域内绑定 `cool::function` 。
    // 在这个例子中,crate 作用域是最外面的作用域。
    {
    use crate::cool::function as root_function;
    root_function();
    }
    }
    }

    fn main() {
    my::indirect_call();
    }

    总结

    这些细粒度的可见性控制非常有用,它们允许你:

    • 封装内部实现细节: 将一些只应该在特定范围内部使用的函数或数据隐藏起来,避免外部滥用或不当修改。
    • 构建清晰的 API: 明确哪些部分是库的公共接口,哪些是内部辅助功能。
    • 增强代码安全性: 限制了对某些敏感操作的访问。

    RUST的这个模块管理系统真的挺复杂的,估计得在实战中用它两回才能真正学得会.

路径用于引用模块树中的项

来看一下 Rust 如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • 绝对路径absolute path)从 crate 根部开始,以 crate 名或者字面量 crate 开头。
  • 相对路径relative path)从当前模块开始,以 selfsuper 或当前模块的标识符开头。

绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。

让我们回到示例 7-1。我们如何调用 add_to_waitlist 函数?还是同样的问题,add_to_waitlist 函数的路径是什么?在示例 7-3 中,我们通过删除一些模块和函数,稍微简化了一下我们的代码。我们在 crate 根部定义了一个新函数 eat_at_restaurant,并在其中展示调用 add_to_waitlist 函数的两种方法。eat_at_restaurant 函数是我们 crate 库的一个公共 API,所以我们使用 pub 关键字来标记它。在“使用 pub 关键字暴露路径”一节,我们将详细介绍 pub。注意,这个例子无法编译通过,我们稍后会解释原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-3: 使用绝对路径和相对路径来调用 add_to_waitlist 函数

第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist 函数与 eat_at_restaurant 被定义在同一 crate 中,这意味着我们可以使用 crate 关键字为起始的绝对路径。

crate 后面,我们持续地嵌入模块,直到我们找到 add_to_waitlist。你可以想象出一个相同结构的文件系统,我们通过指定路径 /front_of_house/hosting/add_to_waitlist 来执行 add_to_waitlist 程序。我们使用 crate 从 crate 根部开始就类似于在 shell 中使用 / 从文件系统根开始。

第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist。以名称为起始,意味着该路径是相对路径。

选择使用相对路径还是绝对路径,还是要取决于你的项目。取决于你是更倾向于将项的定义代码与使用该项的代码分开来移动,还是一起移动。举一个例子,如果我们要将 front_of_house 模块和 eat_at_restaurant 函数一起移动到一个名为 customer_experience 的模块中,我们需要更新 add_to_waitlist 的绝对路径,但是相对路径还是可用的。然而,如果我们要将 eat_at_restaurant 函数单独移到一个名为 dining 的模块中,还是可以使用原本的绝对路径来调用 add_to_waitlist,但是相对路径必须要更新。我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

官方依旧高速神言不说人话

我们用 C 语言的例子来类比一下 Rust 中“把代码定义和项调用各自独立地移动是更常见的”这句话。

在 C 语言中,我们没有像 Rust 这样的模块系统,但我们可以用头文件(.h)源文件(.c) 来类比“定义”和“调用”以及“路径”。

C 语言中的类比

想象你有两个 C 文件:

  1. math_operations.hmath_operations.c

    • math_operations.h (头文件):声明了 add 函数。这就像 Rust 中一个模块的公开接口,告诉别人有这么一个函数。

      1
      2
      // math_operations.h
      int add(int a, int b); // 这是函数的“定义”或“声明”
    • math_operations.c (源文件):实现了 add 函数。这是函数的具体“定义”所在。

      1
      2
      3
      4
      5
      6
      // math_operations.c
      #include "math_operations.h"

      int add(int a, int b) {
      return a + b;
      }
  2. main.c

    • 这个文件会调用 add 函数。

场景一:类似 Rust 中的绝对路径(使用完整路径/包含完整头文件)

main.c 中,如果你想要使用 add 函数,你会这么做:

1
2
3
4
5
6
7
// main.c
#include "math_operations.h" // 告诉编译器去哪里找 add 函数的声明

int main() {
int result = add(5, 3); // 调用 add 函数
return 0;
}

这里,#include "math_operations.h" 就像 Rust 中的绝对路径。它明确地指明了 add 函数的声明在哪里可以找到。

  • 如果我移动 main.c 文件(调用者):假设你把 main.c 从项目的一个子目录移动到另一个子目录,只要 math_operations.h 的相对位置没有改变,或者你通过编译器的 -I 选项(include path)告诉了编译器 math_operations.h 的新位置,那么 main.cadd(5, 3) 的调用不需要修改。因为 add 函数的定义(和声明)本身没有移动。这和 Rust 中绝对路径的稳定性类似。

场景二:类似 Rust 中的相对路径(如果 C 语言有这种直接模块引用)

C 语言没有像 Rust 这样内建的相对模块路径引用机制。但我们可以想象一种情况,如果你需要在 main.c直接引用 add 函数的定义,而不是通过头文件。这在 C 语言中是做不到的,但为了类比,我们可以假设:

如果 main.c 直接依赖于 math_operations.c 文件的相对位置来找到 add 函数的定义(这是个假设,C 实际编译不是这样做的)。那么:

  • 如果我移动 main.c 文件(调用者)main.c 的位置变了,它与 math_operations.c 的相对位置就可能变了。那么,原先的“相对路径”就失效了,你需要修改 main.c 中引用 add 函数的方式。

“各自独立移动”的含义

回到 Rust 的语境:

“把代码定义和项调用各自独立地移动是更常见的”这句话意味着:

  1. 你更常会移动和重构使用某个功能(add 函数)的代码文件(main.c),而不是改变那个功能本身(math_operations.cmath_operations.h)的存放位置。
  2. 当你移动 main.c 时,你希望它里面的 add(5, 3) 调用能够保持不变,而不需要每次移动都去修改它。

因此,使用 绝对路径(类似 C 语言中 #include <library_header.h> 或者通过 -I 选项找到的头文件)会更稳定。因为它从一个固定的起点(Rust 的 crate 根部)开始寻找,不依赖于你当前调用代码(main.c)的位置。即使你把 main.c 移动到项目的其他地方,只要 add 函数的定义没有从它原来的“绝对位置”移动,那么对它的引用就依然有效。

相对路径,由于它的起点是“当前模块”,一旦你移动了当前模块,相对位置就变了,路径就可能失效,需要你手动更新。

所以,Rust 建议我们倾向于使用绝对路径,因为它在代码重构时,特别是当调用方代码移动时,能够减少你需要修改的引用路径的数量,从而提高代码的维护性和稳定性。

简而言之就是绝对路径更好用,OK,接下来我们继续欣赏官方文档的高速神言

让我们试着编译一下示例 7-3,并查明为何不能编译!示例 7-4 展示了这个错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
--> src/lib.rs:9:28
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^

error[E0603]: module `hosting` is private
--> src/lib.rs:12:21
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

示例 7-4: 构建示例 7-3 出现的编译器错误

错误信息说 hosting 模块是私有的。换句话说,我们拥有 hosting 模块和 add_to_waitlist 函数的的正确路径,但是 Rust 不让我们使用,因为它不能访问私有片段。

模块不仅对于你组织代码很有用。他们还定义了 Rust 的 私有性边界privacy boundary):这条界线不允许外部代码了解、调用和依赖被封装的实现细节。所以,如果你希望创建一个私有函数或结构体,你可以将其放入模块。

Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的事务对餐厅顾客来说是不可知的,但办公室经理可以洞悉其经营的餐厅并在其中做任何事情。

Rust 选择以这种方式来实现模块系统功能,因此默认隐藏内部实现细节。这样一来,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。你还可以通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

接下来主要就是引出PUB关键字,需要注意PUB关键字对于结构体,枚举,函数的作用是不太一样的,需要分辨一下

使用 pub 关键字暴露路径

让我们回头看一下示例 7-4 的错误,它告诉我们 hosting 模块是私有的。我们想让父模块中的 eat_at_restaurant 函数可以访问子模块中的 add_to_waitlist 函数,因此我们使用 pub 关键字来标记 hosting 模块,如示例 7-5 所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-5: 使用 pub 关键字声明 hosting 模块使其可在 eat_at_restaurant 使用

不幸的是,示例 7-5 的代码编译仍然有错误,如示例 7-6 所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:9:37
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:12:30
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

示例 7-6: 构建示例 7-5 出现的编译器错误

发生了什么?在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting内容contents) 仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它。

示例 7-6 中的错误说,add_to_waitlist 函数是私有的。私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。

让我们继续将 pub 关键字放置在 add_to_waitlist 函数的定义之前,使其变成公有。如示例 7-7 所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-7: 为 mod hostingfn add_to_waitlist 添加 pub 关键字使他们可以在 eat_at_restaurant 函数中被调用

现在代码可以编译通过了!让我们看看绝对路径和相对路径,并根据私有性规则,再检查一下为什么增加 pub 关键字使得我们可以在 add_to_waitlist 中调用这些路径。

在绝对路径,我们从 crate,也就是 crate 根部开始。然后 crate 根部中定义了 front_of_house 模块。front_of_house 模块不是公有的,不过因为 eat_at_restaurant 函数与 front_of_house 定义于同一模块中(即,eat_at_restaurantfront_of_house 是兄弟),我们可以从 eat_at_restaurant 中引用 front_of_house。接下来是使用 pub 标记的 hosting 模块。我们可以访问 hosting 的父模块,所以可以访问 hosting。最后,add_to_waitlist 函数被标记为 pub ,我们可以访问其父模块,所以这个函数调用是有效的!

在相对路径,其逻辑与绝对路径相同,除了第一步:不同于从 crate 根部开始,路径从 front_of_house 开始。front_of_house 模块与 eat_at_restaurant 定义于同一模块,所以从 eat_at_restaurant 中开始定义的该模块相对路径是有效的。接下来因为 hostingadd_to_waitlist 被标记为 pub,路径其余的部分也是有效的,因此函数调用也是有效的!

我们来仔细讨论一下上面这段话

front_of_house 在你提供的代码中不是私有的,它是默认公开的,因为它是直接在 crate 根部定义的。

Rust 的访问规则有点绕,我们来具体解释一下:

crate 根部定义的模块默认是公开的

当你直接在 src/lib.rs (或者 src/main.rs) 文件中定义一个模块时,比如 mod front_of_house {},这个模块就是定义在 crate根部

crate 根部定义的项(包括模块),默认情况下是可以在整个 crate 内部被直接访问到的。 它们不需要 pub 关键字就可以被同一 crate 中的其他代码访问。pub 关键字的作用是让该项可以被外部 crate (比如你的库被其他项目引用时) 访问,或者让该项可以被子模块之外的模块通过完整路径访问。

所以,front_of_house 模块虽然没有显式地写 pub mod front_of_house {},但因为它直接位于 crate 根部,所以:

  1. eat_at_restaurant 函数(也在 crate 根部)可以直接看到并访问 front_of_house,因为它们是“兄弟”关系,都在最外层。
  2. crate:: 作为绝对路径的起点,它自然也能直接“看到”它根部定义的所有内容,包括 front_of_house

为什么会产生误解?

你可能误解了“私有”的含义。在 Rust 中,一个模块(或任何项)如果没有 pub 关键字,它通常表示该项只在其直接父模块内部可见和可访问。但是,这个规则对于 crate 根部有点特殊。crate 根部本身就是最高层级,它里面定义的所有东西对于整个 crate 内部的代码来说,都是默认可访问的,除非它们被嵌套在某个私有模块内部。

总结

  • front_of_house 模块:因为它直接定义在 crate 根部(src/lib.rs 的顶层),所以默认情况下,这个 crate 内部的所有代码都可以直接看到并引用它。它不是“私有的”,而是对 crate 内部可见的。
  • pub mod hosting:这里 pub 的作用是让 hosting 模块可以被 front_of_house 之外的代码(比如 eat_at_restaurant,它位于 front_of_house 的父级 crate 根部)访问。如果没有 pub,那么 hosting 默认就是私有的,只有 front_of_house 内部的代码才能访问它。
  • pub fn add_to_waitlist():函数默认是私有的。如果你想从它所在的模块外面调用它,就必须给它加上 pub

所以,crate 可以访问到 front_of_house 是因为 front_of_house 就在 crate 根部,并且对于 crate 内部是默认可见的。

依旧AI大法


是的,默认情况下,模块外部无法直接访问 front_of_house 模块的内容。

让我们用刚才的餐厅例子再来说明一下:

你的 Rust 项目是个大餐厅,front_of_house 是餐厅里“前厅”这个区域。

  • 默认情况下(没有 pub:这个“前厅”是餐厅内部的一个区域,它里面的具体设施(比如某个柜台、某个角落)默认都是“不对外开放”的。只有当你身处“前厅”内部,或者你被“前厅”的主管(父模块)特许,才能使用里面的东西。外部的顾客(其他 crate)或者甚至餐厅内部其他区域的员工(其他模块)都不能直接走进“前厅”去拿里面的东西,除非前厅的某个门是打开的(pub)。
  • 为什么 crate::front_of_house 可以访问到?
    • 因为 front_of_house 是直接定义在你的 crate(也就是整个餐厅)的“大厅”(根部)里的。
    • 所以,当你从“大厅”出发(crate::)时,你自然能看到“大厅”里所有的区域划分,包括 front_of_house。这就像你站在餐厅大堂中央,能看到哪里是“前厅”,哪里是“厨房”。
    • 但是,能看到这个区域,不代表你就能直接使用这个区域里的所有东西。要使用里面的东西,里面的东西本身也必须是公开的。

所以,结论是:

如果你想让 front_of_house 模块内部的 某个函数或结构体被**front_of_house 模块外部**的代码访问(比如被 eat_at_restaurant 访问,或者被另一个 crate 访问),那么:

  1. front_of_house 模块本身需要是可访问的(因为它直接在 crate 根部,所以它对 crate 内部是默认可见的)。
  2. front_of_house 内部的子模块或函数/结构体也必须使用 pub 关键字 来声明,才能让外部通过完整路径访问到它们。

这就是为什么在示例中,你不仅要给 hosting 加上 pub,还要给 add_to_waitlist 加上 pub,才能让 eat_at_restaurant 最终调用到它。

使用 super 起始的相对路径

我们还可以使用 super 开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。我们为什么要这样做呢?

考虑一下示例 7-8 中的代码,它模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。fix_incorrect_order 函数通过指定的 super 起始的 serve_order 路径,来调用 serve_order 函数:

1
2
3
4
5
6
7
8
9
10
11

fn serve_order() {}

mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order();
}

fn cook_order() {}
}

示例 7-8: 使用以 super 开头的相对路径从父目录开始调用函数

fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 父模块,也就是本例中的 crate 根。在这里,我们可以找到 serve_order。成功!我们认为 back_of_house 模块和 serve_order 函数之间可能具有某种关联关系,并且,如果我们要重新组织这个 crate 的模块树,需要一起移动它们。因此,我们使用 super,这样一来,如果这些代码被移动到了其他模块,我们只需要更新很少的代码。

创建公有的结构体和枚举

我们还可以使用 pub 来设计公有的结构体和枚举,不过有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。在示例 7-9 中,我们定义了一个公有结构体 back_of_house::Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}

impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}

pub fn eat_at_restaurant() {
// 在夏天点一份黑麦面包作为早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 更改我们想要的面包
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);

// 如果取消下一行的注释,将会导致编译失败;我们不被允许
// 看到或更改随餐搭配的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}

示例 7-9: 带有公有和私有字段的结构体

因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试去除那一行修改 seasonal_fruit 字段值的代码的注释,看看你会得到什么错误!

还请注意一点,因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例(这里我们命名为 summer)。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。

与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub,就像示例 7-10 展示的那样。

1
2
3
4
5
6
7
8
9
10
11
12

mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}

pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}

示例 7-10: 设计公有枚举,使其所有成员公有

因为我们创建了名为 Appetizer 的公有枚举,所以我们可以在 eat_at_restaurant 中使用 SoupSalad 成员。如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

还有一种使用 pub 的场景我们还没有涉及到,那就是我们最后要讲的模块功能:use 关键字。我们将先单独介绍 use,然后展示如何结合使用 pubuse