[018] [ARM-Cortex-M3/4] 利用pendsv和systick异常编写一个简单的RTOS

ARM
Contents
线程的核心
创建线程
启动线程
PendSV异常的初始化.
PendSV异常触发函数.
PendSV异常服务例程.
线程第一次启动函数
切换线程
线程轮询调度测试
定义线程栈
定义线程入口函数
总结与展望

1 线程的核心

image-20220322174624996

对于一个函数:

  • 有自己的栈,只有不破坏栈即可(如数组越界访问),无需手动保存
  • 指令、全局/静态变量、常量都保存在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的数据,任务切换时的现场(寄存器):

image-20220323193354556

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状态

image-20220323202144487

  • thread_stack数组用于存放线程的栈顶指针,每初始化一个线程,线程数量+1

注意:r0~r15、xpsr不一定存放在线程栈顶往前64个位置,后面压栈弹栈时这些位置可能保存的是其他数据,因此只需要每次做好保存和恢复现场即可,无需关心这些寄存器保存在线程栈中的哪个位置。

image-20220323215353176

上图可以看作线程栈运行时的情况,前面位置保存的都是调用put_s_hexput_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如下:

image-20220323212928301

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

image-20220323214304631

END

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柯西的彷徨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值