🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
目录
- 🌏任务创建
- 🧭TCB和栈
- 🧭伪造现场
- 🧭链表操作
- 🌏任务启动
- 🧭创建空闲任务和定时器任务
- 🧭启动调度器
- 🌏任务切换
- 🌏任务的暂停和恢复
- 🧭暂停
- 🧭恢复
- 🌏任务的阻塞和唤醒
- 🧭阻塞
- 🧭唤醒
- 🌏临界资源保护
- 🧭关中断
- 🧭关闭调度器
- 🌏总结
前面认识了FreeRTOS中的链表和堆的管理后,接下来再看看FreeRTOS和任务相关的内容。
🌏任务创建
我们知道,在FreeRTOS中可以存在很多任务,每一个任务都有很多的属性,这些属性构成一个TCB结构体,专门用来描述一个任务。
如上图所示是TCB
结构体的定义,它包含的主要成员变量有:
- volatile StackType_t *
pxTopOfStack
:当前任务栈的栈顶位置,该位置指向栈中最后入栈的元素。 - ListItem_t
xStateListItem
:这是一个用来管理任务状态的链表项。 - ListItem_t
xEventListItem
:这是一个用来管理任务事件的链表项。 - UBaseType_t
uxPriority
:用来表示当前任务的优先级。 - StackType_t *
pxStack
:当前任务栈的最低位置,表示栈的最大容量。 - char
pcTaskName
[ configMAX_TASK_NAME_LEN ]:用来存放任务名称。
如上图所示,在创建任务之前,都会创建一个TaskHandle_t
类型的任务句柄,该句柄本质上就是TCB*
,是TCB结构体指针的类型重命名。
在调用xTaskCreate
创建任务的本质就是在该函数中填充这个TCB结构体,在创建任务时,主要做这几件事:
xTaskCreate
创建动态任务:- 分配TCB结构体
- 分配栈
- 伪造现场:构造栈的内容
- 把TCB放入就绪链表
xTaskCreateStatic
创建静态任务:- 伪造现场:构造栈的内容
- 把TCB放入就绪链表
动态创建和静态创建相比,静态创建少了为任务分配TCB结构体和栈这两步,因为静态创建时,TCB结构体不在堆上,栈也是由用户指定的。
🧭TCB和栈
动态创建:
如上图代码所示,适用xTaskCreate
动态创建任务时,在函数内部会调用pvPortMalloc
函数在堆区上申请存放TCB
结构体变量和任务所需要栈的空间。
而且还让TCB结构体中的pxStack
成员指向栈空间的最低地址(这块内存的起始地址,也是栈的最大存储位置)。
静态创建:
如上图代码所示,适用xTaskCreateStatic
静态创建任务时,并没有从堆区上申请TCB和栈所用的空间,而是直接将调用函数时传入的TCB结构体变量pxTaskBuffer
和栈puxStackBuffer
赋值给pxNewTCB
。
🧭伪造现场
静态创建和动态创建都有伪造现场,而且在这一点上的做法是一致的:
如上图代码所示,在完成TCB变量和栈的创建以后,在xTaskCreate
任务创建函数中调用prvInitialiseNewTask
来初始化TCB。
如上图所示prvInitialiseNewTask
函数,在该函数中进行TCB的初始化,主要操作有三步:
- 计算栈顶
pxTopOfStack
由于栈是一个连续的数组,所以pxNewTCB->pxStack[ ulStackDepth - ( uint32_t ) 1 ]
得到的就是栈的最大地址处,也就是栈顶。除此之外,还要对栈顶进行对齐处理。
- 处理优先级
如果传入的优先级大于可以设置的最大优先级configMAX_PRIORITIES
,就将优先级设置为configMAX_PRIORITIES - 1
。还有一些任务名字的处理等内容比较简单,本喵就不讲解了。
- 初始化栈
会调用pxPortInitialiseStack
函数来初始化栈,也就是进行现场伪造:
如上图代码所示,在函数中进行现场伪造,portINITIAL_XPSR
就是寄存器xPSR
的值,( StackType_t ) pxCode + portINSTRUCTION_SIZE
就是任务函数的地址,给LR
赋值prvTaskExitError
,表示错误返回执行的函数地址,一般不会执行该函数。pvParameters
是创建任务时传给任务函数的那个参数,放在R0
中。
- 无所谓什么值的寄存器,在伪造现场时压根没有处理它,只是在移动栈顶。
- 最后返回的栈顶位置存放的是伪造的
R4
寄存器值。
如上图所示便是伪造完现场后的结果。
🧭链表操作
如上图所示,在task.c
中有一个static List_t pxReadyTasksLists[ configMAX_PRIORITIES ]
类型数组,该数组有configMAX_PRIORITIES
个元素,也就是最大优先级是多少,就有多少个元素。
- 每个元素的类型都是一个
List_t
链表头。- 每一个优先级对应一个就绪队列。
如上图所示,前面的五个链表头List_t
就是就绪队列数组中的五个元素,每个链表头都代表这一个队列,而链表头所在位置的下标就是该队列中所有任务的优先级。
以优先级为1为例,可以看到,三个TCB结构体通过它们的成员变量xStateListItem
链表项,将自己链接到了该队列中。通过链表项中的pOwner
可以找到链表项所属的TCB变量。
- 链表项
xStateListItem
就像是一个铭牌,每个TCB都有一个铭牌,该铭牌位于就绪链表中。
如上图所示,在xTaskCreate
中创建任务时,还要进行链表操作,调用prvAddNewTaskToReadyList
将创建的新TCB链入到对应的就绪链表中。
如上图代码所示,在插入链表之前,要设置一些链表项中的值:
- 初始化当前TCB中的链表项
- 设置链表项中的
pOwner
,使得通过链表项可以找到当前TCB。 - 设置链表项中的
Value
,当进行排序时会用到该值。
如上图代码所示,设置好链表项中的值以后,调用prvAddTaskToReadyList
将包含链表项的TCB插入到就绪队链表中,在该函数中再调用listINSERT_END
将该TCB插入到对应数组下标链表头所维护的链表中。
如上图所示listINSERT_END
宏函数,在该函数中进行尾插,并且让链表项中的pxContainer
指向链表头,将链表头中链表项个数加一。
- 尾插的过程中要保证公平性。
此时一个任务就创建好了。
🌏任务启动
任务创建好以后,需要调用vTaskStartScheduler
函数来启动任务,也就是开启调度器。
🧭创建空闲任务和定时器任务
- 创建空闲任务
在FreeRTOS中,除了我们创建的任务外,还有一个空闲任务,该任务的优先级是最低的,当所有用户任务都不在运行时,就会运行空闲任务,该任务进行回收用户任务资源等操作。
如上图所示vTaskStartScheduler
,在该函数中,如果支持静态创建就创建一个静态的空闲任务,如果不支持就动态创建一个空闲任务。
- 创建定时器任务
如上图所示,如果配置了使用定时器的话,在启动调度器的时候还会创建一个定时器任务。
如上图所示,此时优先级为0的就绪链表中就会有一个空闲任务Idle_Task
。
🧭启动调度器
- 设置
PendSV
和SysTick
中断为最低优先级
如上图,在创建好空闲任务和定时器任务后,会调用xPortStartScheduler
函数来启动调度器。
如上图所示,在xPortStartScheduler
函数中,会设置NVIC中优先级寄存器,让PendSV
和SysTick
中断为最低优先级。
如上图所示,此时中断向量表中的PendSV
和SysTick
中断就被设置成了最低优先级,优先级相同,无法抢占。
- 使能
SysTick
中断
在设置完优先级以后会调用vPortSetupTimerInterrupt
函数来使能SysTick
中断:
如上图代码所示,在vPortSetupTimerInterrupt
函数中配置SysTick
时钟频率,并且使能SysTick
定时器。
portNVIC_SYSTICK_LOAD_REG = ( configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL
,这里设置重装载值时,为什么会减1呢?
如上图所示,假设要3个CLK
产生一次中断,由于SysyTick
是向下计数的,所以刚开始启动时,从2减到0需要2个CLK
,接下来需要重新装载计数值。
装置计数值会耗费一个CLK的时间,所以当计数值重新变成2时已经消耗1个CLK
,然后再计数两个CLK
,一共就3个CLK
了,如此往复。
对于72MHZ的CLK
来说,如果想让1ms产生一次中断,装置值就需要设置成72000000/1000 - 1
。
- 触发
SVC
异常,启动第一个任务。
在调用完vPortSetupTimerInterrupt
函数启动了SysTick
定时器以后,会再调用prvStartFirstTask
来启动第一个任务:
如上图代码所示,vPortSetupTimerInterrupt
是一个汇编函数,在里面进行了四步操作:
- 获取默认的栈顶
如上图所示,从向量表的第一项中获得默认栈的栈顶__initial_sp
。
- 将获取到的栈赋值给
MSP
。 - 打开CPU中断,运行处理所有中断。
- 触发
SVC
异常。
使用汇编指令SVC 0
来触发SVC异常,第一次任务运行前的的现场恢复是在SVC异常中进行的:
如上图vPortSVCHandler
SVC异常处理函数,这也是一个汇编函数,在该函数中恢复创建任务时伪造的现场:
- 获取当前TCB中的栈顶
在FreeRTOS中存在一个pxCurrentTCB
全局变量,该变量指向的是正在执行任务的TCB,在创建任务时,该变量指向最后创建的最高优先级的任务。
如上图所示,在TCB结构体中,第一个成员变量就是pxTopOfStack
栈顶指针,所以用汇编指令读取TCB结构体中的起始4字节得到就是该任务的栈顶。
- 软件恢复
R4~R11
- 将栈顶赋值给
PSP
为了任务处理更加高效,任务的栈使用PSP
寄存器来维护,但是此时SP仍然使用的是MSP
,因为任务还没有启动。
- 让CPU能处理所有中断
- 修改LR中的特殊值
由于这是SVC异常处理函数,所以LR
寄存器中的值是一个特殊值,该值用来触发硬件恢复,这里要修改一下这个特殊值:
如上图,要确保特殊值中的bit3
是1,表示异常返回后是线程模式,还要确保bit2
是1,表示异常返回后使用的是线程栈PSP
。
- 触发硬件恢复
此时软件恢复已经完成,使用BX R14
触发硬件恢复,自此创建任务时伪造的现场全部从当前任务pxCurrentTCB
的栈中恢复到CPU的寄存器里了。
而且程序跳转到了前任务pxCurrentTCB
函数处开始指向。
- 第一次启动任务是通过
SVC
异常启动的。
🌏任务切换
此时已经有任务在运行了,当产生SysTick
中断的时候就会切换任务:
- SysTick中断函数
如上图所示xPortSysTickHandler
中断函数,在进入该函数时,禁止CPU处理优先级中断(允许使用系统调用的中断),然后软件触发PendSV
中断,在PendSV
中断函数中发生任务切换。
- 由于
SysTick
中断和PendSV
中断的优先级相同,所以在SysTick
中触发PendSV
中断时无法抢占执行。- 等允许优先级中断产生后,退出
SysTick
中断后接着执行PendSV
中断函数。
- PendSV中断函数
如上图所示xPortPendSVHandler
PendSV中断处理函数,这是一个汇编函数,在该函数中完成现场恢复:
- 获取当前任务
pxCurrentTCB
的PSP栈顶 - 软件保存
R4~R11
寄存器中的值到当前任务pxCurrentTCB
的栈中。
在保存完寄存器的值以后,将当前任务的栈顶保存到栈中存放R4
的位置。
- 将
pxCurrentTCB
的地址和LR
中的特殊值保存到MSP
中。
由于这是在PendSV
中断函数中,所以中断函数使用的栈必然是MSP
。因为在切换恢复新任务的现场时会用到R3
和LR
寄存器。
- 调用
vTaskSwitchContext
挑选下一个任务
在该函数中再调用taskSELECT_HIGHEST_PRIORITY_TASK
从所有就绪链表中挑选出优先级最高的任务。
如上图所示,在taskSELECT_HIGHEST_PRIORITY_TASK
函数中,遍历所有链表,从优先级最高的链表开始遍历,找到不为空的链表,然后调用listGET_OWNER_OF_NEXT_ENTRY
函数获得链表中要执行的新任务。
- 遍历链表时,并不是直接从系统允许的最高优先级开始遍历,而是从现有任务的最高优先级链表开始向低优先级链表遍历。
如上图所示代码中,在一进入listGET_OWNER_OF_NEXT_ENTRY
函数中,就让pxIndex
迭代到下一个链表项,如果迭代后的链表项指向了链表头,那么继续迭代一次,指向第一个链表项。
最后将pxIndex
指向的新TCB赋值给pxCurrentTCB
。
- 从MSP中获取
pxCurrentTCB
和LR
的值。
挑选完下一个要执行的任务后,从MSP
中读取pxCurrentTCB
和LR
,由于保存的时候,保存的是pxCurrentTCB
的地址到MSP
中,在挑选任务时改变的是pxCurrentTCB
本身。
所以可以从MSP
中读取到的pxCurrentTCB
的地址找到pxCurrentTCB
本身。LR
这个特殊值也要从MSP
中恢复出来。
- 软件恢复
R4~R11
读取新任务pxCurrentTCB
中的头4个字节,得到的是新任务的栈顶pxTopOfStack
,然后从新任务的栈中进行软件恢复。
- 触发硬件恢复。
此时任务就完成了切换,可以看到,任务的切换并不是在SysTick
中断中完成的,而是在SysTick
中触发的PendSV
中完成的。
而第一次启动任务时,是在vTaskStartScheduler
中启动调度器时,触发SVC
异常,在异常处理函数中完成第一次现场切换的。
为什么第一次启动和后面的任务切换不都在SysTick
中断中完成呢?这样更方便啊,本喵之前自己模拟的任务切换就是这样完成的。
- 发生任务切换的场景并不都是时间片轮转到了。
当任务阻塞时会主动发起任务调度把自己切换下去,如果在SysTick
中断完成的话,就无法主动切换任务,而在PendSV
中断中完成的话,可以直接触发PendSV
中断来完成任务切换。
- 任务切换的过程为:保护原任务的现场 -> 挑选要执行的新任务 -> 恢复新任务的现场。
🌏任务的暂停和恢复
如上图所示,存在一个xSuspendedTaskList
链表,用来管理处于暂停任务的状态,该链表中的TCB的存放没有顺序,因为不需要排序,唤醒时是指定唤醒的。
🧭暂停
如上图任务状态转换图,让一个任务变成暂停状态有三种情况:
- 暂停就绪链表中的任务
- 暂停自己
- 暂停阻塞链表中的任务
如上图所示vTaskSuspend
函数,在该函数中,先调用prvGetTCBFromHandle
获得要移除的任务句柄,这是一个宏。然后调用uxListRemove
函数将任务的TCB从它当前的链表中移除。
如上图uxListRemove
函数,在该函数中,先获取要移除TCB所在的链表头pxContainer
,然后从链表中移除。
如果移除的TCB是当前正在操作的链表,则让pxIndex
指向被移除TCB的前一个TCB,这种情况是自己暂停自己。
之后让移除的TCB失忆,让其pxContainer = NULL
,然后再将链表头中记录链表项的uxNUmberOfItems
减一。
如上图所示代码,继续vTaskSuspend
函数讲解,如果要移除的任务处于某个事件链表中,则也要移除。将被移除的任务TCB尾插到暂停链表xSuspendedTaskList
中。
如上图所示,如果被暂停的是正在执行的任务,也就是自己暂停自己,那么就主动发起一次调度,去执行就绪链表中的下一个任务。
🧭恢复
如上图vTaskResume
函数所示,在函数内部,如果要唤醒的任务是处于暂停状态,那么就调用uxListRemove
函数从暂停链表中移除,然后再将该任务插入到就绪链表中。
- 插入就绪链表中的操作,和创建任务时插入的操作一样。
还要判断一下,唤醒任务的优先级如果大于等于当前正在执行的任务,那么就立刻发起一次调度,去执行这个被唤醒的任务。
🌏任务的阻塞和唤醒
如上图所示,存在两个延时链表pxDelayedTaskList
和pxOverflowDelayedTaskList
,这两个链表都是用来管理因调用vTaskDelay
函数而处于阻塞状态的任务的。
🧭阻塞
如上图所示,只有正在处于运行状态的任务才能进入阻塞状态,最常见的阻塞就是调用vTaskDelay
延时函数:
如上图所示vTaskDelay
函数,在该函数中,先调用prvAddCurrentTaskToDelayedList
函数将要阻塞的任务插入到pxDelayedTaskList
链表中,然后再调用portYIELD_WITHIN_API
主动发起一次调度。
如上图所示prvAddCurrentTaskToDelayedList
部分函数,主要进行了三步操作:
- 将调用
vTaskDelay
的当前任务从就绪链表中移除。 - 如果要阻塞的任务是无限阻塞,则放入暂停链表。
- 如果阻塞一段时间,则计算唤醒时间
xTimeToWake
,并设置到链表项中。
在延时链表中,所有任务的TCB按照链表项中的唤醒时间升序排列,越接近链表头的TCB,唤醒时间就越靠前。所以只需要判断链表中第一个TCB的超时时间是否达到就可以了。
如上图所示后续代码,要判断一下超时时间是否溢出了,如果溢出则放入pxOverflowDelayedTaskList
链表,没有超时则放入pxDelayedTaskList
链表。
每产生一次SysTick
中断,全局变量xTickCount
都会加一,该变量代表着系统时间,但是这是一个unsigned int
类型的变量。
在计算唤醒时间xTimeToWake = xConstTickCount + xTicksToWait
后,如果溢出了,则得到的时间会小于当前时间,此时就不能再插入到pxDelayedTaskList
链表中,而是要插入专门管理溢出的延时链表pxOverflowDelayedTaskList
中。
如上图,再更新一下唤醒时间xNextTaskUnblockTime
,要保证该变量是最小值。
🧭唤醒
唤醒时间到了又是如何做的呢?谁去判断,谁去唤醒呢?
如上图xPortSysTickHandler
中断函数所示,在每产生一次SysTick
中断以后,就会调用一次xTaskIncrementTick
函数,在这个函数中进行判断是否唤醒阻塞的任务,以及进行唤醒操作。
如上图xTaskIncrementTick
部分代码所示,每产生一次SysTick
中断就会给系统时间加一,当计数值发生溢出时,调用taskSWITCH_DELAYED_LISTS
交换两个延时链表的链表头,使用原本的溢出延时链表。
如上图代码所示,当系统时间xConstTickCount
大于下一个任务的唤醒时间xNextTaskUnblockTime
时,在一个死循环for
中进行操作。
在循环中,调用listGET_OWNER_OF_HEAD_ENTRY
函数获得延时链表中要被唤醒的任务:
#define listGET_OWNER_OF_HEAD_ENTRY( pxList ) ( ( &( ( pxList )->xListEnd ) )->pxNext->pvOwner )
这是一个宏函数,直接获取延时链表中第一个任务TCB即可,因为是按照唤醒时间排序的。
之所以在循环中进行,是为了将延时链表中延时相同时间的任务一起唤醒,当xConstTickCount < xItemValue
时,说明此时所有要唤醒的任务都唤醒了,跳出循环。
如上图所示,在循环中每挑出一个要唤醒的任务时,都要将其从阻塞链表中移除,并且将其插入到就绪链表中。
放入到就绪链表中后,要判断一下唤醒的任务优先级是否大于等于正在运行的任务优先级,如果大于等于则将xSwitchRequired
设置为真,当退出SysTick
中断后就会发起调度,让唤醒的这个高优先级任务去运行。
- 阻塞任务的唤醒和判断是由
SysTick
中断完成的。
🌏临界资源保护
保护临界资源的方法原则:谁可能跟我竞争,就防患于未然禁止谁。
- 我是任务A,任务B可能跟我竞争,那就"先关闭调度器,再访问临界资源,最后开启调度器"
- 我是任务A,中断函数可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"
- 我是任务A,任务B或中断都可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"
- 我是中断A,中断B可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"
所以说保护临界有关调度器和关中断两种方式。
🧭关中断
如上图所示,在进入临界区时,调用taskENTER_CRITICAL
来关闭中断。
如上图所示,关闭中断时最终会调用portDISABLE_INTERRUPTS
函数来关闭中断,该函数是一个汇编函数,会禁止优先级小于ulNewBASEPRI
的所有中断。
在关闭中断以后,会让uxCriticalNesting
变量加加,这是一个全局变量,只要该值不为0,说说明中断处于关闭状态。
关闭中断时不能关闭所有中断,只关闭优先级低于ulNewBASEPRI
的中断,这些中断也被称为允许使用系统调用FromISR
的中断,优先级比这些高的中断是更加紧急的中断,是不能禁止的。
- 在关闭中断期间,
SysTick
中断和PendSV
中断无法产生,就不会发生任务切换,就没有人来竞争。
中断关闭以后,就可以正式访问临界区了,在访问完临界区以后,要调用prvResetNextTaskUnblockTime
函数来更新延时链表中的唤醒时间。
最后再调用taskEXIT_CRITICAL
开启中断:
如上图代码所示,在开启中断时,会调用vPortExitCritical
函数,在该函数中会先对uxCriticalNesting
减一,直到为0后才调用portENABLE_INTERRUPTS
函数恢复中断。
- 当
uxCriticalNesting
为0时才能恢复中断,因为禁止中断的地方可能不止一处。
🧭关闭调度器
关闭调度器使用的是vTaskSuspendAll
函数:
如上图,在vTaskSuspendAll
函数中,仅仅是将全局变量uxSchedulerSuspended
加一。秘密就在xTaskIncrementTick
函数中。
如上图所示,在SysTick
中断函数中会调用xTaskIncrementTick
函数,在该函数中,会判断uxSchedulerSuspended
的值,如果该值为0,说明没有关闭调度器,就会进行后续的操作,增加系统时间,唤醒阻塞任务等等。
但是调用vTaskSuspendAll
关闭了调度器以后uxSchedulerSuspended
值就不是0了,就不会执行原本的操作了,而且此时的返回值xSwitchRequired = pdFALSE
。
当该函数返回以后,SysTick
中断函数中也不会触发PendSV
中断,也不会发生任务的切换。
uxSchedulerSuspended
不为0,调度器就处于关闭状态,就不会发生任务切换。
打开调度器使用的是xTaskResumeAll
函数:
如上图代码所示,唤醒任务时,先对uxSchedulerSuspended
全局变量进行减1,只有当这个变量为0时,才会重新打开调度器。
恢复xPendingReadyList链表中的任务到就绪链表中:
如上图代码所示,在打开调度器时,先会遍历一个xPendingReadyList
链表,将该链表中的所有任务都放入到就绪链表中,并且根据优先级决定是否立刻发起调度。
xPendingReadyList
链表又是干什么的呢?在关闭调度器的期间,只是不能发生任务切换,但是中断还是可以产生的。
假设现在有一个空队列,原本队列的xEventList
链表中有很多任务在等待,在调度器关闭期间,使用FromISR
后缀的系统调用向队列中写入了数据,此时本应该会唤醒该队列中等待的任务去读取数据的。
但是此时由于调度器是关闭的,将等待数据的任务放入到就绪链表中也没有意义,因为无法立刻发起调度,所有就先将这些原本等待的任务放入到了xPendingReadyList
链表中。
处理阻塞链表中的任务:
如上图所示,首先更新阻塞链表中的唤醒时间,由于在关闭调度器期间,SysTick
中断仍然可以发生,系统时间仍然在流逝,当阻塞链表中的最小唤醒时间和系统时间相等时也无法唤醒阻塞链表中的任务。
所以在关闭调度器期间,真正的系统时间不增加,而是增加一个假的时间xPendedCounts
:
如上图所示,在调度器关闭期间,SysTick
中断仍然会产生,xTaskIncrementTick
函数也仍然会调用,但是此时在该函数中并不进行阻塞任务的唤醒操作,也不增加系统时间xTickCount
,而是仅增加一个假的系统时间xPendedCounts
。
所以在打开调度器的时候,变量xPendedCounts
表示调度器的关闭时长,所以在do
循环中不断调用xTaskIncrementTick
函数来模拟系统时间的增加。每循环一次xPendedCounts
减一。
在模拟过程中会很快的将xPendedCounts
假系统时间消耗完毕,并且会进行阻塞任务的唤醒操作。
- 使用关闭中断的方式来保护临界资源的代价有点大,所以尽量使用闭关调度器的方式来保护临界资源。
🌏总结
分析了FreeRTOS源码中的任务创建,任务启动,任务切换,任务暂停和恢复,任务阻塞和唤醒以及临界资源的保护。要深刻体会到不同类型链表的作用,认识到不同任务的本质就是处于不同类型的链表中。