skaiuijing

前言

在前面一篇文章中,笔者带领大家使用PendSV中断实现了多线程,也终于度过了关于arm架构最难的部分。现在,笔者可以带领大家稍微迈入一点大家所熟悉的操作系统的世界,也就是多线程的并发问题。

操作系统中的并发

试想,RTOS中如果两个线程同时对一个全局资源进行更改,那么会发生什么情况?例如,A线程要向一个地址写入数据,恰好B线程也要向一个地址写入数据,这种情况下会发生什么呢?

显然,最终的结果是第二个完成写入的线程胜利。

竞态通常作为对资源的共享访问而产生,当两个线程访问相同的的资源时,混合的可能性就永远存在。所以,笔者给出两点建议:

1.只要可能,就应该避免资源的共享

这种思想最直接的方式就是减少全局变量的使用。如果我们使用全局变量,必须要考虑是否会造成多个线程并发访问的场景。然而,在实际开发中,这其实是一种奢望,因为硬件资源本身就是共享的,而且有时候为了内存不得不使用全局变量。

2.必须显式地管理对共享资源的访问

我们必须确保一次只有一个线程访问共享资源,那么,我们该如何做到呢?

临界区与原子操作

共享资源往往被称之为临界资源,为了使得操作共享资源时只有一个线程,我们引入了临界区和原子操作的概念。它们的本质是一样的,都是在执行过程中不能被打断的一段代码。

临界区

在RTOS中通常是通过开关中断来实现。当然,早期的unix和linux等操作系统不支持对称多处理时,并发执行的唯一原因也是硬件中断服务,开关中断也是一种常用的实现对资源的唯一访问的手段。

原子操作

不一定通过开关中断实现,也可以通过汇编(ldrex和strex等指令)或者锁等方式来实现对资源的唯一访问。

如何防止临界区被打断?

在Sparrow RTOS中,我们使用PendSV中断来完成上下文切换,从而实现多线程,这可能会造成竞态的出现。还有一种可能,就是外部中断的发生,导致当前任务对共享资源的访问被打断。我们发现,Sparrow中的并发都来自中断,那么,只要在对临界资源的访问的程序中加上关闭中断的操作,这段程序就只有当前线程执行了,当然,执行完后我们肯定要开启中断。

先分析RTOS的运行架构,以cm3为例,它的中断屏蔽寄存器如下:

img

详细信息:

image-20241027111314257

现在我们将要使用BASEPRI寄存器,其实用其他两个都是可以的,但是笔者感觉还是灵活一点比较好。

进入临界区

configShieldInterPriority 是宏,其值为191,由于BASEPRI寄存器是一个八位的寄存器,它是高四位有效,所以它的值是11,默认屏蔽11及以上的中断。顺便一提,之所以使用uint32_t而不是uint8_t,是考虑

(可能有读者疑惑为什么要使用191而不是11,答案是:大小端序问题。笔者曾经被这玩意坑过不少次。PendSV中断的默认优先级是15,当移植RTOS发现PendSV中断都不响应时,此时就可以怀疑CPU到底是大端序还是小端序了。当然,也有可能是编译器干的)

下面的代码就是往basepri寄存器中写入191,return xReturn是考虑代码进入临界区时,可能又发生中断,中断里面也有临界区,当退出中断时,此时basepri的值就会变成默认状态的0,这段代码之后的临界区就是无效的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23


__attribute__((always_inline)) inline uint32_t xEnterCritical( void )
{
uint32_t xReturn;
uint32_t temp;

__asm volatile(
" cpsid i \n"
" mrs %0, basepri \n"
" mov %1, %2 \n"
" msr basepri, %1 \n"
" dsb \n"
" isb \n"
" cpsie i \n"
: "=r" (xReturn), "=r"(temp)
: "r" (configShieldInterPriority)
: "memory"
);

return xReturn;
}

退出临界区

将之前保存的值写入basepri寄存器即可:

1
2
3
4
5
6
7
8
9
10
11
12
__attribute__((always_inline)) void xEixtCritical( uint32_t xReturn )
{
__asm volatile(
" cpsid i \n"
" msr basepri, %0 \n"
" dsb \n"
" isb \n"
" cpsie i \n"
:: "r" (xReturn)
: "memory"
);
}

实验

现在我们进行一个小实验来验证我们的临界区是否成功实现,我们需要验证临界区是否能够屏蔽中断,那我们使用什么中断进行验证呢?

不要忘了上下文切换函数PendSV本身就是一个中断。

先加上我们自己写的延时函数(hal库的hal_delay容易出bug导致卡死,与它的计数默认使用中断进行有关),修改led_bright函数如下:

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
void fone(uint32_t time)
{
uint8_t i = 0;
while (time--)
{
i = 10;
while (i--)
;
}
}


void ftwo(uint32_t time)
{
uint8_t i = 0;
while (time--)
{
i = 10;
while (i--)
;
}
}

void theone(uint32_t one)
{
while (one--)
{
fone(1000);
}
}


void thetwo(uint32_t two)
{
while (two--)
{
ftwo(1000);
}
}


uint32_t count = 0;
void led_bright( )
{
while (1) {
uint32_t xre = xEnterCritical();

switchTask();
theone(1000);
count++;
xExitCritical(xre);
}
}


编译然后下载到开发板上进行调试,先介绍一下实验思路;

我们在count++上面添加了一个延时1s的函数。如果临界区没有屏蔽中断,那么延时时肯定会触发PendSV中断,那我们运行到中断里面时,count的值仍然是0。如果临界区是正确的,那么count的值会是1,这证明是在延时1s后才触发的中断。

先打上断点;

image-20241027131951645

gdb下,按c运行到断点处,现在count为0。

在PendSV中断这里也打上断点:

image-20241027130435942

按r运行到PendSV中断里面。

打印:

image-20241027130449685

结果是1,说明我们的临界区起作用了。

总结

讲解了操作系统中的一些并发问题,然后通过配置中断寄存器的方式实现了临界区,最后通过实验来验证临界区是否成功执行。

本次实验的文件夹:skaiui2/SKRTOS_sparrow at experiment