1 线程的核心
对于一个函数:
- 有自己的栈,只有不破坏栈即可(如数组越界访问),无需手动保存
- 指令、全局/静态变量、常量都保存在flash上(启动时会将变量重定位到RAM中),无需手动保存
- r0~r3、r12、lr、返回地址pc、xpsr发生中断/异常时,由硬件自动保存,并且在退出异常服务例程后,会自动恢复。
- 进入函数前,保存r4~r11到自己的栈中,退出函数后,恢复r4~r1。(由ATPCS规定,C函数只要使用到这些寄存器,生成的汇编指令都会在进入函数时,将寄存器Push到栈中,退出时从栈中Pop)
但是,如果在C函数里面去切换任务,则别的子程序可能会破坏之前函数里使用的r4~r11,所以需要手动保存。
因此,线程的核心就是栈,即提前开辟一块内存空间,从高地址开始存放数据(满减栈),切换时保存好现场(r0~15、xpsr),切换回来时从栈中恢复现场。
栈的大小主要取决于函数调用深度和局部变量大小(入口参数,返回值一般所占空间较小),如函数A调用了a1,a1又调用了a2,a2又调用了a3,当在调用a2时发生了任务切换,则函数A需要保存自己的数据、函数a1和a2的数据,任务切换时的现场(寄存器):
2 创建线程
此处参考RT-Thread
对于一个线程至少要有:
- 线程入口函数的地址
- 线程入口函数的参数(可不用,一般为指针变量)
- 栈的起始地址
- 栈的大小
- 线程控制块(未使用)
初始化函数如下:
void thread_init(void (*entry)(void *param), void *param, void *stack_base, rt_uint32_t stack_size)
{
struct stack_frame *stack_frame;
rt_uint32_t *stack_top = (rt_uint32_t*)stack_base + stack_size;
stack_top -= sizeof(struct stack_frame);
stack_frame = (struct stack_frame *)stack_top;
// 伪造现场
for (rt_uint32_t i = 0; i < sizeof(struct stack_frame) / sizeof(rt_uint32_t); i++){
((rt_uint32_t*)stack_frame)[i] = 0xdeadbeef;
}
stack_frame->exception_stack_frame.r0 = (unsigned long)param; /* r0 : argument */
stack_frame->exception_stack_frame.r1 = 0; /* r1 */
stack_frame->exception_stack_frame.r2 = 0; /* r2 */
stack_frame->exception_stack_frame.r3 = 0; /* r3 */
stack_frame->exception_stack_frame.r12 = 0; /* r12 */
stack_frame->exception_stack_frame.lr = 0; /* lr -- rt_thread_exit*/
stack_frame->exception_stack_frame.pc = (unsigned long)entry;
stack_frame->exception_stack_frame.psr = 1 << 24; /* thumb */
// 保存线程的栈顶指针
thread_stack[thread_count++] = (unsigned long)stack_top;
}
- 线程栈顶指针
stack_top
指向数组末尾地址stack_base + stack_size
stack_frame
为需要保存的寄存器结构类型:
struct exception_stack_frame // 硬件自动保存
{
rt_uint32_t r0;
rt_uint32_t r1;
rt_uint32_t r2;
rt_uint32_t r3;
rt_uint32_t r12;
rt_uint32_t lr;
rt_uint32_t pc;
rt_uint32_t psr;
};
struct stack_frame // 软件手动保存
{
/* r4 ~ r11 register */
rt_uint32_t r4;
rt_uint32_t r5;
rt_uint32_t r6;
rt_uint32_t r7;
rt_uint32_t r8;
rt_uint32_t r9;
rt_uint32_t r10;
rt_uint32_t r11;
struct exception_stack_frame exception_stack_frame;
};
将stack_top减去sizeof(struct stack_frame)=64的大小,先空出64个字节用于初始化存放16个寄存器(SP单独保存),即人为伪造现场:
- 先将这些寄存器初始化为一个无意义的值
0xdeadbeef
r0
初始化为线程入口函数的参数lr
应该初始化为线程退出函数的地址,但未做相关处理,因此初始化为0,所有线程的入口函数必须为无限循环模式pc
初始化为线程入口函数的地址xpsr
的bit24必须初始化为1,表示Thumb
状态
thread_stack
数组用于存放线程的栈顶指针,每初始化一个线程,线程数量+1
注意:r0~r15、xpsr不一定存放在线程栈顶往前64个位置,后面压栈弹栈时这些位置可能保存的是其他数据,因此只需要每次做好保存和恢复现场即可,无需关心这些寄存器保存在线程栈中的哪个位置。
上图可以看作线程栈运行时的情况,前面位置保存的都是调用put_s_hex
和put_hex
函数时保存的数据,后面发生线程调度时,硬件会依次将xpsr、pc、lr、r12、r0~r3保存起来,然后由软件手动保存r4~r11。r4~r11一般先保存高标号的寄存器,这是因为STM和LDM指令会将高标号的寄存器存放在高地址。
3 启动线程
在systick
异常服务例程中每隔1ms判断是否需要启动/切换线程,首先会判断启动运行标志位:
- false:直接返回
- true:判断是否是第一次启动
void SysTick_Handler(void)
{
// 异常进入活动态时, 其悬起位被硬件清除(但若由于所需的处理线程花费时间太长而导致悬起状态再次置位则需清除)
// SCB->ICSR |= SCB_ICSR_PENDSTCLR_Msk;
ticks++;
if (!is_thread_running())
return; // 表示无需切换线程
if (current_thread == -1)
{
/* 启动第1个线程 */
/* 切换线程: 从栈里恢复寄存器 */
to_thread_sp = get_thread_stack(0);
PendSV_Trigger();
}
}
其中is_thread_running
函数:
rt_uint8_t is_thread_running(void){
return thread_running;
}
该标志会在main函数中初始化线程完成后置位,线程启动和切换都会触发PendSV
异常。
3.1 PendSV异常的初始化
SCB_SHPR2 EQU 0xE000ED20 ; set pendsv priority
PendSV_PRI EQU 0x00FF0000 ; PendSV priority value (lowest)
PendSV_Init PROC
ldr r0, =SCB_SHPR2
ldr r1, =PendSV_PRI
str r1, [r0]
bx lr
ENDP
配置SCB_SHPR2
寄存器,将PendSV异常优先级设为最低。
3.2 PendSV异常触发函数
SCB_ICSR EQU 0xE000ED04 ; interrupt control state register
PendSV_SET EQU 0x10000000 ; value to trigger PendSV exception(BIT28)
PendSV_Trigger PROC
EXPORT PendSV_Trigger
ldr r0, =SCB_ICSR
ldr r1, =PendSV_SET
str r1, [r0]
bx lr
ENDP
将SCB_ICSR
寄存器bit28置位,将PendSV异常悬起标志置位,当异常进入活动态时,即进入其ESR,异常悬起标志会被硬件置位,因此一般无需手动清除。
3.3 PendSV异常服务例程
PendSV异常用于启动和切换线程
; 不能先push {lr}, 最后 pop {pc}, 因为sp指向的线程栈可能会改变!
; 硬件保存现场: r0~r3, r12, lr, pc, xpsr
PendSV_Handler PROC
IMPORT current_thread
ldr r1, =current_thread
ldr r0, [r1]
adds r0, #1
; 启动第1个线程
beq start_thread_context_switch
; 切换线程
bne thread_context_switch
ENDP
进入PendSV_Handler
时,硬件会保存被打断子程序的现场: r0~r3, r12, lr, pc, xpsr。如果当前运行的线程current_thread
的标号为-1,则会跳转到线程启动函数start_thread_context_switch
运行。
注意:不能使用bl、blx指令,因为在进入PendSV异常服务例程时,lr=EXC_RETURN
,异常退出时需要利用该值触发异常返回机制,恢复r0~r3, r12, lr, pc, xpsr。同时也不能采用进入异常push {lr}
,退出异常pop {pc}
返回的方法,因此sp指向的线程栈可能会改变!即一开始的压栈和最后的弹栈可能使用的不是同一个线程的栈。
3.4 线程第一次启动函数
start_thread_context_switch PROC
EXPORT start_thread_context_switch
IMPORT current_thread
IMPORT to_thread_sp
; 当前线程标号-1 -> 0
ldr r1, =current_thread
mov r0, #0
str r0, [r1]
; 从线程的栈里把r4~r11读出来写入寄存器
ldr r1, =to_thread_sp
ldr r0, [r1]
ldmia r0!, {r4 - r11}
; sp指向当前线程栈的栈顶位置
msr msp, r0
; 触发硬件中断返回->硬件恢复现场: 将栈里的r0~r3, r12, lr, pc, xpsr写入寄存器
bx lr ; lr=EXC_RETURN, 利用异常返回机制
ENDP
本质:恢复线程栈初始化时伪造的现场。
- 当前运行线程标号
current_thread
值:-1 -> 0,表示已执行首次调度 - 将线程栈中初始化时伪造的现场r4~r11值恢复到寄存器(
0xdeadbeef
) - 最后触发异常返回机制,硬件将线程栈中伪造的现场r0~r3, r12, lr, pc, xpsr值恢复到寄存器
4 切换线程
thread_context_switch PROC
EXPORT thread_context_switch
IMPORT update_thread_stack
IMPORT from_thread
IMPORT to_thread_sp
; 1. 保存当前线程的现场
stmdb sp!, {r4 - r11}
ldr r2, =from_thread
ldr r0, [r2]
mov r1, sp
mov r4, lr
; 2. 更新存当前线程栈指针
bl update_thread_stack
; 3. 切换到新线程
mov lr, r4
ldr r1, =to_thread_sp
ldr r0, [r1]
ldmia r0!, {r4 - r11}
msr msp, r0
bx lr
ENDP
本质:保存from线程的寄存器,恢复to线程的寄存器。
- from线程被打断后,硬件将r0~r3, r12, lr, pc, xpsr保存到from线程的栈中,然后进入ESR(此时sp为from线程的当前栈顶指针)
- 进入上下文切换函数:
- 将r4~r11保存到from线程的栈中
- 更新from线程的栈顶指针(即更新记录from线程当前栈顶指针位置的全局变量)
- 将sp切换为to线程的栈顶指针(此时sp已为to线程的当前栈顶指针)
- 将to线程栈中保存的r4~r11值恢复到寄存器
- 最后利用异常返回机制
lr = EXC_RETURN
,硬件从to线程的栈恢复r0~r3, r12, lr, pc, xpsr值到寄存器,切换到to线程中保存的返回地址位置处继续运行。
5 线程轮询调度测试
5.1 定义线程栈
// 程序栈是4字节对齐的
// 很多奇怪问题可能是栈空间不够导致的
static char stack_a[1024] __attribute__ ((aligned (4)));
static char stack_b[1024] __attribute__ ((aligned (4)));
static char stack_c[1024] __attribute__ ((aligned (4)));
5.2 定义线程入口函数
//! 线程还没有退出地址, 不用while1 会跑飞
void thread_a(void *param){
while (1){
my_printf("thread_a: %c", (char)param);
systick_delay_ms(1000);
}
}
void thread_b(void *param){
while (1){
my_printf("thread_b: %c", (char)param);
systick_delay_ms(1000);
}
}
void thread_c(void *param)
{
int sum = 0;
for (int i = 0; i <= 100; i++)
sum += i;
while (1)
{
my_printf("thread_c: sum = %d", sum);
systick_delay_ms(1000);
}
}
在main中初始化线程并首次启动:
int my_main()
{
thread_init(thread_a, (void*)'a', stack_a, sizeof(stack_a));
thread_init(thread_b, (void*)'b', stack_b, sizeof(stack_b));
thread_init(thread_c, (void*)0, stack_c, sizeof(stack_c));
thread_startup();
return 0;
}
其中thread_startup
函数会将线程启动运行标志置位:
void thread_startup(void)
{
thread_running = 1;
while (1);
}
当线程启动时,会切换到第一个初始化的线程A中运行,并且会在systick中轮询调度,不会再返回到main函数。(后面可以改进)
最后稳定运行2.5h
的打印log如下:
3个线程每执行一次打印,都会利用systick计数标志延时1秒,但是结果是每隔3秒才会同时打印,这是因为每隔1秒切换调用线程时,都会更新当前的systick->ctrl
值,每个线程的systick->ctrl
值需要经过一次循环调度,才会更新同步一致,因此最终打印间隔为3*1000秒。
6 总结与展望
- 线程切换的核心就是利用栈保存与恢复现场,其中硬件会保存r0~r3, r12, lr, pc, xpsr,我们需手动保存r4~r11
- 可以定义一个线程控制块,赋予线程优先级、状态、时间片等属性,利用就绪链表来管理线程的调度,实现按优先级抢占式调用,同优先级采用时间片轮询调用
- 可采用同步与互斥手段来保护临界段,否则可能出现如下问题,线程A和线程B同时执行a++(a=7):
- 线程A将a值加载到r0时,发生了线程调度,切换到了线程B
- 线程B执行完a++后,a=8,然后发起调度,切换回线程A
- 线程A继续执行r0++,但是此时r0值仍为7,+1后变为8存储到变量a
- 结果:执行两次++后,变量a的值为8
END