news 2026/10/17 1:16:23

FreeRTOS内核地基:列表项与调度器时间片轮转深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FreeRTOS内核地基:列表项与调度器时间片轮转深度解析

最近在啃FreeRTOS源码,越看越发现列表和列表项这套数据结构就是整个内核的地基。任务切换、延时、事件等待,归根到底都是把列表项从这个链表摘下来、挂到那个链表去。列表本身不难理解,但列表项里有几个字段——xItemValue、pxIndex、vListInsertEnd……我第一次看到的时候是有点懵的,尤其是pxIndex这种“游标”设计,如果不理解调度器是怎么用它做时间片轮转的,看多少遍源码都容易绕进去。这篇笔记就把这些容易不理解的点一次讲透,适合正在读FreeRTOS源码、或者被任务调度细节卡住的嵌入式开发者。

1. 列表项到底在管什么:先从调度器视角看数据结构

1.1 一个任务何时“挂”进列表

先别急着抠结构体,我想先从调度器视角捋一遍。FreeRTOS里的任务,不管处于什么状态,最终都要“挂”在某条链路上。新建一个任务,初始化完成后会放进就绪列表;任务调用延时函数,调度器把它从就绪列表摘下来,放进延时列表;延时时间到,又把它从延时列表摘下来,放回就绪列表。任务等信号量、等队列消息,也是类似流程,只不过多了一个事件等待列表。

所以你看,任务的每一次状态切换,本质上都是列表项的移动操作。这就像火车站里旅客的行李牌,旅客本身不变,但行李牌会挂在不同的队列上,表示他当前在哪个候车区。FreeRTOS的任务控制块(TCB)里长期放着两个列表项,一个管“状态”(xStateListItem),一个管“事件等待”(xEventListItem),分别是为调度器服务和为同步原语服务的。

如果没有列表这套基础数据结构,任务调度、超时管理、优先级就绪表全都无从谈起。所以在看调度算法之前,先把列表和列表项彻底搞明白,后面读task.c才不会像看天书。

1.2 ListItem_t 五个字段的分工,以及那个容易忽略的 MiniListItem_t

列表项的核心定义在 list.h 里,去掉完整性校验宏之后,长这样:

struct xLIST_ITEM { configLIST_VOLATILE TickType_t xItemValue; struct xLIST_ITEM *configLIST_VOLATILE pxNext; struct xLIST_ITEM *configLIST_VOLATILE pxPrevious; void *pvOwner; struct xLIST *configLIST_VOLATILE pxContainer; }; typedef struct xLIST_ITEM ListItem_t;

每个字段都不白给:

  • xItemValue:排序键,决定这个列表项在链表里的位置。
  • pxNext / pxPrevious:双向链表指针,分别指向后一个和前一个节点。
  • pvOwner:指向持有该列表项的对象,通常是任务TCB。这样从列表项能反查出是哪个任务在排队。
  • pxContainer:指向该列表项当前所属的列表。这个字段很重要,它让你能判断“这个列表项现在到底挂在哪个列表里”,也是O(1)删除操作的入口。

而列表本身的结构是:

typedef struct xLIST { UBaseType_t uxNumberOfItems; ListItem_t *configLIST_VOLATILE pxIndex; MiniListItem_t xListEnd; } List_t;

注意那个MiniListItem_t,它是列表项的“精简版”:

struct xMINI_LIST_ITEM { configLIST_VOLATILE TickType_t xItemValue; struct xLIST_ITEM *configLIST_VOLATILE pxNext; struct xLIST_ITEM *configLIST_VOLATILE pxPrevious; }; typedef struct xMINI_LIST_ITEM MiniListItem_t;

它没有pvOwner和pxContainer,只有排序值和前后指针。xListEnd就是这样一个“哨兵节点”,它不承载任何任务信息,只用来标记链表的边界。为什么FreeRTOS不直接用ListItem_t当xListEnd?省内存是原因之一,不过更关键的是让它看起来更“特殊”,从语义上就跟真正的列表项区分开了。

1.3 为什么是双向链表而不是数组

很多初学者会问,任务列表为什么不用数组?数组的随机访问更快,但任务的数量是动态的,创建和删除任务都要插入和摘除节点,数组的插入删除是O(n)的,最坏要整体搬移。而双向链表的插入和删除,只要知道节点位置,都是O(1)操作。

那为什么非要双向,单向不行吗?因为删除一个节点时,你要把前一个节点的next指向后一个节点。如果是单向链表,你还得从头遍历找到前驱,删一次O(n),调度器受不了。双向链表每个节点都保存前后指针,删自己只需要改两个指针。FreeRTOS作为一个实时内核,任何操作的时间复杂度都要可控,双向链表在这里是刚需,不是设计者炫技。

2. 读懂 xItemValue:它不是优先级,是一把排序的钥匙

2.1 同一个字段在三个场景中的含义

xItemValue刚开始很容易被理解成“优先级”,这是最大的误解。它本质上是一个“能被比较大小的数值”,具体存什么,取决于这个列表项被挂在哪个列表里。我列一个表格,一下就清楚了:

列表场景xItemValue 的实际含义排序效果
就绪列表任务优先级(数值越大优先级越高)同优先级任务不比较值,靠插入顺序
延时列表任务应该被唤醒的系统Tick数按唤醒时间升序,队首就是最早该醒的任务
事件等待列表任务等待超时的截止Tick数按截止时间升序,便于超时判断

你看,同一个字段,放在延时列表里就是“闹钟时间”,放在事件等待列表里就是“最后期限”,放在就绪列表里才等于优先级。所以读源码时一定不要看到xItemValue就脑补成优先级,它的语义是跟着列表走的。

任务控制块里有两个列表项,xStateListItem和xEventListItem。xStateListItem挂在就绪列表或状态相关的列表里,xEventListItem挂在事件等待列表里。同一个任务,可以同时出现在两个不同的列表中,因为它有两个独立的列表项,每个列表项有各自的xItemValue。

2.2 为什么 xListEnd 的值必须是 portMAX_DELAY

列表初始化时有这么一行:

pxList->xListEnd.xItemValue = portMAX_DELAY;

portMAX_DELAY就是TickType_t能表示的最大值。xListEnd是哨兵,永远在链表末尾。因为所有真实列表项的xItemValue都不会超过这个值(正常情况也不该超过),所以新节点无论怎么插,都会落在xListEnd前面。这样一来,遍历列表时只要遇到xListEnd,就知道到头了,不需要单独记长度或者尾部指针。

这个设计很像你往一本按金额排序的账本里加记录,最后一页永远放着一个“金额无穷大”的占位页,任何新账单都插在它前面。它保证了插入逻辑永远有一个明确的终止位置,不需要每回都做“链表是否为空”的特殊判断。代码里那个特殊分支处理的是新值恰好等于portMAX_DELAY的情况,此时直接让迭代器指向xListEnd,也是插到最末尾,语义保持一致。

2.3 相同 xItemValue 时插入顺序靠什么保证

vListInsert里那个for循环条件很多人第一眼会看岔:

pxIterator->pxNext->xItemValue <= xValueOfNewListItem

注意是<=,不是<。这意味着当链表里某个节点的xItemValue等于新节点的值时,循环还会继续往后走,直到遇到比新值更大的节点。效果就是,相等值的节点会按“先来后到”的顺序排队,新来的排到老节点后面。

就绪列表里如果有两个相同优先级的任务,虽然它们不靠vListInsert排序,但这个“稳定插入”的特性在延时列表里很重要。两个任务同一时刻调用延时API,唤醒时间相同,先进延时列表的先被唤醒,行为可预测。

所以xItemValue的正确理解方式是一把“排序钥匙”,它决定节点在列表中的位置,至于这个钥匙代表的是优先级、唤醒时刻还是超时时刻,完全由使用场景决定。

3. pxIndex:列表的“游标”,时间片轮转全靠它

3.1 pxIndex 初始化与移动规则

pxIndex是List_t里的一个字段,它指向列表中某个节点,类似一个“游标”。列表初始化时,pxIndex被指向xListEnd:

pxList->pxIndex = ( ListItem_t * ) &( pxList->xListEnd );

它是一个ListItem_t*,而xListEnd是MiniListItem_t,这里用强转,是因为xListEnd的pxNext和pxPrevious本身就是ListItem_t*,双向链表操作时把它当普通列表项用完全没问题。pxIndex的移动规则很明确:要么在插入时改变位置,要么在遍历取任务时往前走一步。

这个字段的存在,让FreeRTOS的列表不只是“有序链表”,还是一个带当前访问位置的数据结构。你插入节点的时候,插入位置可以直接由pxIndex决定,遍历节点的时候,也可以从pxIndex继续往前走,这就为时间片轮转调度打下了基础。如果没有pxIndex,想实现“这一轮从某个节点继续”就很麻烦。

3.2 listGET_OWNER_OF_NEXT_ENTRY 的“跳哨兵”逻辑

就绪调度里最核心的宏是listGET_OWNER_OF_NEXT_ENTRY,去掉校验部分后逻辑如下:

#define listGET_OWNER_OF_NEXT_ENTRY( pxTCB, pxList ) \ { \ List_t * const pxConstList = ( pxList ); \ ( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext; \ if( ( void * ) ( pxConstList )->pxIndex == ( void * ) &( ( pxConstList )->xListEnd ) ) \ { \ ( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext; \ } \ ( pxTCB ) = ( pxConstList )->pxIndex->pvOwner; \ }

第一次看这个宏,很多人的疑问是:凭什么要跳两次?第一次把pxIndex移到下一个节点,如果发现指向了xListEnd,说明遍历已经走到了链表末尾的哨兵,此时再往下跳一次,就绕到了链表的第一个节点。这个“遇到哨兵就再跳一次”的逻辑,保证每次调用都能返回一个真实的列表项所有者(即任务TCB),并且能循环遍历整个链表。

调度器调用这个宏,拿到下一个应该运行的任务,运行一个时间片后,如果任务还在就绪态,就通过vListInsertEnd把它插回列表“尾部”,下一次调度又调用这个宏继续取下一个。pxIndex在这个过程中不断前移,形成循环轮转。这就是时间片轮转(Round Robin)的最底层实现。

3.3 普通尾插与“游标尾插”的本质区别

“插入尾部”这个说法容易让人误解。一般的尾插是指固定插在整个链表的最后节点之前。但FreeRTOS的vListInsertEnd插的不是传统意义上的末尾,而是“pxIndex之前”。

当pxIndex指向xListEnd时,插入位置确实就是整个链表的末尾。但当pxIndex已经移动到列表中间的某个节点时,插入位置就在这个节点前面。所以vListInsertEnd更像“在当前游标位置之前插入”,而不是“在链表末尾插入”。这个设计配合listGET_OWNER_OF_NEXT_ENTRY使用,非常巧妙。

你可以把就绪列表想象成一张圆形转盘,pxIndex是转盘上的指针,任务节点围着转盘排队。调度器每次按指针方向取一个任务,运行完后把它放回指针前面。这样一圈圈转下来,同优先级任务得到均等的CPU时间,没有哪个任务被饿死。如果只用一个固定的链表尾指针,这个轮转效果就很难实现,因为你无法动态调整“尾部”的位置。

4. vListInsertEnd 与 vListInsert:两个插入函数的源码级对比

4.1 vListInsert:升序插入,O(n)排位置

vListInsert的代码在tasks.c里,是常规意义上的“有序插入”:

void vListInsert( List_t * const pxList, ListItem_t * const pxNewListItem ) { ListItem_t * pxIterator; const TickType_t xValueOfNewListItem = pxNewListItem->xItemValue; if( xValueOfNewListItem == portMAX_DELAY ) { pxIterator = ( ListItem_t * ) &( pxList->xListEnd ); } else { for( pxIterator = ( ListItem_t * ) &( pxList->xListEnd ); pxIterator->pxNext->xItemValue <= xValueOfNewListItem; pxIterator = pxIterator->pxNext ) { /* 空循环,只用来移动迭代器 */ } } pxNewListItem->pxNext = pxIterator->pxNext; pxNewListItem->pxNext->pxPrevious = pxNewListItem; pxNewListItem->pxPrevious = pxIterator; pxIterator->pxNext = pxNewListItem; pxNewListItem->pxContainer = pxList; ( pxList->uxNumberOfItems )++; }

逻辑非常朴素:从哨兵xListEnd开始往后找,找到第一个“值大于等于新值”的节点,把新节点插在它前面。如果新值等于portMAX_DELAY,直接跳到xListEnd,插到最末尾。因为有哨兵,空列表和第一个插入的节点都不需要特殊照顾,代码统一了。

这个函数的时间复杂度最坏是O(n)。延时列表、事件等待列表里的节点数量一般不会特别大,而且这些列表本来就需要按时间排序,O(n)可以接受。如果用在这里的vListInsertEnd,列表乱序,超时管理就会全盘崩溃。

4.2 vListInsertEnd:游标尾插,O(1)入列

vListInsertEnd的代码更短:

void vListInsertEnd( List_t * const pxList, ListItem_t * const pxNewListItem ) { ListItem_t * const pxIndex = pxList->pxIndex; pxNewListItem->pxNext = pxIndex; pxNewListItem->pxPrevious = pxIndex->pxPrevious; pxIndex->pxPrevious->pxNext = pxNewListItem; pxIndex->pxPrevious = pxNewListItem; pxNewListItem->pxContainer = pxList; ( pxList->uxNumberOfItems )++; }

新节点的next指向pxIndex,previous指向pxIndex前一个节点,然后把前一个节点的next和新节点的previous互相接上。一句话:把新节点夹在pxIndex和它前一个节点中间。整个操作不依赖链表长度,时间复杂度O(1)。

这个函数不去比较xItemValue,所以它的插入位置完全由pxIndex决定。xItemValue在这里只作为一个普通字段保存,不参与排序判断。这就是为什么FreeRTOS能在就绪列表里用O(1)完成同优先级任务的插入——它不需要维持有序,只需要保证“从pxIndex继续轮转”的语义。

4.3 调度器在什么场景选择哪个接口

这两个插入接口是搭配使用的,不是随便选的。我把它们的区别整理成了一张表:

对比项vListInsertvListInsertEnd
插入位置按xItemValue升序插入插到pxIndex当前指向的节点之前
排序依据xItemValue不使用xItemValue
时间复杂度最坏O(n)O(1)
典型场景延时列表、事件等待列表就绪列表同优先级追加
与pxIndex的关系不改变pxIndex依赖pxIndex当前值

就绪列表用vListInsertEnd,因为同优先级任务之间不需要按优先级排序,它们要的是公平轮转,每个任务都该轮流执行。而延时列表和事件等待列表必须用vListInsert,因为它们需要严格的升序排列,否则调度器无法快速判断“谁最早超时”。

说句实在话,这两个接口的名字起得有点误导人。vListInsertEnd听起来像“插到末尾”,实际是“插到游标前”;vListInsert听起来像“插入”,实际是“按值排序插入”。我当初就是被这名字骗了,走了不少弯路。

4.4 源码里的“隐藏宏”为什么值得一看

列表和列表项的源码里有一些看起来很奇怪的宏,比如configLIST_VOLATILE、listFIRST_LIST_ITEM_INTEGRITY_CHECK_VALUE、listSECOND_LIST_ITEM_INTEGRITY_CHECK_VALUE。它们平时不影响功能,但知道它们是干嘛的,能帮你排查一些诡异问题。

configLIST_VOLATILE就是volatile修饰符,防止编译器把对列表指针的访问优化掉。因为列表项可能被中断或另一个任务修改,编译器要是把某个读取缓存进寄存器,读到旧值就麻烦了。完整性校验宏是给列表项前后填充特定的字节值,开启configUSE_LIST_DATA_INTEGRITY_CHECK_BYTES后,FreeRTOS会定期检查这些校验字节是否被破坏,用来检测内存越界和栈溢出。通常我们在调试阶段才打开,正式发布默认关闭。

5. 动手小实验:用一张列表验证插入与遍历行为

5.1 实验设计:构造列表项并观察插入位置

光看源码还是不够,我建议你亲手跑一个简单实验。我写了一个很小的Demo,模拟列表插入与遍历过程。这个实验不需要真实创建任务,自己初始化一个列表,创建三个列表项,分别设置不同的xItemValue,然后用两个插入接口分别演示结果。

实验代码如下:

void vListDemo( void ) { List_t xList; ListItem_t xItem1, xItem2, xItem3; vListInitialise( &xList ); vListInitialiseItem( &xItem1 ); vListInitialiseItem( &xItem2 ); vListInitialiseItem( &xItem3 ); /* 实验一:用 vListInsert 插入 3、1、2 */ xItem1.xItemValue = 3; xItem2.xItemValue = 1; xItem3.xItemValue = 2; vListInsert( &xList, &xItem1 ); vListInsert( &xList, &xItem2 ); vListInsert( &xList, &xItem3 ); /* 实验二:用 vListInsertEnd 插入同样的值 */ vListInitialise( &xList ); xItem1.xItemValue = 3; xItem2.xItemValue = 1; xItem3.xItemValue = 2; vListInsertEnd( &xList, &xItem1 ); vListInsertEnd( &xList, &xItem2 ); vListInsertEnd( &xList, &xItem3 ); }

给列表项设置pvOwner为不同的编号值,遍历打印时就能分辨到底是哪个列表项。

5.2 实验一:vListInsert 的结果

按顺序调用三次vListInsert,最终链表按xItemValue排序后的结构是:

xItem2(xItemValue=1) -> xItem3(xItemValue=2) -> xItem1(xItemValue=3) -> xListEnd

插入过程是:xItem1(值3)插入空链表,位置在xListEnd前;xItem2(值1)从xListEnd开始向后找,xListEnd的下一个节点是xItem1,xItem1的xItemValue=3,1 <= 3成立,所以继续向后,下一个是xListEnd,循环终止,xItem2插到xItem1前面;xItem3(值2)同理找位置,最终插到xItem2和xItem1之间。

这验证了vListInsert完全按照xItemValue排序,与插入顺序无关。它的行为就是我们常见的“有序链表插入法”。

5.3 实验二:vListInsertEnd 的结果

用vListInsertEnd插入相同顺序,链表结构是:

xItem1(xItemValue=3) -> xItem2(xItemValue=1) -> xItem3(xItemValue=2) -> xListEnd

三个列表项的顺序,完全等于插入的先后来到顺序。因为每次插入都发生在pxIndex之前,pxIndex初始指向xListEnd,所以第一个插入的xItem1位于哨兵前;第二个插入时,pxIndex还是xListEnd(vListInsertEnd不移动pxIndex),xItem2插到xItem1后面;第三个同理。

这时候用listGET_OWNER_OF_NEXT_ENTRY遍历三次,返回的顺序就是xItem1、xItem2、xItem3,同插入顺序完全一致。这就是就绪列表里时间片轮转的顺序来源。如果你给就绪列表同一优先级连续调用vListInsertEnd添加任务,调度器每次取走的都是最早加入且还没有运行过的那个任务。

5.4 实验结论与调度器设计意图

两个实验对照看,调度器的设计意图就非常清晰了。就绪列表不需要维护任务优先级顺序,同优先级任务之间只需要一个公平队列,所以用vListInsertEnd,插入O(1)。延时列表需要维护唤醒时刻顺序,所以用vListInsert,牺牲一点插入时间换取出队效率。

调度器之所以能在每个系统Tick里快速判断“有没有任务该醒了”,靠的就是延时列表的有序性。它只需要看第一个节点的xItemValue是否小于等于当前Tick,是就取出处理,不是就说明没有任务需要唤醒。如果延时列表无序,每次都要遍历全部节点,实时性就没法保证。

6. 常见问题与排查技巧实录

6.1 列表项被重复插入会怎样

如果用同一个列表项指针连续调用两次vListInsertEnd,第二次插入时,这个节点还在前一个列表里,新旧指针会互相覆盖,链表结构立刻被破坏,轻则丢节点,重则死循环。这是初学者最容易犯的错误。

FreeRTOS在列表项结构里放了pxContainer字段,就是为了防这个。一个列表项只能有一个pxContainer,如果它不为空,说明这个列表项已经挂在某个列表里,不应该再次插入。实际使用中,任务的状态列表项和事件列表项是提前分配好的,不同的场景用不同的列表项,不要复用一个列表项放进两个列表。

6.2 遍历列表时能不能顺手删节点

遍历操作和时间片轮转的交互非常微妙。listGET_OWNER_OF_NEXT_ENTRY会移动pxIndex,如果遍历过程中删除当前节点,pxIndex可能指向已摘除节点,下一轮遍历就乱了。

FreeRTOS内部代码在删除节点时会做保护。uxListRemove里有一行:

if( pxList->pxIndex == pxItemToRemove ) { pxList->pxIndex = pxItemToRemove->pxPrevious; }

这是把游标回退到被删节点的前一个,避免悬空。但你写自己的遍历代码时,一定要记得这个规则:要么先记录“下一个节点”再删除当前节点,要么删除后重新定位pxIndex。我自己写调试代码时,通常先打印pxList->pxIndex的位置,再打印所有节点的pvOwner,从数据上确认当前状态,再决定要不要动链表。

6.3 排查任务卡死:先查列表状态

遇到任务莫名其妙不再执行,我建议先看就绪列表的uxNumberOfItems。如果应该运行的任务不在就绪列表里,再看延时列表的队首,确认它的唤醒时间是否正常。很多时候所谓“任务卡死”,其实是任务被挂到了延时列表,而唤醒时间被设成了portMAX_DELAY,也就是永久阻塞,表象上跟死锁一模一样。

事件等待列表同理,如果任务在等信号量,它的xEventListItem一定在某个队列的等待列表上,并且xItemValue记录的是截止时间。排查时就盯着这几个列表的节点数量和pxIndex位置,基本能定位问题在哪一步。这是源码级调试的基础操作。

6.4 一个调试小技巧:用断言和 IDE 实时表达式

想把这些列表行为看得更明白,可以把configASSERT打开,它会在列表操作出错时立刻停下,而不是让程序带病运行。configUSE_LIST_DATA_INTEGRITY_CHECK_BYTES在调试阶段也可以打开,列表被踩踏时能更快暴露问题。

我用IDE调试时,喜欢在实时变量窗口里同时监控pxList->uxNumberOfItems、pxList->pxIndex、以及某个列表项的pxContainer。当程序停在断点处,直接看这三个值,基本能还原出列表当前的结构。我还习惯在列表操作的关键函数入口加条件断点,比如vListInsertEnd里当pxList->uxNumberOfItems大于某个值时暂停,可以快速捕捉到异常插入。这个习惯帮我排查过好几个优先级反转导致的怪异现象。

最后再分享一个我个人的小习惯:看FreeRTOS源码,别急着看调度算法本身,先把List的几个函数在纸上画一遍链表指针变化,特别是vListInsertEnd和pxIndex的配合。画完这张图,时间片轮转、延时队列、任务状态迁移这些概念会一下子串起来。列表项没搞懂之前,后面全都是空中楼阁;一旦这块通了,FreeRTOS的内核主线基本就握住了一半。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:16:18

evo轨迹评估工具:SLAM与视觉里程计精度分析实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:15:57

S5PV210交叉编译器安装全指南:从工具链选型到Hello World

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:15:53

彻底搞懂SIFT:从尺度空间到OpenCV特征匹配与调参实战

简介&#xff1a;基于尺度不变特征变换&#xff08;SIFT&#xff09;的Python实现与OpenCV调用资源包&#xff0c;面向需要实现SIFT算法或借助OpenCV快速提取特征的计算机视觉学习者、研究者和开发者&#xff0c;可应用于图像匹配、物体识别、三维重建、全景拼接等场景。压缩包…

作者头像 李华
网站建设 2026/10/17 1:15:47

C语言处理CSV文件:状态机解析器设计与完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:15:43

Python猜拳小游戏实战:从零到一掌握编程核心逻辑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:15:20

单纯形法与大M法实战:从SciPy调库到手写Python实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华