用 STM32 搞清楚 FreeRTOS 的任务上下文到底是什么

FreeRTOS 的上下文是一个细节特别多的事情,为了便于搞清楚原理,我们暂且不掺杂诸如 TrustZone、FPU 等一些其他特性,但这并不代表这些细节不属于上下文的一部分,而是我们故意先搁置它们以便简化这部分内容,使我们能够聚焦于主线,这样对初学者能够更好地入门,要想看明白这篇笔记,前提需要你对 ARM Cortex-M 的寄存器有一些了解。希望我总结的这篇笔记能够为想学习 FreeRTOS 内部机制 的人提供一些帮助。

下文的介绍会涉及到 FreeRTOS 的源码片段,代码来源于一个专用于 FreeRTOS 学习的仓库,地址为:gitcode.com/qingshan120...

任务被切走的时候,到底丢了什么

假设两个任务都在 while (1) 里运行,但是 CPU 只有一套 R0--R15,两个任务却像在同时跑。

那问题就来了:任务 A 算到一半被切走,过一会儿又切回来,它为什么可以接着上次切走前往下接续运行?

答案朴素得有点无聊:切走之前,把"下次回来还要用"的寄存器值先保存到内存里;切回来之前,再恢复回去。保存的这份东西,就叫上下文(context)。

这里有个容易被忽略的细节:FreeRTOS 不是每次都把 16 个通用寄存器全存一遍。 FreeRTOS 的软件和 CPU 的硬件之间有分工 ------ 异常进 CPU 的那一瞬间,硬件会自动帮你压好 8 个;剩下 8 个硬件不管,得软件自己压。所以"上下文"在内存里,就是一串按固定顺序摆好的 32 位字,加一根指着它的栈指针。

而这串字最反直觉的地方是:任务还没开始跑,它就已经摆好了 。任务甚至不知道自己被"造"过------它睁开眼,PC 就已经站在函数第一行了。

先做个思想实验。任务 A 正在算 sum += i,编译器把 sum 放在 R4 里。中断来了,调度器决定换任务 B。如果 R4 不保存,等 A 再被切回来,它接着算的 sum 就是任务 B 留在 R4 里的东西,算出来的结果没人能解释。比这更致命的是 PC:没有它,CPU 根本不知道该从哪条指令继续。

所以上下文里最不能少的两样东西,一个是"我现在在哪"(PC),一个是"我手里攥着什么"(通用寄存器)。栈指针则负责告诉 CPU:这份东西存在哪。

顺带一提,这也解释了为什么 RTOS 的上下文切换开销基本是个常数:要保存和恢复的寄存器数量是写死的,跟任务里有多少局部变量无关。

这篇笔记就是把这份"伪造的现场"一格一格拆开看。

先分清两套栈:MSP 和 PSP

Cortex-M 有两个栈指针,MSP(主栈)和 PSP(进程栈)。同一时刻只有一个在干活,选谁由 CONTROL 寄存器的 bit1 说了算。

几个必须记住的规矩:

  • 复位之后,MSP 自动从向量表第 0 个字加载。以本仓库的链接脚本为例,_estack = 0x2000_0000 + 96K = 0x2001_8000,于是 main() 就是在这根栈上跑的。
  • 只要进了异常(中断),就一定是 MSP ,没得选。handler 模式不允许跑在 PSP 上。
  • 任务里的普通代码跑在 PSP 上。异常返回时回哪根栈,由 EXC_RETURN 的 bit2 决定------这个值后面会反复出现。
  • CONTROL 还有两位:bit0 nPRIV 决定特权级,bit2 FPCA 表示"这个任务用过浮点没有"。


~图 1 · MSP 与 PSP 各管一段:复位和中断走主栈,任务代码走进程栈~

为什么要搞两套?因为任务的栈可以很小、可以各自独立,任务切走时只要换一个 PSP 就完事;而中断嵌套多深,都不用去关心某个任务的栈还剩多少。物理上它们都在同一片 RAM 里,只是逻辑上分开了。

如果所有任务都挤在 MSP 上会怎样?每个任务的栈需求不一样,一个任务压栈压深了,踩到的是另一个任务的现场,甚至是中断的现场。分成两根之后,任务栈溢出顶多弄死自己(配合栈溢出检查还能提前报警),中断那根栈始终是干净的。

还有个容易忽略的点:切换任务时,硬件并不会"自动帮你换栈"。真正换栈的动作,就是 msr psp, r0 这么一条指令------把新的栈顶写进 PSP。所以"任务切换"在硬件层面看,无非是换了一套栈指针、换了一组寄存器值,仅此而已。

那么问题来了:任务还没跑起来,PSP 指向哪?指向一块刚申请出来的数组,数组顶上已经摆好了一份"假现场"。

任务栈里到底躺着什么

先说一个很多人踩过的坑:xTaskCreate 的栈深参数单位是字,不是字节。传 256,拿到的是 1024 字节。

真正决定"现场摆在哪个位置"的,是 prvInitialiseNewTask() 里这两行:

freertos/tasks.c:862

c 复制代码
    #if( portSTACK_GROWTH < 0 )
    {
        pxTopOfStack = &( pxNewTCB->pxStack[ ulStackDepth - ( uint32_t ) 1 ] );
        pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) ); /*lint !e923 !e9033 !e9078 MISRA exception.  Avoiding casts between pointers and integers is not practical.  Size differences accounted for using portPOINTER_SIZE_TYPE type.  Checked by assert(). */

        /* Check the alignment of the calculated top of stack is correct. */
        configASSERT( ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack & ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) == 0UL ) );

ulStackDepth 传进来时是字数,pxStack 又是 StackType_t *,所以 pxStack[ulStackDepth - 1] 正好是数组的最后一个字。数组本身按 8 字节 对齐分配(portBYTE_ALIGNMENT 是 8),但"字数 × 4"未必是 8 的倍数,于是有了第二行那次向下对齐------AAPCS 要求栈指针 8 字节 对齐,编译器生成的代码也默认按这个前提来。对齐之后的这个地址,我们后面统一叫它 T。

然后把 T 往下传给 port 层,让它去摆栈帧:

freertos/tasks.c:1056

c 复制代码
        #else /* portHAS_STACK_OVERFLOW_CHECKING */
        {
            pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );
        }

摆完的返回值,存进 TCB 的第一个成员:

freertos/tasks.c:252

c 复制代码
typedef struct tskTaskControlBlock             /* The old naming convention is used to prevent breaking kernel aware debuggers. */
{
    volatile StackType_t    *pxTopOfStack;    /*< Points to the location of the last item placed on the tasks stack.  THIS MUST BE THE FIRST MEMBER OF THE TCB STRUCT. */

那句 THIS MUST BE THE FIRST MEMBER 是 FreeRTOS 内核作者用大写字母喊出来的,原因很实际:SVC 和 PendSV 处理切换时,汇编里只做两次 ldr------先取 pxCurrentTCB 的地址,再取 [pxCurrentTCB]。如果 pxTopOfStack 不在偏移 0,每次切换都得多算一次偏移。


~图 2 · TCB 的第一个成员指着栈里最低的那个字;数组最高的那一格从头到尾没人用~

顺便说下 pxEndOfStack。它记的是栈的高地址端,只在开了 configRECORD_STACK_HIGH_ADDRESS 时才有这个字段。用途很实在:栈溢出检查、高水位统计,都得先知道"栈顶在哪",才能算出用了多少。

注意图里最上面那一格是空的。这不是画错了,也不是留白,后面会解释它为什么必然空着。

创建任务时,栈帧是如何构造出来的

先看全貌:一次任务切换要保存的寄存器,其实分三拨------硬件自动压的、FreeRTOS 手动压的、以及压根不单独占格子的。


~图 3 · 8 + 1 + 8 = 17 个字,一个不多一个不少~

真正干活的是 pxPortInitialiseStack()。它短得有点过分:

freertos/portable/GCC/ARM_CM4F/port.c:187

c 复制代码
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
	/* Simulate the stack frame as it would be created by a context switch
	interrupt. */

	/* Offset added to account for the way the MCU uses the stack on entry/exit
	of interrupts, and to ensure alignment. */
	pxTopOfStack--;

	*pxTopOfStack = portINITIAL_XPSR;	/* xPSR */
	pxTopOfStack--;
	*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;	/* PC */
	pxTopOfStack--;
	*pxTopOfStack = ( StackType_t ) portTASK_RETURN_ADDRESS;	/* LR */

	/* Save code space by skipping register initialisation. */
	pxTopOfStack -= 5;	/* R12, R3, R2 and R1. */
	*pxTopOfStack = ( StackType_t ) pvParameters;	/* R0 */

	/* A save method is being used that requires each task to maintain its
	own exec return value. */
	pxTopOfStack--;
	*pxTopOfStack = portINITIAL_EXC_RETURN;

	pxTopOfStack -= 8;	/* R11, R10, R9, R8, R7, R6, R5 and R4. */

一共十几行有效代码,没有任何循环,就是把指针往下挪、往格子里塞值。用到的那几个常量在这里:

freertos/portable/GCC/ARM_CM4F/port.c:85

c 复制代码
#define portFPCCR							( ( volatile uint32_t * ) 0xe000ef34 ) /* Floating point context control register. */
#define portASPEN_AND_LSPEN_BITS			( 0x3UL << 30UL )

/* Constants required to set up the initial stack. */
#define portINITIAL_XPSR					( 0x01000000 )
#define portINITIAL_EXC_RETURN				( 0xfffffffd )

/* The systick is a 24-bit counter. */
#define portMAX_24_BIT_NUMBER				( 0xffffffUL )

/* For strict compliance with the Cortex-M spec the task start address should
have bit-0 clear, as it is loaded into the PC on exit from an ISR. */
#define portSTART_ADDRESS_MASK		( ( StackType_t ) 0xfffffffeUL )

还有任务"意外返回"时该去哪儿:

freertos/portable/GCC/ARM_CM4F/port.c:104

c 复制代码
/* Let the user override the pre-loading of the initial LR with the address of
prvTaskExitError() in case it messes up unwinding of the stack in the
debugger. */
#ifdef configTASK_RETURN_ADDRESS
	#define portTASK_RETURN_ADDRESS	configTASK_RETURN_ADDRESS
#else
	#define portTASK_RETURN_ADDRESS	prvTaskExitError

现在逐段看。第一句注释就把话说透了:"模拟一次上下文切换中断会造出来的栈帧"(Simulate the stack frame as it would be created by a context switch interrupt)。也就是说,这段代码在干一件很狡猾的事------它假装刚才发生过一次异常,把异常硬件本来会压的那 8 个字,手动摆到了该在的位置上。

第一步,先往下让一格。 pxTopOfStack-- 之后才写 xPSR。为什么不让 xPSR 落在 T 上?因为异常返回时,硬件会从 PSP 开始往上连弹 8 个字,弹到 xPSR 那个字为止,弹完 PSP 就指向 xPSR 上面一格。如果 xPSR 放在 T,弹完 PSP = T + 4,直接越出数组了。所以 xPSR 必须待在 T - 4,数组最高的那一格从此永远空着。

第二步,写硬件帧的前三个字。 xPSR = 0x01000000,只有 T 位(bit24)是 1,告诉 CPU"接下来是 Thumb 代码"。xPSR 其实是 APSR、IPSR、EPSR 三个寄存器的合体,T 位就住在 EPSR 里;异常返回时硬件把它整块搬回去。PC = pxCode & 0xFFFFFFFE, pxCode 就是创建任务时指定的任务函数,顺手把 bit0 清掉------任务函数地址都是奇数(Thumb 位),可装进 PC 之前必须是偶数,否则异常返回时会 fault。LR = prvTaskExitError,意思是:任务函数要是敢 return,就会跳进这个函数,configASSERT 然后死循环,让你在调试器里一眼看见。

第三步,pxTopOfStack -= 5,一次跨过 5 格。 这 5 格对应 R12、R3、R2、R1、R0,但代码只给最低的那一格赋了值:R0 = pvParameters。注意这句赋值之前指针已经减了 5,所以写进去的正好是 R0,不是 R1。这也是唯一一个"任务函数能直接看见"的初始寄存器------任务函数的第一个参数,就是靠它送进去的。

第四步,写 EXC_RETURN = 0xFFFFFFFD。 这个值平时躺在 LR 里,异常返回时被硬件当命令读。0xFFFFFFFD 拆开看:低 4 位 1101 = 回 Thread 模式、用 PSP;bit4 = 1 表示"标准帧";bit5 = 0 表示回去还是特权级;bit6 = 0 是默认语义。bit4 这一位后面还会单独说。

第五步,pxTopOfStack -= 8,收工。 8 个格子对应 R4--R11,一个都没写,指针直接跨过去。返回的地址就是 R4 那一格。

数一下:1(xPSR)+ 1(PC)+ 1(LR)+ 5(R12、R3、R2、R1、R0)+ 1(EXC_RETURN)+ 8(R4--R11)= 17 个字 = 68 字节。

但真正被写过的只有 5 个:xPSR、PC、LR、R0、EXC_RETURN。剩下 12 个是"跳过",不是"清零"-------= 5 和 -= 8 只挪指针,这段代码自己没往那 12 个格子里写任何东西。

不过这里有个前提要交代清楚:很多工程在走到这一步之前,已经把整块栈清过了。只要开了 trace facility 或者高水位统计,prvInitialiseNewTask() 里会先 memset 整块栈成 0xA5(tskSET_NEW_STACKS_TO_KNOWN_VALUE 那个宏管的)。所以你在调试器里看到的这 12 格,通常是 0xA5,而不是随机数。但这是 tasks.c 的策略,不是 port 的承诺------换个配置,它就真的是上一任的残渣。


~图 4 · 17 个格子逐个点名:金色是硬件帧,紫色是 EXC_RETURN,青色是软件帧~

有人会问:既然这 12 个格子是空的,为什么 port 不顺手把它们也清零?因为 port 层只负责"摆现场",不负责"打扫房间"。整块栈要不要填充、填什么值,是 tasks.c 的策略(0xA5 水印,给高水位统计用);port 只保证"该有值的那 5 格有值"。两层各干各的,port 才能保持这么短。

还有三个常见的问题,顺手澄清一下。

第一个:为什么老有人说"18 个字、72 字节"? 因为数组顶上那格也被算进去了。那格确实存在,但它从头到尾没被写过,也不属于栈帧------栈帧是从 T - 4 开始往下摆的,T 只是那条"不许越过"的边界。数帧的时候把它算进去,就会多出 4 个字节。

第二个:R4--R11 为什么不初始化? 源码注释写得很直白:Save code space by skipping register initialisation------省代码空间。这段代码每个任务只跑一次,但那也是 flash 里的几十个字节,能省就省。代价是:这 8 个格子里要么是 0xA5,要么是上一任留下的残渣,总之都不是"你想要的值"。所以任务第一次读 R4--R11 之前,必须先给它赋值 。编译器在函数入口保存"被调用者保存寄存器"(callee-saved,即 R4--R11,函数返回后值必须不变)的时候,本来就会先写后读,所以这件事对 C 代码是透明的;但如果你在任务里手写汇编,别指望这 8 个格子里是 0。

第三个:FPU 呢? M4F 有 16 个 S0--S15 加 16 个 S16--S31,加起来比通用寄存器还多,凭什么一个都不在帧里?

因为 EXC_RETURN 的 bit4 是 1,表示"标准帧",硬件只压 8 个通用寄存器。要让浮点寄存器也入栈,得满足一串条件:任务真的用过浮点(CONTROL.FPCA = 1)、并且 FPCCR 里的 ASPEN 和 LSPEN 两位开着(xPortStartScheduler 会开,见下一节)。而任务刚出生时 FPCA 还是 0,什么都没用过,自然没什么可存。

真到任务用上浮点、被切走的那天,硬件会追加压 S0--S15,FreeRTOS 再补上 S16--S31------那是运行期 PendSV 的活儿,本文不展开。你只要记住:出生帧是 17 字,不是 33 字,因为 FreeRTOS 构造初始栈帧不使用浮点数,这时候还没有浮点上下文可言。

顺带说一句:如果你哪天读到一个 port 里写着 vstmdbeq r0!, {s16-s31},那就是在补浮点数这部分,跟本文的 17 个字是两码事。

svc 0:让伪造现场变成真的

栈帧摆好了,任务还是不会跑。得有人把 PSP 指过去,再把 PC 装进 PC。

这件事分三步:调度器做准备工作、prvPortStartFirstTask() 重置环境、svc 0 触发异常返回。

先看 xPortStartScheduler() 收尾那几行:

freertos/portable/GCC/ARM_CM4F/port.c:361

c 复制代码
	/* Make PendSV and SysTick the lowest priority interrupts. */
	portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
	portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;

	/* Start the timer that generates the tick ISR.  Interrupts are disabled
	here already. */
	vPortSetupTimerInterrupt();

	/* Initialise the critical nesting count ready for the first task. */
	uxCriticalNesting = 0;

	/* Ensure the VFP is enabled - it should be anyway. */
	vPortEnableVFP();

	/* Lazy save always. */
	*( portFPCCR ) |= portASPEN_AND_LSPEN_BITS;

	/* Start the first task. */
	prvPortStartFirstTask();

这里面跟上下文有关的有三件:把 PendSV 和 SysTick 的优先级压到最低(保证上下文切换不会打断更紧急的中断);使能 FPU;把 FPCCR 的 ASPEN | LSPEN 置上------这就是"惰性压栈"的开关,让硬件在任务真用了浮点时才追加压栈。至于 uxCriticalNesting = 0,先记住这行,一会儿有用。

然后是 prvPortStartFirstTask(),整段都是汇编:

freertos/portable/GCC/ARM_CM4F/port.c:261

c 复制代码
static void prvPortStartFirstTask( void )
{
	/* Start the first task.  This also clears the bit that indicates the FPU is
	in use in case the FPU was used before the scheduler was started - which
	would otherwise result in the unnecessary leaving of space in the SVC stack
	for lazy saving of FPU registers. */
	__asm volatile(
					" ldr r0, =0xE000ED08 	\n" /* Use the NVIC offset register to locate the stack. */
					" ldr r0, [r0] 			\n"
					" ldr r0, [r0] 			\n"
					" msr msp, r0			\n" /* Set the msp back to the start of the stack. */
					" mov r0, #0			\n" /* Clear the bit that indicates the FPU is in use, see comment above. */
					" msr control, r0		\n"
					" cpsie i				\n" /* Globally enable interrupts. */
					" cpsie f				\n"
					" dsb					\n"
					" isb					\n"
					" svc 0					\n" /* System call to start first task. */
					" nop					\n"
				);

逐句读:

  • ldr r0, =0xE000ED08 读的是 VTOR(向量表偏移寄存器),[VTOR] 是向量表第 0 项,也就是初始 MSP 。也就是说,这里是从向量表里把 _estack 又读了一遍。
  • msr msp, r0 把主栈重置回栈顶。main() 一路跑下来用过的那坨栈,就此作废------反正以后也用不上了。
  • mov r0, #0; msr control, r0 把 CONTROL 清零:清掉 FPCA(声明"还没有浮点上下文")、回到 MSP、保持特权级。
  • cpsie i / cpsie f 开中断。注意这两句在 svc 之前,也就是说,从这一刻起中断随时可能进来。
  • 最后 svc 0。这是全文最关键的一条指令。

为什么不直接 ldr r0, =_estack?因为 port 层不该知道链接脚本里的符号名------换个工程、换个链接脚本就编不过了。读 VTOR 再读向量表第 0 项,是唯一不依赖符号的通用做法;而 VTOR 指向的向量表,本来就是启动文件摆好的。

svc 是"请求操作系统服务"的指令,会触发 SVC 异常。而在用这个 port 的工程里,SVC_Handler 通常被宏映射到 vPortSVCHandler:

freertos/portable/GCC/ARM_CM4F/port.c:242

c 复制代码
void vPortSVCHandler( void )
{
	__asm volatile (
					"	ldr	r3, pxCurrentTCBConst2		\n" /* Restore the context. */
					"	ldr r1, [r3]					\n" /* Use pxCurrentTCBConst to get the pxCurrentTCB address. */
					"	ldr r0, [r1]					\n" /* The first item in pxCurrentTCB is the task top of stack. */
					"	ldmia r0!, {r4-r11, r14}		\n" /* Pop the registers that are not automatically saved on exception entry and the critical nesting count. */
					"	msr psp, r0						\n" /* Restore the task stack pointer. */
					"	isb								\n"
					"	mov r0, #0 						\n"
					"	msr	basepri, r0					\n"
					"	bx r14							\n"
					"									\n"
					"	.align 4						\n"
					"pxCurrentTCBConst2: .word pxCurrentTCB				\n"
				);
}
/*-----------------------------------------------------------*/

这段只有 9 条指令,但信息量很大:

  1. ldr r3, pxCurrentTCBConst2 / ldr r1, [r3]:先拿到 pxCurrentTCB 这个变量的地址,再读出它存的值------当前 TCB 的地址。此刻它已经被调度器指到了第一个要跑的任务。
  2. ldr r0, [r1]:TCB 的第一个成员就是 pxTopOfStack,一次 ldr 拿到栈顶。
  3. ldmia r0!, {r4-r11, r14}:从栈顶开始、往高地址连读 9 个字,装进 R4--R11 和 R14(也就是 EXC_RETURN)。读的正好是"软件帧 8 个 + EXC_RETURN 1 个"那 9 格,读完后 r0 自动加上 36,落在 T - 32。
  4. msr psp, r0:把 PSP 指到 T - 32,也就是 R0 那一格。从这一刻起,PSP 正式接管任务栈。
  5. msr basepri, r0(此时 r0 是 0):把 BASEPRI 清零,放行所有可屏蔽中断。
  6. bx r14:这不是普通函数返回,而是触发一次异常返回 。硬件读到 EXC_RETURN = 0xFFFFFFFD,于是按规矩从 PSP 弹 8 个字。

最后那一下硬件出栈,是这个过程的收尾:从 PSP = T - 32 开始,硬件依次弹出 R0、R1、R2、R3、R12、LR、PC、xPSR,弹完 PSP = T。然后:

  • PC 从栈里被装回 PC,指向任务函数的第一行;
  • xPSR 落进 APSR,T 位是 1,CPU 继续按 Thumb 执行;
  • R0 就是刚才写进去的 pvParameters;
  • LR 就是 prvTaskExitError;
  • PSP 停在数组顶上,8 字节对齐。

还有个细节值得注意:svc 0 执行的时候,CPU 已经处于 handler 模式,脚下踩的是 MSP。所以 vPortSVCHandler 里读写的 PSP 完全是"别人的"栈------它敢在异常里大摇大摆地改 PSP,却不怕把自己脚下的栈踩了。

于是任务函数就这么"被调用"了------它甚至分不清自己是被谁调起来的。这就是整个启动过程最漂亮的地方:没有一条指令是"跳转到任务函数",任务是被硬件异常返回"弹"进去的。


~图 5 · 从 xTaskCreate 到 TCB,写 5 格、挪 12 格~


~图 6 · 软件负责挪指针,硬件负责弹栈;红框里那两步不归 FreeRTOS 管~

笔记总结

回到开头那个问题:任务被切走的时候,到底丢了什么?

丢了 17 个字,和一根 PSP。

xPSR 里的 T 位、PC 里的入口地址、LR 里那个"不许返回"的地址、R0 里的任务参数、EXC_RETURN 里的回家路线------这 5 个有值的格子,加上 12 个暂时还没有意义的格子,就是一次任务切换的全部家当。

把几个数字钉在这儿,方便你回头查:

  • 17 个字 = 68 字节:一次任务切换要保存的上下文;
  • 5 个字被赋值 :xPSR、PC、LR、R0、EXC_RETURN;
  • 12 个字被跳过 :R12、R3、R2、R1、R4--R11;
  • 三个关键地址 :pxTopOfStack = T - 68,svc 之后 PSP = T - 32,硬件弹完 8 个字 PSP = T。

第一次 SysTick 到来之后,这套保存/恢复的逻辑会再被用到一次,只不过方向反过来------那是运行期切换的事情,任务切换时上下文是靠 PendSV 执行的,本文到这儿就收手来。剩下的那些,是调度算法、是链表、是优先级,是另一篇笔记要介绍的事。

下次你写 xTaskCreate 的时候,脑子里能闪过这 17 个格子,这篇笔记就没白写。

相关推荐
SZYSK5 小时前
鸿蒙人脸识别算法放在终端里:和外挂授权 SDK 差在排障路径
物联网·算法·华为·harmonyos
Flynn Liang5 小时前
MQTT 共享订阅$share/原理:怎么用它给消费端水平扩容做负载均衡
运维·物联网·mqtt·iot·mqtt broker
liubinkaixin6 小时前
一炉硬质合金的温度曲线,卡在几十根补偿导线上
物联网·1024程序员节
数字新视界8 小时前
动环监控可视化在机房管理中的创新应用与发展趋势
大数据·物联网·信息可视化·信息与通信·一体化机柜
乐讯通物联网服务商9 小时前
电梯物联网终端是怎么工作的?从传感器到云平台的完整链路拆解
物联网·物联网卡·电梯物联网终端
Solara9 小时前
进推荐流的文章不是快 1.42 倍,是 3.36 倍:我把错的数写进了长期记忆
人工智能·程序员·ai编程
蓝策电子9 小时前
存量时代:2026 地下停车场改造市场规模与竞争格局分析
物联网
2601_9679791210 小时前
当代码有了温度:程序员的键盘上,藏着人间烟火
程序员·生活·技术·代码·温情
microrain11 小时前
设备报文怎么变,SagooIoT 只约定三个函数名
javascript·物联网·golang·sagooiot