👋 你好,我是Chillward

欢迎来到我的博客,这里主要是我平时学习编程的笔记和踩坑记录还有一些别的碎碎念。

🧑‍💻 关于我

  • 目前正在学习:C/C++、Rust、RT-Thread嵌入式、Linux相关知识
  • 兴趣方向:嵌入式开发、底层原理、编程语言特性
  • 状态:入门水平,慢慢摸索中,在这里记录学习过程

📚 博客内容

主要就是记录自己学习过程中的笔记,方便自己回头看,也希望能帮到有需要的人:

  • Rust学习笔记:从入门开始的学习过程记录
  • RT-Thread开发笔记:实时操作系统学习和使用的记录
  • C语言相关笔记:一些C语言特性的学习和使用总结
  • 其他杂项:遇到的问题、解决方案、零散知识点记录
  • 一些碎碎念

📬 联系方式

  • GitHub: Chillward
  • 博客就是当前站点,随缘更新

西郊有密林,祝君出重围。

时隔不知道多久的颓废之后,正在尝试恢复人类形态(并非

在面向对象编程中,设计模式是解决常见问题的经典方案。然而,在C语言这种非面向对象的语言中实现设计模式需要一些技巧。本文将探讨如何在C语言中实现简单工厂模式,这是一种创建型设计模式,用于封装对象的创建过程。

项目结构

首先,让我们看一下这个简单示例的目录结构:

1
2
3
4
5
6
7
8
├── include/
│ ├── shape.h
│ ├── circle.h
│ └── rectangle.h
├── src/
│ ├── shape.c
│ ├── circle.c
│ └── rectangle.c

挑战与思路

拿C语言写设计模式真是一整个牢住了,没有继承,没有多态,什么都没有。突然有点想念强面向对象语言。

用C语言实现设计模式确实有一定挑战,因为C语言本身不支持继承、多态等面向对象特性。但通过一些技巧,我们仍然可以模拟出类似的效果。

在这个简单工厂模式的实现中,我们使用了一些模拟面向对象编程的技术,包括不透明指针模式。这些技术可以帮助我们在C语言中实现类似面向对象的行为。

顺带一提,在这个简单工厂模式的实现里,有涉及到一些模拟oop跟不透明模式的东西,或许大概可能也许以后会写一下这些东西(逃

Shape句柄的实现

让我们先来看看shape_t这个句柄是如何定义的:

1
2
3
4
5
6
7
8
9
typedef const struct shape_api **shape_t;
struct shape_api {
void (*draw)(shape_t shape);
void (*destroy)(shape_t shape);
};

struct shape {
const struct shape_api *api;
};

shape_t 是一个指向 shape_api 结构体指针的指针。通过这种设计,我们可以使用 (*shape_t)->成员函数 的方式来访问不同形状类实现的成员函数。

工厂函数

在简单工厂模式中,工厂的主要职责是创建对象。我们实现了以下两个工厂函数:

1
2
shape_t shape_create(enum shape_type type);
shape_t shape_create_with_param(const shape_config_t *config);

其中 shape_createshape_create_with_param 的简化版本,使用预定义的默认参数。让我们重点分析 shape_create_with_param 函数:

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
shape_t shape_create_with_param(const shape_config_t *config) {
if (!config)
return NULL;
switch (config->type) {
case SHAPE_CIRCLE: {
struct shape_circle *circle =
(struct shape_circle *)malloc(sizeof(struct shape_circle));
if (!circle)
return NULL;
shape_circle_init_with_radius(circle, config->params.circle.radius);
return &circle->shape.api;
}
case SHAPE_RECTANGLE: {
struct shape_rectangle *rectangle =
(struct shape_rectangle *)malloc(
sizeof(struct shape_rectangle));
if (!rectangle)
return NULL;
shape_rectangle_init_with_size(rectangle,
config->params.rectangle.width,
config->params.rectangle.height);
return &rectangle->shape.api;
}
default:
return NULL;
}
}

这里没有什么错误处理,毕竟它只是一个简易的demo而已。

这个函数的主要逻辑是:

  1. 检查配置参数是否有效
  2. 根据配置中的类型创建相应的对象
  3. 调用对应类的初始化函数
  4. 返回对象的句柄

由于我没有实现自己的内存分配函数,所以我们这里就使用 malloc()

配置结构体

工厂函数需要一个配置结构体来指定要创建的对象类型和参数:

1
2
3
4
5
6
7
8
9
10
11
typedef struct shape_config {
enum shape_type type;
union {
struct {
float radius;
} circle;
struct {
float width, height;
} rectangle;
} params;
} shape_config_t;

shape_config_t 结构体使用联合体(union)来存储不同类型形状的参数。这种设计使得我们可以用一个结构体来表示所有可能形状的配置。

基本上来说,shape_config_t基本上就只是一个单纯的结构体,包含了需要构建对象的类别跟所需要的参数

设计考虑

需要注意的是,当前的实现存在一个设计上的局限性:每次添加新的形状类型时,都需要修改 shape_config_t 结构体和 shape_create_with_param 函数。这违反了开闭原则(对扩展开放,对修改封闭)。

当然,这个配置结构体跟刚才提到的create函数都是不符合开闭原则的。每添加一个类都需要对他俩进行修改,这很不优雅。

在实际项目中,可以考虑更灵活的解决方案,比如使用注册机制或配置表来避免直接修改核心代码。不过,对于这个简单的演示来说,当前的实现已经足够清晰。

在拷打了一会AI之后,AI给了我一种新的解决方案,待我有空了去研究研究。

初始化函数

回归正题,我们发现这个工厂要求被它管理的类需要有”shape_rectangle_init_with_size”这样一个暴露出来的初始化函数

工厂要求每个被管理的类都提供一个公开的初始化函数。以矩形为例:

1
2
3
4
5
6
void shape_rectangle_init_with_size(struct shape_rectangle *self, float width,float height) {
memset(self, 0, sizeof(*self));
self->shape.api = &_rectangle_api;
self->width = width;
self->height = height;
}

随便掏一个出来,可以发现它所做的工作也并不复杂,只是很单纯的置0了对象,然后把传进来的参数填进去了而已。

初始化函数主要完成以下工作:

  1. 使用 memset 将对象内存清零
  2. 将虚函数表指针绑定到对象的 api 成员
  3. 设置对象的特定参数(如矩形的宽度和高度)

“self->shape.api = &_rectangle_api;”这一句可以稍微注意一下,这个初始化函数将对应子类的(基类为shape)虚函数表绑定到了self->shape.api,这是我们之后实现运行时多态的必要条件

虚函数表

每个形状类都有自己的虚函数表,定义了该类实现的特定行为:

1
const struct shape_api _rectangle_api = {.draw = _shape_rectangle_draw, .destroy = _shape_rectangle_destroy};

虚函数表大概长这样,我们实现了draw跟destroy函数并且绑定到了这个结构体上

这个虚函数表包含了指向实际实现函数的指针。对于矩形类,我们实现了 _shape_rectangle_draw_shape_rectangle_destroy 函数。

成员函数实现

让我们看看 _shape_rectangle_destroy 函数的实现:

1
2
3
4
5
static void _shape_rectangle_destroy(shape_t shape) {
struct shape_rectangle *self =
container_of(shape, struct shape_rectangle, shape.api);
free(self);
}

这里把destroy掏出来看看,基本上来说它就只是单纯的用container_of宏,通过在rectangle子类中包含的shape*的地址和一些别的信息反推出了rectangle本身的地址,然后执行了free。其实draw也是差不多的,也是反推出来本身地址之后,取出结构体中的内容物进行使用,你甚至可以再次进行一个分层,这没什么问题。

这个函数使用 container_of 宏从 shape_t 句柄反推出完整的矩形对象地址,然后释放内存。draw 函数的实现原理类似,也是通过反推地址来访问对象的特定数据。

container_of 宏是一个常用的C语言技巧,它通过结构体成员的地址计算出整个结构体的地址。

通用接口函数

虽然严格来说,以下两个函数不属于工厂模式的核心部分,但它们提供了统一的接口来操作所有形状对象:

1
2
void shape_draw(shape_t shape);
void shape_destroy(shape_t shape);

虽然强行归类一切没什么意义,C语言甚至都不是面向对象的,更不是强面向对象。但是没法把他俩归类还是让我有点烦。

这些函数提供了类型安全的操作接口,隐藏了具体的实现细节。

shape_draw 函数的实现展示了如何通过虚函数表调用具体的实现:

1
2
3
4
5
void shape_draw(shape_t shape) {
if (shape && *shape && (*shape)->draw) {
(*shape)->draw(shape);
}
}

他俩其实也挺简单的,就是进行一个简单的判空之后,解引用出*shape_api,然后再通过结构体首个成员的地址是整个结构体的地址这个机制套娃两次就可以对子类绑定好的成员函数进行一个调用了。

这个函数首先进行空指针检查,然后通过双重解引用访问虚函数表中的 draw 函数指针,最后调用具体的实现。这种设计允许我们在不知道具体类型的情况下调用正确的方法。

示例代码

下面是一个完整的使用示例,展示了如何创建和使用不同形状:

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
int main(int argc, char **argv) {
printf("=== Simple Factory Pattern Demo ===\n\n");

// 测试1: 使用默认参数创建形状
printf("Test 1: Creating shapes with default parameters\n");

shape_t circle1 = shape_create(SHAPE_CIRCLE);
if (circle1) {
printf("Created default circle\n");
shape_draw(circle1);
shape_destroy(circle1);
}

shape_t rectangle1 = shape_create(SHAPE_RECTANGLE);
if (rectangle1) {
printf("Created default rectangle\n");
shape_draw(rectangle1);
shape_destroy(rectangle1);
}

printf("\n");

// 测试2: 使用带参数的工厂函数创建自定义形状
printf("Test 2: Creating shapes with custom parameters\n");

shape_config_t circle_config = {.type = SHAPE_CIRCLE,
.params.circle.radius = 2.5f};

shape_t circle2 = shape_create_with_param(&circle_config);
if (circle2) {
printf("Created custom circle with radius 2.5\n");
shape_draw(circle2);
shape_destroy(circle2);
}

shape_config_t rectangle_config = {.type = SHAPE_RECTANGLE,
.params.rectangle.width = 3.0f,
.params.rectangle.height = 4.0f};

shape_t rectangle2 = shape_create_with_param(&rectangle_config);
if (rectangle2) {
printf("Created custom rectangle with width 3.0 and height 4.0\n");
shape_draw(rectangle2);
shape_destroy(rectangle2);
}

printf("\n");

// 测试3: 错误处理 - 无效类型
printf("Test 3: Error handling - invalid shape type\n");
shape_t unknown_shape = shape_create(999); // 无效类型
if (!unknown_shape) {
printf("Correctly rejected unknown shape type\n");
}

printf("\n=== Demo Completed ===\n");

return 0;
}

当然,最后在这里赋上一个main.c

总结

通过这个简单的示例,我们可以看到如何在C语言中实现简单工厂模式。虽然C语言本身不支持面向对象特性,但通过一些技巧,我们仍然可以模拟出类似的效果:

  1. 不透明指针:使用 shape_t 作为不透明句柄,隐藏实现细节
  2. 虚函数表:通过结构体指针模拟虚函数表,实现运行时多态
  3. 工厂函数:封装对象的创建过程,提供统一的创建接口
  4. 配置结构体:使用联合体存储不同类型对象的参数

感觉这种设计模式,复杂的已经不是单个成员函数了,复杂的是这些函数之间的关系了感觉。

这种设计模式的复杂性主要来自于函数之间的关系和交互,而不是单个函数的实现。通过合理的抽象和封装,我们可以在C语言中构建出灵活、可扩展的系统。

在实际项目中,可以根据需要进一步优化这个实现,比如添加更完善的错误处理、支持动态注册新类型、或者实现更复杂的设计模式。

在C语言中,原生并没有像C++或JavaScript那样内置的**箭头函数(lambda)**支持。但是,我们可以通过一些技巧和模式来模拟实现类似的功能。

下面介绍两种常见的实现方式:

1. 使用宏和typeof关键字

这种方法是利用C语言的宏预处理器和GCC/Clang编译器特有的typeof扩展。这种方式的优点是代码简洁,看起来最接近真正的lambda表达式,但缺点是它依赖于特定的编译器,可移植性较差。

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
#include <stdio.h>

// 定义一个宏,用于创建类似lambda的函数
// 参数:
// type: 函数的返回类型
// args: 函数的参数列表,用括号括起来
// body: 函数体
#define LAMBDA(type, args, body) \
({ \
type __fn__ args body \
__fn__; \
})

int main() {
// 定义一个lambda,求两个数的和
int (*sum_lambda)(int, int) = LAMBDA(int, (int a, int b), {
return a + b;
});

printf("Sum: %d\n", sum_lambda(5, 3)); // 输出 Sum: 8

// 定义一个lambda,判断一个数是否为偶数
int (*is_even_lambda)(int) = LAMBDA(int, (int num), {
return num % 2 == 0;
});

printf("Is 10 even? %d\n", is_even_lambda(10)); // 输出 Is 10 even? 1
printf("Is 7 even? %d\n", is_even_lambda(7)); // 输出 Is 7 even? 0

return 0;
}

工作原理:

  • LAMBDA宏创建了一个匿名函数,并立即返回这个函数的地址。
  • ({}) 这种语法是GCC的**语句表达式(Statement Expressions)**扩展,它允许在一个表达式中使用代码块。代码块的最后一条语句的值就是整个表达式的值。
  • typeof在这里没有直接使用,但这个模式的核心是利用了GCC/Clang对局部函数(nested functions)的支持,并在宏中返回了其地址。上述代码中的typeof是隐藏在宏的实现细节中,用来自动推断函数指针类型的。

2. 使用结构体和函数指针

这是一种更通用、更具可移植性的方法,可以在任何遵循C标准的编译器上使用。它的核心思想是将函数指针捕获的变量(被lambda引用的外部变量)封装在一个结构体中。

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
#include <stdio.h>

// 定义一个结构体,用于表示lambda
typedef struct {
void *context; // 用于存储捕获的变量
int (*function)(void *context, int arg); // 函数指针
} Lambda;

// 实际执行的函数,它接收一个上下文和一个参数
int adder_function(void *context, int arg) {
int *base = (int *)context;
return (*base) + arg;
}

// 创建一个lambda
Lambda create_adder(int base) {
// 分配内存来存储捕获的变量
int *context = (int *)malloc(sizeof(int));
*context = base;

Lambda l = {
.context = context,
.function = adder_function
};
return l;
}

int main() {
// 创建一个lambda,它会捕获外部变量 'x'
int x = 10;
Lambda add_x = create_adder(x);

// 调用lambda
int result = add_x.function(add_x.context, 5); // 10 + 5
printf("Result: %d\n", result); // 输出 Result: 15

// 释放捕获变量的内存
free(add_x.context);

return 0;
}

工作原理:

  • 我们定义了一个Lambda结构体,它包含两个成员:
    • context:一个**void*指针**,用于存储lambda需要访问的外部变量(即“捕获的变量”)。
    • function:一个函数指针,它接收context指针和实际的参数。
  • 当我们创建一个lambda时,我们会分配内存来存储外部变量,并将其地址赋给context
  • 在调用时,我们将Lambda结构体的context和实际参数一起传递给function指针指向的函数。
  • 这种方式更复杂,但非常灵活,并且是C语言中实现闭包(closure)的经典模式。它也是很多库(例如:libdispatch)在C语言中实现异步回调和闭包的基础。

事件集

事件集也是线程间同步的机制之一,一个事件集可以包含多个事件,利用事件集可以完成一对多,多对多的线程间同步。下面以坐公交为例说明事件,在公交站等公交时可能有以下几种情况:

①P1 坐公交去某地,只有一种公交可以到达目的地,等到此公交即可出发。

②P1 坐公交去某地,有 3 种公交都可以到达目的地,等到其中任意一辆即可出发。

③P1 约另一人 P2 一起去某地,则 P1 必须要等到 “同伴 P2 到达公交站” 与“公交到达公交站”两个条件都满足后,才能出发。

这里,可以将 P1 去某地视为线程,将 “公交到达公交站”、“同伴 P2 到达公交站” 视为事件的发生,情况①是特定事件唤醒线程;情况②是任意单个事件唤醒线程;情况③是多个事件同时发生才唤醒线程。

事件集工作机制

事件集主要用于线程间的同步,与信号量不同,它的特点是可以实现一对多,多对多的同步。即一个线程与多个事件的关系可设置为:其中任意一个事件唤醒线程,或几个事件都到达后才唤醒线程进行后续的处理;同样,事件也可以是多个线程同步多个事件。这种多个事件的集合可以用一个 32 位无符号整型变量来表示,变量的每一位代表一个事件,线程通过 “逻辑与” 或“逻辑或”将一个或多个事件关联起来,形成事件组合。事件的 “逻辑或” 也称为是独立型同步,指的是线程与任何事件之一发生同步;事件 “逻辑与” 也称为是关联型同步,指的是线程与若干事件都发生同步。

RT-Thread 定义的事件集有以下特点:

1)事件只与线程相关,事件间相互独立:每个线程可拥有 32 个事件标志,采用一个 32 bit 无符号整型数进行记录,每一个 bit 代表一个事件;

2)事件仅用于同步,不提供数据传输功能;

3)事件无排队性,即多次向线程发送同一事件 (如果线程还未来得及读走),其效果等同于只发送一次。

在 RT-Thread 中,每个线程都拥有一个事件信息标记,它有三个属性,分别是 RT_EVENT_FLAG_AND(逻辑与),RT_EVENT_FLAG_OR(逻辑或)以及 RT_EVENT_FLAG_CLEAR(清除标记)。当线程等待事件同步时,可以通过 32 个事件标志和这个事件信息标记来判断当前接收的事件是否满足同步条件。

事件集工作示意图

如上图所示,线程 #1 的事件标志中第 1 位和第 30 位被置位,如果事件信息标记位设为逻辑与,则表示线程 #1 只有在事件 1 和事件 30 都发生以后才会被触发唤醒,如果事件信息标记位设为逻辑或,则事件 1 或事件 30 中的任意一个发生都会触发唤醒线程 #1。如果信息标记同时设置了清除标记位,则当线程 #1 唤醒后将主动把事件 1 和事件 30 清为零,否则事件标志将依然存在(即置 1)。

事件集控制块

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

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

/* 事件集合,每一 bit 表示 1 个事件,bit 位的值可以标记某事件是否发生 */
rt_uint32_t set;
};
/* rt_event_t 是指向事件结构体的指针类型 */
typedef struct rt_event* rt_event_t;

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

事件集的管理方式

事件集控制块中含有与事件集相关的重要参数,在事件集功能的实现中起重要的作用。事件集相关接口如下图所示,对一个事件集的操作包含:创建 / 初始化事件集、发送事件、接收事件、删除 / 脱离事件集。

事件相关接口

创建和删除事件集

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

1
rt_event_t rt_event_create(const char* name, rt_uint8_t flag);

调用该函数接口时,系统会从对象管理器中分配事件集对象,并初始化这个对象,然后初始化父类 IPC 对象。下表描述了该函数的输入参数与返回值:

参数 描述
name 事件集的名称
flag 事件集的标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO
返回 ——
RT_NULL 创建失败
事件对象的句柄 创建成功

Note

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

系统不再使用 rt_event_create() 创建的事件集对象时,通过删除事件集对象控制块来释放系统资源。删除事件集可以使用下面的函数接口:

1
rt_err_t rt_event_delete(rt_event_t event);

在调用 rt_event_delete 函数删除一个事件集对象时,应该确保该事件集不再被使用。在删除前会唤醒所有挂起在该事件集上的线程(线程的返回值是 - RT_ERROR),然后释放事件集对象占用的内存块。下表描述了该函数的输入参数与返回值:

参数 描述
event 事件集对象的句柄
返回 ——
RT_EOK 成功

初始化和脱离事件集

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

1
rt_err_t rt_event_init(rt_event_t event, const char* name, rt_uint8_t flag);

调用该接口时,需指定静态事件集对象的句柄(即指向事件集控制块的指针),然后系统会初始化事件集对象,并加入到系统对象容器中进行管理。下表描述了该函数的输入参数与返回值:

参数 描述
event 事件集对象的句柄
name 事件集的名称
flag 事件集的标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO
返回 ——
RT_EOK 成功

系统不再使用 rt_event_init() 初始化的事件集对象时,通过脱离事件集对象控制块来释放系统资源。脱离事件集是将事件集对象从内核对象管理器中脱离。脱离事件集使用下面的函数接口:

1
rt_err_t rt_event_detach(rt_event_t event);

用户调用这个函数时,系统首先唤醒所有挂在该事件集等待队列上的线程(线程的返回值是 - RT_ERROR),然后将该事件集从内核对象管理器中脱离。下表描述了该函数的输入参数与返回值:

参数 描述
event 事件集对象的句柄
返回 ——
RT_EOK 成功

发送事件

发送事件函数可以发送事件集中的一个或多个事件,如下:

1
rt_err_t rt_event_send(rt_event_t event, rt_uint32_t set);

使用该函数接口时,通过参数 set 指定的事件标志来设定 event 事件集对象的事件标志值,然后遍历等待在 event 事件集对象上的等待线程链表,判断是否有线程的事件激活要求与当前 event 对象事件标志值匹配,如果有,则唤醒该线程。下表描述了该函数的输入参数与返回值:

参数 描述
event 事件集对象的句柄
set 发送的一个或多个事件的标志值
返回 ——
RT_EOK 成功

接收事件

内核使用 32 位的无符号整数来标识事件集,它的每一位代表一个事件,因此一个事件集对象可同时等待接收 32 个事件,内核可以通过指定选择参数 “逻辑与” 或“逻辑或”来选择如何激活线程,使用 “逻辑与” 参数表示只有当所有等待的事件都发生时才激活线程,而使用 “逻辑或” 参数则表示只要有一个等待的事件发生就激活线程。接收事件使用下面的函数接口:

1
2
3
4
5
rt_err_t rt_event_recv(rt_event_t event,
rt_uint32_t set,
rt_uint8_t option,
rt_int32_t timeout,
rt_uint32_t* recved);

当用户调用这个接口时,系统首先根据 set 参数和接收选项 option 来判断它要接收的事件是否发生,如果已经发生,则根据参数 option 上是否设置有 RT_EVENT_FLAG_CLEAR 来决定是否重置事件的相应标志位,然后返回(其中 recved 参数返回接收到的事件);如果没有发生,则把等待的 set 和 option 参数填入线程本身的结构中,然后把线程挂起在此事件上,直到其等待的事件满足条件或等待时间超过指定的超时时间。如果超时时间设置为零,则表示当线程要接受的事件没有满足其要求时就不等待,而直接返回 - RT_ETIMEOUT。下表描述了该函数的输入参数与返回值:

参数 描述
event 事件集对象的句柄
set 接收线程感兴趣的事件
option 接收选项
timeout 指定超时时间
recved 指向接收到的事件
返回 ——
RT_EOK 成功
-RT_ETIMEOUT 超时
-RT_ERROR 错误

option 的值可取:

1
2
3
4
5
6
/* 选择 逻辑与 或 逻辑或 的方式接收事件 */
RT_EVENT_FLAG_OR
RT_EVENT_FLAG_AND

/* 选择清除重置事件标志位 */
RT_EVENT_FLAG_CLEAR

事件集应用示例

这是事件集的应用例程,例子中初始化了一个事件集,两个线程。一个线程等待自己关心的事件发生,另外一个线程发送事件,如以下代码所示:

事件集的使用例程

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
#include <rtthread.h>

#define THREAD_PRIORITY 9
#define THREAD_TIMESLICE 5

#define EVENT_FLAG3 (1 << 3)
#define EVENT_FLAG5 (1 << 5)

/* 事件控制块 */
static struct rt_event event;

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

/* 线程 1 入口函数 */
static void thread1_recv_event(void *param)
{
rt_uint32_t e;

/* 第一次接收事件,事件 3 或事件 5 任意一个可以触发线程 1,接收完后清除事件标志 */
if (rt_event_recv(&event, (EVENT_FLAG3 | EVENT_FLAG5),
RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
RT_WAITING_FOREVER, &e) == RT_EOK)
{
rt_kprintf("thread1: OR recv event 0x%x\n", e);
}

rt_kprintf("thread1: delay 1s to prepare the second event\n");
rt_thread_mdelay(1000);

/* 第二次接收事件,事件 3 和事件 5 均发生时才可以触发线程 1,接收完后清除事件标志 */
if (rt_event_recv(&event, (EVENT_FLAG3 | EVENT_FLAG5),
RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR,
RT_WAITING_FOREVER, &e) == RT_EOK)
{
rt_kprintf("thread1: AND recv event 0x%x\n", e);
}
/* 执行完该事件集后进行事件集的脱离,事件集重复初始化会导致再次运行时,出现重复初始化的问题 */
rt_event_detach(&event);
rt_kprintf("thread1 leave.\n");
}


ALIGN(RT_ALIGN_SIZE)
static char thread2_stack[1024];
static struct rt_thread thread2;

/* 线程 2 入口 */
static void thread2_send_event(void *param)
{
rt_kprintf("thread2: send event3\n");
rt_event_send(&event, EVENT_FLAG3);
rt_thread_mdelay(200);

rt_kprintf("thread2: send event5\n");
rt_event_send(&event, EVENT_FLAG5);
rt_thread_mdelay(200);

rt_kprintf("thread2: send event3\n");
rt_event_send(&event, EVENT_FLAG3);
rt_kprintf("thread2 leave.\n");
}

int event_sample(void)
{
rt_err_t result;

/* 初始化事件对象 */
result = rt_event_init(&event, "event", RT_IPC_FLAG_PRIO);
if (result != RT_EOK)
{
rt_kprintf("init event failed.\n");
return -1;
}

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

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

return 0;
}

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

仿真运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 \ | /
- RT - Thread Operating System
/ | \ 4.1.1 build Sep 5 2024 15:53:21
2006 - 2022 Copyright by RT-Thread team
msh >event_sample
thread2: send event3
thread1: OR recv event 0x8
thread1: delay 1s to prepare the second event
msh >thread2: send event5
thread2: send event3
thread2 leave.
thread1: AND recv event 0x28
thread1 leave.

msh >event_sample
thread2: send event3
thread1: OR recv event 0x8
thread1: delay 1s to prepare the second event
msh >thread2: send event5
thread2: send event3
thread2 leave.
thread1: AND recv event 0x28
thread1 leave.

例程演示了事件集的使用方法。线程 1 前后两次接收事件,分别使用了 “逻辑或” 与“逻辑与”的方法。

事件集的使用场合

事件集可使用于多种场合,它能够在一定程度上替代信号量,用于线程间同步。一个线程或中断服务例程发送一个事件给事件集对象,而后等待的线程被唤醒并对相应的事件进行处理。但是它与信号量不同的是,事件的发送操作在事件未清除前,是不可累计的,而信号量的释放动作是累计的。事件的另一个特性是,接收线程可等待多种事件,即多个事件对应一个线程或多个线程。同时按照线程等待的参数,可选择是 “逻辑或” 触发还是 “逻辑与” 触发。这个特性也是信号量等所不具备的,信号量只能识别单一的释放动作,而不能同时等待多种类型的释放。如下图所示为多事件接收示意图:

多事件接收示意图

一个事件集中包含 32 个事件,特定线程只等待、接收它关注的事件。可以是一个线程等待多个事件的到来(线程 1、2 均等待多个事件,事件间可以使用 “与” 或者 “或” 逻辑触发线程),也可以是多个线程等待一个事件的到来(事件 25)。当有它们关注的事件发生时,线程将被唤醒并进行后续的处理动作。

这写的还是挺像人话的,就是暂时不知道有什么用

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 之后,因为他们只是相对于方法本身的。

这段稍微有点绕,但是等到自己写代码用到的时候倒腾倒腾应该也还好