mokongking

关于RTOS操作系统的那些事

一、Cortex‑M3 异常与栈机制(RTOS 根基,必须吃透)

1. 两个栈指针 MSP / PSP

  • MSP 主栈指针:复位默认使用,main、中断服务函数全部默认跑在 MSP 上。
  • PSP 进程栈指针:专门给任务(线程)使用。所有任务跑在 PSP。

RTOS 核心设计:中断用 MSP,任务用 PSP。切换任务本质就是切换 PSP 的值。

2. 异常进入时硬件自动压栈(非常关键)

当触发异常(SysTick / PendSV),CPU 硬件自动把下面 8 个寄存器压入当前栈: xPSR, PC, LR, R12, R3, R2, R1, R0

这一步不需要写任何汇编,硬件自动完成,8 个寄存器,占 8 个 32bit 字。

剩下寄存器 R4~R11,硬件不会自动保存,必须我们手动存入任务栈。

所以 PendSV 上下文切换流程一句话总结: 进入 PendSV:硬件压 R0-R3,R12,LR,PC,xPSR;我们手动把 R4~R11 压栈;保存 PSP 到 TCB。 退出 PendSV:从新 TCB 取出 PSP,手动恢复 R4~R11;退出异常,硬件自动恢复 R0-R3,R12,LR,PC,xPSR。

3. PendSV 是什么?为什么选 PendSV 做任务切换

  • PendSV:可挂起系统调用异常,可以通过写SCB->ICSR寄存器手动置位触发。
  • 特点:等到所有更高优先级中断全部执行完毕之后,PendSV 才会执行。

这就是为什么任务切换必须用 PendSV: 如果在某个中断里面触发任务切换,PendSV 不会立刻抢占当前中断;等中断返回后才执行任务切换,不会破坏中断嵌套

永远记住:PendSV 必须是最低优先级异常!

4. SVC 异常

SVC 是系统服务调用,通过svc #0汇编指令触发。 我们用 SVC 来第一次启动调度器,从 main(MSP)切入第一个任务(PSP)。

FreeRTOS 原版也是 SVC 启动第一个任务。

二、TCB 任务控制块详细拆解

// 任务状态
typedef enum {
    TASK_READY,   // 就绪,可以被调度运行
    TASK_BLOCKED  // 阻塞,延时等待,不参与调度
} TaskState_t;

// 任务函数原型,和FreeRTOS保持一致
typedef void (*TaskFunction_t)(void *pvParam);

typedef struct TCB {
    uint32_t *pxTopOfStack;      // 【最重要】栈顶指针,PSP的值就存在这里
    struct TCB *pxNext;          // 链表指针,把所有TCB串成单向链表
    TaskState_t eState;          // 任务状态:就绪 / 阻塞
    uint32_t ulDelayWakeTick;    // 阻塞任务唤醒的tick计数
    uint8_t uxPriority;          // 任务优先级(后面做抢占调度用)
} TCB_t;

每个字段解释

  1. pxTopOfStack:指向任务栈最后一个保存寄存器的位置。每次切换任务,PSP 的值直接存 / 取这个指针。
  2. pxNext:单向链表,把所有创建的任务串起来。简易版本用轮询调度,遍历链表找就绪任务。
  3. eState:任务就绪 / 阻塞。阻塞的任务调度器会跳过。
  4. ulDelayWakeTick:调用vTaskDelay(n)时,ulDelayWakeTick = xTickCount + n;每次 SysTick 中断检查,当全局 tick >= 这个值,任务恢复就绪。
  5. uxPriority:预留,简易版先不用,后面升级抢占调度才用到。
  6. 全局变量:

    TCB_t *pxCurrentTCB;    // 当前正在运行的任务TCB指针
    TCB_t *pxReadyTasksList;// 任务链表头
    uint32_t xTickCount = 0;// 系统节拍计数器,SysTick每进一次中断+1
    

    任务栈设计

    每个任务拥有独立栈,静态全局数组,不使用 malloc。

    GD32F103RC RAM 总共 48KB,栈开太大很容易栈溢出 HardFault。

    #define TASK_STACK_SIZE 128U
    uint32_t Task1Stack[TASK_STACK_SIZE];
    uint32_t Task2Stack[TASK_STACK_SIZE];
    

    栈是从高地址向低地址增长(ARM Cortex-M 满递减栈 FD)。 Task1Stack[127] 是栈最高地址,创建任务初始化栈帧,从栈顶(数组末尾)往低地址填充。

  7. 三、任务栈初始化函数 prvInitialiseTaskStack 逐行详解

    static uint32_t *prvInitialiseTaskStack(uint32_t *pxTopOfStack, TaskFunction_t pxTaskCode)
    {
        // 【硬件自动压栈的8个寄存器,先填充】
        *(--pxTopOfStack) = 0x01000000UL;    // xPSR:bit24=1,Thumb模式,M3必须开启,否则死机
        *(--pxTopOfStack) = (uint32_t)pxTaskCode; // PC:任务入口地址,异常返回就跳到这里执行任务
        *(--pxTopOfStack) = 0xEFFFFFFFUL;    // LR:任务返回值,0xEFFFFFFF含义:返回时自动切回PSP
  8. 这三个值是手动伪造一个异常退出时的栈帧。 Cortex‑M3 从异常返回时,硬件会自动从栈里取出:xPSR, PC, LR, R12,R3,R2,R1,R0。 我们在创建任务的时候,预先在任务栈里把这 8 个字填好;等第一次 PendSV/SVC 异常返回,CPU 就以为刚刚发生过一次中断,直接从栈里读取这几个寄存器,跳去执行任务函数
  9.     *(--pxTopOfStack) = 0x00000000UL;    // R12
  10.     *(--pxTopOfStack) = 0x00000000UL;    // R3
        *(--pxTopOfStack) = 0x00000000UL;    // R2
        *(--pxTopOfStack) = 0x00000000UL;    // R1
        *(--pxTopOfStack) = 0x00000000UL;    // R0
    
        // 【软件手动保存寄存器 R4~R11】
        *(--pxTopOfStack) = 0x00000000UL;    // R11
        *(--pxTopOfStack) = 0x00000000UL;    // R10
        *(--pxTopOfStack) = 0x00000000UL;    // R9
        *(--pxTopOfStack) = 0x00000000UL;    // R8
        *(--pxTopOfStack) = 0x00000000UL;    // R7
        *(--pxTopOfStack) = 0x00000000UL;    // R6
        *(--pxTopOfStack) = 0x00000000UL;    // R5
        *(--pxTopOfStack) = 0x00000000UL;    // R4
    
        return pxTopOfStack;
    }
    

    核心理解:

    我们手动在任务栈里面伪造一套异常栈帧,模拟任务曾经被 PendSV 中断保存过现场。 当第一次恢复这个任务的时候,PendSV 退出异常,硬件会直接从栈帧读取 PC,跳转到任务入口函数。

    重点:0xEFFFFFFF,Cortex-M 特殊 LR 值。 当异常返回,LR=0xEFFFFFFF,CPU 会自动使用 PSP 作为栈指针,完美切到任务栈。 如果填错这个值,会继续使用 MSP,栈混乱直接 HardFault。

    1. *(--pxTopOfStack) = 0x01000000UL; → xPSR

    出处:ARMv7‑M 架构手册(Cortex‑M3 内核参考手册)

    xPSR 是组合程序状态寄存器。

    • 0x01000000 就是 bit24 = 1,这个位名字叫 T 位(Thumb bit)。

    为什么必须是 1?

    Cortex‑M3 硬件上只支持 Thumb 指令集,不支持 ARM 32 位指令集。 CPU 在执行代码时,会读取当前 T 位:

    • T=1:取 Thumb(16/32 位混合指令)
    • T=0:尝试取 ARM 指令 → M3 没有 ARM 指令解码器 → 立即 HardFault

    正常运行 C 代码的时候,编译器输出全部是 Thumb 指令,硬件自动保证 T=1。 但是我们是手动构造栈帧,CPU 不会自动帮你设置 T 位! 如果栈帧里 xPSR 的 T 位写 0,异常返回瞬间,T=0 → HardFault。

    能不能写别的值?

    bit24 必须 = 1;其他状态位(进位、零标志等)任务刚启动无所谓,填 0 即可。 所以固定写 0x01000000。

    因果:任务第一次被恢复运行,靠异常返回,异常返回会把栈里的 xPSR 加载进真实 xPSR 寄存器 → T 位决定指令集模式。


    2. *(--pxTopOfStack) = (uint32_t)pxTaskCode; → PC(程序计数器)

    出处:ARMv7-M 异常返回机制

    异常退出时,硬件会把栈里这个值加载到 PC,也就是接下来要执行的第一条指令地址。

    为什么填任务函数地址 pxTaskCode?

    我们伪造栈帧的目标:异常返回之后,CPU 直接跑任务函数。 所以 PC 字段直接填任务入口函数地址。

    注意一个细节:

    因为 T 位在 xPSR 里已经置 1,所以pxTaskCode地址最低位可以不用手动置 1(老很多教程会写 |1,那是旧习惯)。

    但有个小知识点:ARM 函数指针,地址 bit0=1 表示 Thumb 入口;但在栈帧 PC 字段,硬件不会用这个 bit 判断 Thumb 模式,栈帧里是看 xPSR 的 T 位。 所以栈帧 PC 直接填函数地址即可。

    因果:

    当 PendSV/SVC 异常返回,硬件把栈里这个值送入 PC → 跳转到任务函数,任务开始执行。


    3. *(--pxTopOfStack) = 0xEFFFFFFFUL; → LR(异常返回码,EXC_RETURN)

    【最容易混淆!这个不是普通函数返回地址!!】

    重点:这个 LR 不是普通 C 语言函数调用的 LR! 这是 EXC_RETURN 异常返回特殊码,ARMv7-M 架构定义的特殊魔术值。

    出处:ARMv7-M Architecture Reference Manual B1.5.8 Exception return

    当进入异常时,CPU 会自动把一个 32bit 的特殊值保存到 LR,这个值叫 EXC_RETURN。 退出异常的时候,把这个值放到 PC,触发异常返回逻辑;这个值的高 4 位固定是 0xE,代表这是异常返回码,不是普通地址。

    0xEFFFFFFF 拆解(二进制高位):

    Bit [31:28] = 0xE → 标记:这是EXC_RETURN异常返回码
    Bit 2 = 0 → 返回后使用 PSP(进程栈指针)
    Bit 1 = 1 → 返回后保持Thumb模式
    Bit 0 = 1 → 返回到线程模式(不是异常模式)
    

    完整含义:

    当执行 BX LR,LR=0xEFFFFFFF → 触发异常返回,返回线程模式,使用 PSP 栈。

    还有另外几个常见 EXC_RETURN 码,对比一下你就懂区别:

    • 0xFFFFFFF1:返回线程模式,使用 MSP(主栈)
    • 0xFFFFFFF9:返回异常模式,使用 MSP
    • 0xFFFFFFFD:返回异常模式,使用 PSP
    • 0xEFFFFFFF:返回线程模式,PSP,正是我们 RTOS 任务需要!

    为什么必须写这个值?因果关系

    我们伪造栈帧,模拟 “刚刚从线程模式进入异常”。 当我们做完上下文恢复,执行BX LR,LR 等于这个魔术值。 CPU 识别到高 4 位是 0xE,就知道:这不是普通函数跳转,要执行异常返回流程。 异常返回流程:

    1. 从 PSP 指向的栈,弹出 8 个寄存器(xPSR,PC,LR,R12,R3,R2,R1,R0)
    2. 切换到线程模式,使用 PSP(任务栈)运行代码

    👉 如果这里写错 EXC_RETURN 值: 比如写 0xFFFFFFF1,返回时使用 MSP,任务栈完全不会被使用,直接栈错乱 HardFault。

    ❗千万不要误以为:这个 LR 是任务函数 return 之后跳转到哪里。 任务函数绝对不能 return! 任务是死循环,如果任务函数 return,会把这个0xEFFFFFFF当成普通地址去跳转,直接死机。 EXC_RETURN 只在异常返回场景才有特殊含义,在普通代码里它就是非法地址。


    三者前后关联:栈的填充顺序(栈是满递减 FD,重点!)

    GD32F103 Cortex‑M3:栈从高地址向低地址增长。 --pxTopOfStack:先把指针减 1(指向更低地址),再写入数值。

    我们构造栈帧顺序:

    高地址 → 低地址
              xPSR        ← 最先写入 *(--pxTopOfStack)
              PC          ← 第二个写入
              EXC_RETURN  ← 第三个写入
              R12
              R3
              R2
              R1
              R0
              R4~R11(我们软件手动压在更下面)
    

    真实硬件发生中断时,硬件压栈顺序: 进入异常时,硬件自动依次压入栈(从高地址往低地址): xPSR, PC, LR(EXC_RETURN), R12, R3, R2, R1, R0

    ✅ 我们手动构造栈帧,严格复刻硬件压栈顺序! 顺序一点不能颠倒!颠倒之后异常返回读到的寄存器全部错位,HardFault。

    因果链条完整串起来:

    1. 创建任务,在任务栈从高到低,按硬件压栈顺序,写入 xPSR、PC、EXC_RETURN、R12~R0,再压 R4-R11。
    2. 第一次调度触发 SVC 异常。
    3. SVC 汇编恢复 R4-R11,执行 BX LR,LR=0xEFFFFFFF,触发异常返回。
    4. CPU 识别 EXC_RETURN=0xEFFFFFFF:切换线程模式,使用 PSP。
    5. 硬件从 PSP 指向的栈,弹出 8 个寄存器:xPSR (T=1)、PC (任务函数地址)、EXC_RETURN...
    6. PC 被加载成任务函数地址,xPSR.T=1 保持 Thumb 模式。
    7. CPU 开始执行任务代码。

    常见误区汇总

    1. ❌ 误区:0xEFFFFFFF是任务函数返回地址。 ✅ 纠正:不是地址,是 ARM 架构规定的异常返回魔术码,只有 BX 这个值的时候触发异常返回。任务不能 return。
    2. ❌ 误区:xPSR 随便写,只要 T=1 就行。 ✅ 纠正:是的,bit24 必须 = 1,其余状态位初始 0 没问题,所以固定 0x01000000。
    3. ❌ 误区:PC 地址要 | 1。 ✅ 纠正:栈帧内 PC 不需要 | 1;栈帧里由 xPSR 的 T 位控制 Thumb 模式。(普通函数指针才需要 | 1)
    4. ❌ 误区:顺序可以随便调换。 ✅ 纠正:栈帧顺序必须和硬件自动压栈顺序完全一致,一旦错位,异常返回读错寄存器,直接 HardFault。
    5. 四、任务创建函数 xTaskCreate

      嵌入式尽量不要 malloc,这里改成静态 TCB 数组版本,工程更稳定。

      // 静态TCB池,最多创建4个任务,可根据需求改
      #define MAX_TASK_NUM 4
      TCB_t TCB_Pool[MAX_TASK_NUM];
      uint8_t TCB_Count = 0;
      
      TCB_t* xTaskCreate(TaskFunction_t pxTaskCode, uint32_t *puxStackBuffer, uint8_t uxPriority)
      {
          if(TCB_Count >= MAX_TASK_NUM)
          {
              return NULL; // 任务池满
          }
          TCB_t *pxNewTCB = &TCB_Pool[TCB_Count++];
      
          // 栈顶 = 栈数组最后一个元素地址
          uint32_t *pxStackTop = &puxStackBuffer[TASK_STACK_SIZE - 1U];
          pxNewTCB->pxTopOfStack = prvInitialiseTaskStack(pxStackTop, pxTaskCode);
      
          pxNewTCB->eState = TASK_READY;
          pxNewTCB->uxPriority = uxPriority;
      
          // 头插法插入任务链表
          pxNewTCB->pxNext = pxReadyTasksList;
          pxReadyTasksList = pxNewTCB;
      
          return pxNewTCB;
      }
      

      头插法:新任务插到链表头部,pxReadyTasksList永远指向最新任务。

      五、汇编文件 portasm.s(Keil AC5 语法,逐行解释)

          .syntax unified
          .thumb
      
          ; 声明全局函数,供C代码调用
          .global vPortPendSVHandler
          .global vPortSVCHandler
          .global pxCurrentTCB
      
      vPortPendSVHandler:
          ; 1. MRS R0, PSP:读取当前进程栈指针PSP到R0
          MRS R0, PSP
          ; 2. STMDB R0!, {R4-R11}
          ; STMDB:Store Multiple Decrement Before,满递减栈,把R4~R11存入PSP指向栈,R0自动更新
          STMDB R0!, {R4-R11}
      
          ; 3. 取出pxCurrentTCB地址,把更新后的R0存入TCB->pxTopOfStack
          LDR R1, =pxCurrentTCB
          LDR R2, [R1]
          STR R0, [R2]
      
          ; 4. 调用C函数 vTaskSwitchContext,挑选下一个要运行的任务
          BL vTaskSwitchContext
      
          ; 5. 读取新任务TCB,取出新任务栈顶
          LDR R1, =pxCurrentTCB
          LDR R2, [R1]
          LDR R0, [R2]
      
          ; 6. LDMIA R0!, {R4-R11}:从栈恢复R4~R11
          LDMIA R0!, {R4-R11}
      
          ; 7. MSR PSP, R0:把新栈顶写入PSP
          MSR PSP, R0
      
          ; 8. BX LR,退出异常,硬件自动恢复R0-R3、R12、LR、PC、xPSR
          BX LR
      
      ; SVC中断服务函数:调度器第一次启动,用来切换到第一个任务
      vPortSVCHandler:
          LDR R0, =pxCurrentTCB
          LDR R1, [R0]
          LDR R2, [R1]
          LDMIA R2!, {R4-R11}
          MSR PSP, R2
          MOV R0, #0
          MSR CONTROL, R0
          ISB
          BX LR
      

      CONTROL寄存器 bit0:0=MSP,1=PSP。ISB指令:指令同步屏障,刷新流水线,必须写,否则行为不稳定。

      六、调度器核心:vTaskSwitchContext

      功能:遍历任务链表,选出下一个就绪任务。简易版本是轮询调度(时间片轮转,不支持抢占)。

      void vTaskSwitchContext(void)
      {
          TCB_t *pxNextTCB = pxCurrentTCB->pxNext;
      
          // 循环遍历链表找就绪任务
          while(1)
          {
              if(pxNextTCB->eState == TASK_READY)
              {
                  pxCurrentTCB = pxNextTCB;
                  break;
              }
              // 当前任务阻塞,取下一个
              pxNextTCB = pxNextTCB->pxNext;
              // 链表循环到头,回到链表头部
              if(pxNextTCB == NULL)
              {
                  pxNextTCB = pxReadyTasksList;
              }
          }
      }
      

      逻辑: 从当前任务的下一个任务开始遍历,遇到TASK_READY就选中为新任务。 如果任务是阻塞状态,直接跳过。

      七、SysTick 中断(系统节拍 tick)

      SysTick:系统定时器,配置为 1ms 中断一次,也就是 1 tick = 1ms。

      void SysTick_Handler(void)
      {
          uint32_t ulOldIntState = __get_PRIMASK();
          __disable_irq(); // 关中断,保护TCB链表访问
      
          xTickCount++;
      
          // 遍历所有任务,检查阻塞任务是否到期唤醒
          TCB_t *pxTCB = pxReadyTasksList;
          do
          {
              if( (pxTCB->eState == TASK_BLOCKED) && (xTickCount >= pxTCB->ulDelayWakeTick) )
              {
                  pxTCB->eState = TASK_READY;
              }
              pxTCB = pxTCB->pxNext;
          }while(pxTCB != NULL);
      
          __set_PRIMASK(ulOldIntState); // 恢复中断状态
      
          // 置位PendSV,触发任务切换
          SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
      }
      

      为什么要关中断?TCB 链表是全局共享数据,SysTick 中断访问链表时,如果同时有代码修改链表会造成链表损坏、死机。 使用__get_PRIMASK()保存中断掩码,不要直接无脑enable_irq(),避免把原本关闭的中断强行打开。

      八、阻塞延时函数 vTaskDelay

      void vTaskDelay(uint32_t ticks)
      {
          if(ticks == 0)
          {
              return;
          }
          uint32_t ulOldIntState = __get_PRIMASK();
          __disable_irq();
      
          // 当前任务标记阻塞,计算唤醒tick
          pxCurrentTCB->eState = TASK_BLOCKED;
          pxCurrentTCB->ulDelayWakeTick = xTickCount + ticks;
      
          __set_PRIMASK(ulOldIntState);
      
          // 立刻触发PendSV,马上切换别的任务
          SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
      }
      

      注意:这是阻塞延时,不是忙等。任务进入阻塞状态,调度器不会再运行它,CPU 去跑其他就绪任务。FreeRTOS 的vTaskDelay原理一模一样。

      九、调度器启动函数 vTaskStartScheduler

      void vTaskStartScheduler(void)
      {
          // 初始当前任务指向链表头
          pxCurrentTCB = pxReadyTasksList;
      
          // SysTick初始化,1ms中断,GD32F103默认系统时钟72MHz
          SysTick_Config(SystemCoreClock / 1000UL);
      
          // 设置NVIC优先级
          NVIC_SetPriority(SysTick_IRQn, 1);
          NVIC_SetPriority(PendSV_IRQn, 15);
      
          // SVC触发,第一次上下文切换,进入第一个任务
          __asm volatile ("svc 0");
      
          // svc触发后永远不会回到这里
          while(1);
      }
      

      GD32F103RC 默认 HSE 72MHz,SystemCoreClock=72000000,72000000 /1000 =72000,SysTick 每 72000 个时钟脉冲触发一次中断 = 1ms。

      十、任务示例代码 + main 函数

      void Task1(void *pvParam)
      {
          while(1)
          {
              gpio_bit_set(GPIOA, GPIO_PIN_5);
              vTaskDelay(500);
              gpio_bit_reset(GPIOA, GPIO_PIN_5);
              vTaskDelay(500);
          }
      }
      
      void Task2(void *pvParam)
      {
          while(1)
          {
              gpio_bit_set(GPIOB, GPIO_PIN_5);
              vTaskDelay(1000);
              gpio_bit_reset(GPIOB, GPIO_PIN_5);
              vTaskDelay(1000);
          }
      }
      
      int main(void)
      {
          // GD32标准库初始化:时钟、GPIO
          rcu_periph_clock_enable(RCU_GPIOA);
          rcu_periph_clock_enable(RCU_GPIOB);
      
          gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5);
          gpio_init(GPIOB, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5);
          gpio_bit_reset(GPIOA, GPIO_PIN_5);
          gpio_bit_reset(GPIOB, GPIO_PIN_5);
      
          // 创建两个任务
          xTaskCreate(Task1, Task1Stack, 1);
          xTaskCreate(Task2, Task2Stack, 1);
      
          // 启动调度器,不再返回main
          vTaskStartScheduler();
      
          while(1)
          {
              // 永远不会执行到这里
          }
      }
      

      任务函数必须死循环,不能 return。一旦 return,PC 跳转到非法地址,HardFault。

      十一、调试 HardFault 常见原因(GD32F103RC 高频踩坑)

      1. ❌ xPSR 没有设置 bit24=1:Thumb 位丢失,进入 ARM 模式,M3 不支持 ARM 指令,直接 HardFault。
      2. ❌ PendSV 优先级不是最低:中断嵌套时触发任务切换,破坏栈帧。
      3. ❌ 栈开太小:任务内部局部变量太多,栈溢出,覆盖全局变量 / TCB。
      4. ❌ 混淆 MSP 和 PSP:LR 值不是 0xEFFFFFFF,异常返回栈指针错误。
      5. ❌ 访问 TCB 全局变量没有关中断:SysTick 和任务同时修改链表,链表指针错乱。
      6. ❌ SysTick 时钟配置错误:tick 不准或者根本不进中断。
      7. ❌ 栈数组定义局部变量:栈放在栈段而不是全局静态区,直接被覆盖。任务栈必须全局 static。

      十二、功能迭代路线(一步步升级,贴近 FreeRTOS)

      1. 当前版本:轮询调度、阻塞延时、上下文切换(时间片轮转,无抢占)
      2. 增加:抢占式调度:SysTick 中断发现存在更高优先级就绪任务,触发 PendSV 抢占低优先级任务。
      3. 增加:空闲任务:所有任务都阻塞时,自动运行空闲任务。
      4. 增加:队列 Queue,任务间数据传递。
      5. 增加:二进制信号量、计数信号量、互斥锁(注意优先级反转问题)。
      6. 增加:任务删除、栈溢出检测(栈水印)。
      7. 增加:事件组、软件定时器。
      8. AI生成用作记录

posted on 2026-09-17 19:05  虎啸岳林  阅读(29)  评论(0)    收藏  举报

导航