当前位置: 首页 > news >正文

FreeRTOS源码分析四:时钟中断处理响应流程

系列文章目录

FreeRTOS源码分析一:task创建(RISCV架构)
FreeRTOS源码分析二:task启动(RISCV架构)
FreeRTOS源码分析三:列表数据结构


文章目录

  • 系列文章目录
  • 前言
  • 中断触发间隔:一个 tick
  • 中断响应流程
    • 1.中断向量设置
    • 2.中断响应上下文保存
    • 3.切换到 ISR 中断服务例程专用堆栈
    • 4.更新定时器以触发下一次时钟中断
    • 5. xTaskIncrementTick 更新 tick
    • vTaskSwitchContext 调度任务
    • 中断返回例程 portcontextRESTORE_CONTEXT
  • 总结


前言

task 启动篇中提到,启动第一个任务之前,xPortStartScheduler 函数调用 vPortSetupTimerInterrupt 设置定时器中断随后使能了 mie 寄存器的时钟中断位。

随后调用 xPortStartFirstTask 从任务堆栈中装载寄存器并通过 ret 返回到任务的第一条指令位置启动第一个任务。

本篇我们来分析一下时钟中断触发的时间间隔以及在 RISCV 架构下的响应流程。


中断触发间隔:一个 tick

void vPortSetupTimerInterrupt( void )
{......    /* 计算下次定时器中断的时间点:当前时间 + 一个tick的时间增量 */ullNextTime += ( uint64_t ) uxTimerIncrementsForOneTick;/* 设置机器定时器比较寄存器,当MTIME达到这个值时触发中断 */*pullMachineTimerCompareRegister = ullNextTime;/* 预先计算下下次中断的时间,为下次中断处理做准备 */ullNextTime += ( uint64_t ) uxTimerIncrementsForOneTick;
}

vPortSetupTimerInterrupt 会设置 mtimecmp 寄存器为当前 mtime + uxTimerIncrementsForOneTick。当 mtime 大于 mtimecmp 时触发时钟中断。宏定义如下:

#define configCPU_CLOCK_HZ				( 10000000 )
#define configTICK_RATE_HZ				( ( TickType_t ) 1000 )
const size_t uxTimerIncrementsForOneTick = ( size_t ) ( ( configCPU_CLOCK_HZ ) / ( configTICK_RATE_HZ ) ); 
  • configCPU_CLOCK_HZ 定义了 CPU 的主频为 10,000,000 Hz(即 10 MHz)。
  • configTICK_RATE_HZ 定义了 FreeRTOStick rate,即每秒钟触发多少次系统时钟中断。这里设置为 1000,即每 1 毫秒触发一次 tick 中断。
  • uxTimerIncrementsForOneTick 计算出 每个 tick 所需的定时器增量(计数器的步进),也就是说:要产生一次 tick 中断,硬件定时器需要计数多少个 CPU 时钟周期。

中断响应流程

1.中断向量设置

主函数调用 Demo 之前设置中断异常处理函数 freertos_risc_v_trap_handler

int main( void )
{__asm__ volatile ( "csrw mtvec, %0" : : "r" ( freertos_risc_v_trap_handler ) );return  main_blinky();}

2.中断响应上下文保存

宏 portcontextSAVE_CONTEXT_INTERNAL 在异常或中断发生时最先执行,依旧按照第一篇新建任务初始化堆栈时,堆栈的格式来设置堆栈内部的寄存器的内容

   .macro portcontextSAVE_CONTEXT_INTERNAL
addi sp, sp, -portCONTEXT_SIZE
store_x x1,  2  * portWORD_SIZE( sp )
store_x x5,  3  * portWORD_SIZE( sp )
store_x x6,  4  * portWORD_SIZE( sp )
store_x x7,  5  * portWORD_SIZE( sp )
store_x x8,  6  * portWORD_SIZE( sp )
store_x x9,  7  * portWORD_SIZE( sp )
store_x x10, 8  * portWORD_SIZE( sp )
store_x x11, 9  * portWORD_SIZE( sp )
store_x x12, 10 * portWORD_SIZE( sp )
store_x x13, 11 * portWORD_SIZE( sp )
store_x x14, 12 * portWORD_SIZE( sp )
store_x x15, 13 * portWORD_SIZE( sp )
#ifndef __riscv_32estore_x x16, 14 * portWORD_SIZE( sp )store_x x17, 15 * portWORD_SIZE( sp )store_x x18, 16 * portWORD_SIZE( sp )store_x x19, 17 * portWORD_SIZE( sp )store_x x20, 18 * portWORD_SIZE( sp )store_x x21, 19 * portWORD_SIZE( sp )store_x x22, 20 * portWORD_SIZE( sp )store_x x23, 21 * portWORD_SIZE( sp )store_x x24, 22 * portWORD_SIZE( sp )store_x x25, 23 * portWORD_SIZE( sp )store_x x26, 24 * portWORD_SIZE( sp )store_x x27, 25 * portWORD_SIZE( sp )store_x x28, 26 * portWORD_SIZE( sp )store_x x29, 27 * portWORD_SIZE( sp )store_x x30, 28 * portWORD_SIZE( sp )store_x x31, 29 * portWORD_SIZE( sp )
#endif /* ifndef __riscv_32e */load_x t0, xCriticalNesting                                   /* Load the value of xCriticalNesting into t0. */
store_x t0, portCRITICAL_NESTING_OFFSET * portWORD_SIZE( sp ) /* Store the critical nesting value to the stack. */portasmSAVE_ADDITIONAL_REGISTERS  /* 空宏,无操作 */csrr t0, mstatus
store_x t0, 1 * portWORD_SIZE( sp )

3.切换到 ISR 中断服务例程专用堆栈

中断服务例程代码保存寄存器之后根据 mcause 判断是异常还是中断进行进一步处理

.section .text.freertos_risc_v_trap_handler
.align 8                                      # 8字节对齐
freertos_risc_v_trap_handler:portcontextSAVE_CONTEXT_INTERNAL          # 保存当前任务的上下文(寄存器状态)# 读取异常/中断原因和程序计数器csrr a0, mcause                           # 读取机器模式异常原因寄存器到a0csrr a1, mepc                             # 读取机器模式异常程序计数器到a1# 判断是中断还是异常(mcause的MSB位)bge a0, x0, synchronous_exception         # 如果mcause >= 0,跳转到同步异常处理# (中断的mcause MSB=1,为负数)# 异步中断处理分支
asynchronous_interrupt:store_x a1, 0( sp )                       # 中断:保存未修改的异常返回地址load_x sp, xISRStackTop                   # 切换到ISR专用栈j handle_interrupt                        # 跳转到中断处理# 同步异常处理分支  
synchronous_exception:addi a1, a1, 4                            # 同步异常:返回地址+4,指向异常指令的下一条指令store_x a1, 0( sp )                       # 保存更新后的异常返回地址load_x sp, xISRStackTop                   # 切换到ISR专用栈j handle_exception                        # 跳转到异常处理# 中断处理主体
handle_interrupt:
#if( portasmHAS_MTIME != 0 )                 # 如果系统有MTIME定时器test_if_mtimer:                           # 检查是否为机器定时器中断addi t0, x0, 1                        # t0 = 1slli t0, t0, __riscv_xlen - 1         # 将1左移到MSB位置(32位系统移31位,64位系统移63位)addi t1, t0, 7                        # t1 = 0x8000...0007(机器定时器中断代码)bne a0, t1, application_interrupt_handler  # 如果不是定时器中断,跳转到应用中断处理# 处理定时器中断portUPDATE_MTIMER_COMPARE_REGISTER    # 更新定时器比较寄存器(设置下次中断时间)call xTaskIncrementTick               # 调用FreeRTOS系统时钟增量函数beqz a0, processed_source             # 如果返回0(无任务需要切换),跳转到处理完成call vTaskSwitchContext               # 否则进行任务上下文切换j processed_source                    # 跳转到处理完成
#endif /* portasmHAS_MTIME */# 异常处理主体
handle_exception:/* a0 包含 mcause 异常原因 */li t0, 11                                 # t0 = 11(环境调用异常代码)bne a0, t0, application_exception_handler # 如果不是环境调用,跳转到应用异常处理# 处理系统调用(环境调用)call vTaskSwitchContext                   # 进行任务上下文切换j processed_source                        # 跳转到处理完成# 中断/异常处理完成
processed_source:portcontextRESTORE_CONTEXT                # 恢复任务上下文(寄存器状态)# 然后返回到被中断的代码继续执行

有个很重要的事情是:

  • 经过时钟中断,进入中断处理例程,任务堆栈向下增长,值变小,保存寄存器切换中断服务例程的堆栈。这是第一步大流程。
  • 此时堆栈中从栈顶开始到 portCONTEXT_SIZE 的大小空间从上到下存储的是 SP、mstatus、x1、x5-x31、xCriticalNesting
  • 这代表:此时任务可以切换,或者说,此时任务已经完成了所有的初始化,就像新建任务初始化完毕堆栈那样
  • 同样的,对于最后的中断返回例程,我们也能猜到,它会按照约定取出堆栈中的内容,同时把堆栈增长的空间回收

总结来说,先保存现场到任务堆栈,确定是时钟中断之后,调用 portUPDATE_MTIMER_COMPARE_REGISTER 更新定时器,随后调用 xTaskIncrementTick 更新 tick,若返回非 0,则调用 vTaskSwitchContext 调度任务。随后恢复上下文并中断返回。


4.更新定时器以触发下一次时钟中断

portUPDATE_MTIMER_COMPARE_REGISTER 用于更新定时器

.macro portUPDATE_MTIMER_COMPARE_REGISTER/* 加载定时器比较寄存器的地址到寄存器a0 */load_x a0, pullMachineTimerCompareRegister  /* 加载下一次定时器触发时间变量的地址到寄存器a1 */load_x a1, pullNextTime                     #if( __riscv_xlen == 32 )/*=== 32位RISC-V架构处理 ===*//* 在32位系统中,需要用两次32位写操作来更新64位的定时器比较值 */li a4, -1                   /* 将a4设置为0xFFFFFFFF(最大32位值) */lw a2, 0(a1)                /* 从ullNextTime加载低32位到a2 */lw a3, 4(a1)                /* 从ullNextTime加载高32位到a3 *//* * 关键的三步写入序列,防止意外的定时器中断:* 1. 先写入最大值到低位,确保比较值不会小于当前值* 2. 写入新的高位值* 3. 最后写入新的低位值*/sw a4, 0(a0)                /* 步骤1: 先将低位设为最大值,防止意外触发 */sw a3, 4(a0)                /* 步骤2: 写入ullNextTime的高32位到比较寄存器 */sw a2, 0(a0)                /* 步骤3: 写入ullNextTime的低32位到比较寄存器 *//* 计算下一次定时器触发时间 = 当前时间 + 一个时间片的增量 */lw t0, uxTimerIncrementsForOneTick  /* 加载一个tick的定时器增量值 */add a4, t0, a2              /* 低位相加:增量 + 当前时间低位 */sltu t1, a4, a2             /* 检查低位相加是否溢出,如果a4 < a2则溢出 */add t2, a3, t1              /* 高位 = 原高位 + 溢出标志 *//* 将计算出的新的下次触发时间存回ullNextTime变量 */sw a4, 0(a1)                /* 存储新的低32位 */sw t2, 4(a1)                /* 存储新的高32位 */#endif /* __riscv_xlen == 32 */
.endm

sltu 指令是 RISC-V 中的无符号比较指令,全称是 "Set Less Than Unsigned",如果 rs1 < rs2(无符号比较),则将目标寄存器 rd 设置为 1


5. xTaskIncrementTick 更新 tick

xTaskIncrementTick 除了更新 tick 递增系统时钟计数,还会检查并唤醒到期的阻塞任务,根据优先级决定是否需要抢占式任务切换,调用应用程序时钟钩子函数

/*** * 当前配置参数:* - configUSE_TICK_HOOK = 1       (启用时钟钩子)* - configUSE_PREEMPTION = 1      (启用抢占调度)* - configNUMBER_OF_CORES = 1     (单核处理器)* - configUSE_TIME_SLICING = 0    (禁用时间片轮转)*/
BaseType_t xTaskIncrementTick( void )
{TCB_t * pxTCB;                          // 任务控制块指针TickType_t xItemValue;                  // 延迟列表项的值(唤醒时间)BaseType_t xSwitchRequired = pdFALSE;   // 是否需要任务切换标志/* 时钟递增应该在每个内核定时器事件上发生。* 如果调度器被挂起,则递增待处理的时钟计数。 */if( uxSchedulerSuspended == ( UBaseType_t ) 0U ){// === 调度器未被挂起的情况 ===/* 小优化:在此代码块中时钟计数不会改变 */const TickType_t xConstTickCount = xTickCount + ( TickType_t ) 1;/* 递增RTOS时钟,如果溢出到0则切换延迟和溢出延迟列表 */xTickCount = xConstTickCount;// 处理时钟计数器溢出的情况(从最大值回绕到0)if( xConstTickCount == ( TickType_t ) 0U ){taskSWITCH_DELAYED_LISTS();  // 切换延迟任务列表}/* 检查此时钟是否使某个超时到期。任务按其唤醒时间顺序存储在队列中* 这意味着一旦找到一个阻塞时间未到期的任务,就无需继续查找列表中的其他任务 */if( xConstTickCount >= xNextTaskUnblockTime ){// === 处理需要唤醒的阻塞任务 ===for( ; ; ){// 检查延迟任务列表是否为空if( listLIST_IS_EMPTY( pxDelayedTaskList ) != pdFALSE ){/* 延迟列表为空。将xNextTaskUnblockTime设置为最大可能值* 这样下次通过时 if( xTickCount >= xNextTaskUnblockTime ) 测试* 极不可能通过,避免不必要的列表检查 */xNextTaskUnblockTime = portMAX_DELAY;break;}else{/* 延迟列表不为空,获取延迟列表头部任务的唤醒时间* 这是该任务必须从阻塞状态移除的时间点 */pxTCB = listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xStateListItem ) );// 检查是否到了唤醒时间if( xConstTickCount < xItemValue ){/* 还没有到解除此任务阻塞的时间,记录下一个任务的唤醒时间* 用于下次时钟中断时的快速判断 */xNextTaskUnblockTime = xItemValue;break;}/* 时间到了,从延迟列表中移除该任务 */listREMOVE_ITEM( &( pxTCB->xStateListItem ) );/* 检查任务是否同时在等待事件(如信号量、队列等)* 如果是,也要从事件列表中移除 */if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL ){listREMOVE_ITEM( &( pxTCB->xEventListItem ) );}/* 将唤醒的任务加入到对应优先级的就绪列表中 */prvAddTaskToReadyList( pxTCB );/* 抢占式调度:检查新唤醒的任务优先级是否高于当前运行任务* 如果是,则需要进行任务切换以让高优先级任务立即运行 */if( pxTCB->uxPriority > pxCurrentTCB->uxPriority ){xSwitchRequired = pdTRUE;  // 标记需要任务切换}}}}/* 调用应用程序定义的时钟钩子函数* 只有在没有待处理时钟时才调用,避免调度器解锁时重复调用 */if( xPendedTicks == ( TickType_t ) 0 ){vApplicationTickHook();  // 调用应用程序时钟钩子}/* 检查是否有待处理的让出请求需要处理* 在单核系统中,xYieldPendings[0]用于标记是否需要任务切换 */if( xYieldPendings[ 0 ] != pdFALSE ){xSwitchRequired = pdTRUE;  // 需要任务切换}}else{// === 调度器被挂起的情况 ===/* 调度器被挂起时,不能立即处理任务调度* 只累计待处理的时钟计数,等调度器恢复时统一处理 */xPendedTicks += 1U;/* 即使调度器被锁定,时钟钩子函数仍需要定期调用* 这允许应用程序在调度器挂起期间执行时间相关的维护任务 */vApplicationTickHook();}return xSwitchRequired;  // 返回是否需要任务切换
}

这里我们先不关心任务相关的等待队列和事件等待队列。后续再细看。

简单来说,函数着重做几件事:

  • 递增系统时钟计数,
  • 检查并唤醒到期的阻塞任务,
  • 根据优先级决定是否需要抢占式任务切换,若需要则设置返回值 xSwitchRequired 非 0。
  • 另外,这里会检查条件 xYieldPendings[ 0 ] != pdFALSE 满足则需要调度。与下文函数 vTaskSwitchContext 对应,它会在无法调度时设置该变量为 pdTrue
  • 调用应用程序时钟钩子函数

vTaskSwitchContext 调度任务

vTaskSwitchContext 非常简单,只设置全局变量 pxCurrentTCB 指向当前优先级最高的任务,待后续返回即执行该任务

void vTaskSwitchContext( void )
{// 调度器是否挂起if( uxSchedulerSuspended != ( UBaseType_t ) 0U ){/* * xYieldPendings[0]: 挂起期间的切换请求标志* - pdTRUE: 有挂起的切换请求,需要在调度器恢复时执行* - pdFALSE: 没有挂起的切换请求*/xYieldPendings[ 0 ] = pdTRUE;}else{/* * 调度器正常运行,可以执行任务切换* 首先清除挂起标志,表示没有延迟的切换请求*/xYieldPendings[ 0 ] = pdFALSE;/* * 安全检查:栈溢出检测* taskCHECK_FOR_STACK_OVERFLOW(): 宏定义,根据配置可能为空* * 检查内容:* 1. 当前任务的栈指针是否超出栈空间边界* 2. 栈末尾的"魔术数字"是否被破坏* * 如果检测到栈溢出:* - 调用用户定义的钩子函数 vApplicationStackOverflowHook()* - 通常会停止系统运行或重启*/taskCHECK_FOR_STACK_OVERFLOW();/* * 核心操作:选择下一个要运行的任务* taskSELECT_HIGHEST_PRIORITY_TASK(): 关键宏定义*/taskSELECT_HIGHEST_PRIORITY_TASK();/* * 编译器优化抑制:* (void)( pxCurrentTCB ): 告诉编译器pxCurrentTCB被"使用"了*/(void)( pxCurrentTCB );}
}

注意:函数会在调度器挂起时在此返回,不执行实际的任务切换。那么什么时候切换?

  • 1:当调度器恢复时(xTaskResumeAll),会检查此标志如果为 pdTRUE,会执行延迟的任务切换。
  • 2:当再次触发时钟中断,执行到函数 xTaskIncrementTick 更新 tick 时它会检查 xYieldPendings[0] 如果为 pdTRUE 则返回非 0 表示需要调度。

中断返回例程 portcontextRESTORE_CONTEXT

这里主要和中断执行例程对应上,在堆栈的哪个位置存的哪一个寄存器,就把哪一个寄存器写回去,另外回收堆栈空间

.macro portcontextRESTORE_CONTEXTload_x t1, pxCurrentTCB     /* 加载当前任务控制块指针 */load_x sp, 0 ( t1 )         /* 从TCB第一个成员读取栈指针 *//* 恢复程序计数器 */load_x t0, 0 ( sp )csrw mepc, t0               /* 设置下次要执行的指令地址 *//* 恢复状态寄存器 */load_x t0, 1 * portWORD_SIZE( sp )csrw mstatus, t0/* 空宏,什么都不执行 */portasmRESTORE_ADDITIONAL_REGISTERS/* 恢复临界嵌套计数 */load_x t0, portCRITICAL_NESTING_OFFSET * portWORD_SIZE( sp )load_x t1, pxCriticalNestingstore_x t0, 0 ( t1 )/* 恢复通用寄存器 x1, x5-x15 */load_x x1,  2  * portWORD_SIZE( sp )load_x x5,  3  * portWORD_SIZE( sp )load_x x6,  4  * portWORD_SIZE( sp )load_x x7,  5  * portWORD_SIZE( sp )load_x x8,  6  * portWORD_SIZE( sp )load_x x9,  7  * portWORD_SIZE( sp )load_x x10, 8  * portWORD_SIZE( sp )load_x x11, 9  * portWORD_SIZE( sp )load_x x12, 10 * portWORD_SIZE( sp )load_x x13, 11 * portWORD_SIZE( sp )load_x x14, 12 * portWORD_SIZE( sp )load_x x15, 13 * portWORD_SIZE( sp )#ifndef __riscv_32e/* 标准RISC-V:恢复 x16-x31 */load_x x16, 14 * portWORD_SIZE( sp )load_x x17, 15 * portWORD_SIZE( sp )load_x x18, 16 * portWORD_SIZE( sp )load_x x19, 17 * portWORD_SIZE( sp )load_x x20, 18 * portWORD_SIZE( sp )load_x x21, 19 * portWORD_SIZE( sp )load_x x22, 20 * portWORD_SIZE( sp )load_x x23, 21 * portWORD_SIZE( sp )load_x x24, 22 * portWORD_SIZE( sp )load_x x25, 23 * portWORD_SIZE( sp )load_x x26, 24 * portWORD_SIZE( sp )load_x x27, 25 * portWORD_SIZE( sp )load_x x28, 26 * portWORD_SIZE( sp )load_x x29, 27 * portWORD_SIZE( sp )load_x x30, 28 * portWORD_SIZE( sp )load_x x31, 29 * portWORD_SIZE( sp )
#endif /* ifndef __riscv_32e */addi sp, sp, portCONTEXT_SIZE   /* 恢复栈指针位置 */mret                            /* 机器模式返回,切换到任务 */
.endm

在选择最高优先级任务的时候用到了硬件加速。这里看一下:

这个宏用于记录就绪优先级,即设置该全局变量 uxReadyPriorities 的第 uxPriority bit1

#define portRECORD_READY_PRIORITY( uxPriority, uxReadyPriorities ) \( uxReadyPriorities ) |= ( 1UL << ( uxPriority ) )

这个宏用于清除就绪优先级,即设置该全局变量 uxReadyPriorities 的第 uxPriority bit0

#define portRESET_READY_PRIORITY( uxPriority, uxReadyPriorities ) \( uxReadyPriorities ) &= ~( 1UL << ( uxPriority ) )

这个宏用于获取最高就绪优先级,利用 __builtin_clz(x) 返回 x 的二进制表示中前导 0 的个数

#define portGET_HIGHEST_PRIORITY( uxTopPriority, uxReadyPriorities ) \uxTopPriority = ( 31UL - __builtin_clz( uxReadyPriorities ) )

我们用一个简单程序验证一下它会被优化成 clz 指令,然后用一条指令完成任务:

// test_priority.c
#include <stdint.h>#define portGET_HIGHEST_PRIORITY( uxTopPriority, uxReadyPriorities ) \uxTopPriority = ( 31UL - __builtin_clz( uxReadyPriorities ) )#define portRECORD_READY_PRIORITY( uxPriority, uxReadyPriorities ) \( uxReadyPriorities ) |= ( 1UL << ( uxPriority ) )volatile uint32_t uxReadyPriorities = 0;
volatile uint32_t uxTopPriority;int main(void)
{portRECORD_READY_PRIORITY(5, uxReadyPriorities);portGET_HIGHEST_PRIORITY(uxTopPriority, uxReadyPriorities);return 0;
}

使用如下命令编译:riscv32-unknown-elf-gcc -march=rv32im -mabi=ilp32 -O2 -S test_priority.c -o test_priority.s,查看文件可以知道:

在这里插入图片描述
直观看,如果拥有 zbb 扩展,它被编译为指令 clz,具体指令集描述如下所示:

在这里插入图片描述

总结

完结撒花!!!

http://www.xdnf.cn/news/1242757.html

相关文章:

  • 深入浅出 RabbitMQ:工作队列实战(轮训策略VS公平策略)
  • 鸿蒙南向开发 编写一个简单子系统
  • 机器学习 入门——决策树分类
  • 并发编程常用工具类(下):CyclicBarrier 与 Phaser 的协同应用
  • C++入门自学Day6-- C++模版
  • 飞算JavaAI需求转SpringBoot项目沉浸式体验
  • 【BUUCTF系列】[极客大挑战 2019]LoveSQL 1
  • vllm启动Qwen/Qwen3-Coder-30B-A3B-Instruct并支持工具调用
  • MLIR Introduction
  • android内存作假通杀补丁(4GB作假8GB)
  • History 模式 vs Hash 模式:Vue Router 技术决策因素详解
  • ZYNQ-按键消抖
  • JavaScript 中的流程控制语句详解
  • 3.JVM,JRE和JDK的关系是什么
  • 第二十四天(数据结构:栈和队列)队列实践请看下一篇
  • SQL注入SQLi-LABS 靶场less39-50详细通关攻略
  • 基于实时音视频技术的远程控制传输SDK的功能设计
  • 【ECCV2024】AdaCLIP:基于混合可学习提示适配 CLIP 的零样本异常检测
  • [GESP202306 四级] 2023年6月GESP C++四级上机题超详细题解,附带讲解视频!
  • 刷题记录0804
  • ref和reactive的区别
  • 8位以及32位的MCU如何进行选择?
  • ArrayDeque双端队列--底层原理可视化
  • Redis 常用数据结构以及单线程模型
  • LeetCode 140:单词拆分 II
  • Array容器学习
  • app-1
  • 优选算法 力扣 11. 盛最多水的容器 双指针降低时间复杂度 贪心策略 C++题解 每日一题
  • Javascript面试题及详细答案150道之(031-045)
  • python包管理器uv踩坑