注意:这篇文章上次更新于669天前,文章内容可能已经过时。
This article was last updated669 days ago, the content may be outdated.
Scheduler 翻译成中文叫做调度器。
调度器这个词对于学计算机的同学来说应该并不陌生,因为在大学操作系统的课堂上我们学过很多关于进程调度的知识。
- 先来先服务(FCFS)
- 短作业优先(SJF)
- 优先级调度
- 时间片轮转
- 多级反馈队列
等等。
在这篇文章中,我们将会讨论高通 WLAN Host Driver 中调度器的实现。
为什么先学习调度器呢?
因为调度器是 WLAN Host Driver 中相对简单的部分,仅有 4 个源代码文件,而且调度器是 WLAN Host Driver 的核心部分,是各个模块之间沟通的桥梁。
源代码我转存到了我的 GitHub 仓库中,今天要看的部分在这里:
也可以使用我的 opengrok 服务器来查看源代码,如果它还在线的话。
https://opengrok.dijk.eu.org/xref/wlan-driver/qca-wifi-host-cmn/scheduler/
数据部分
scheduler 上下文
代码位置:
⭐
根据类型,消息体指针可以是指向内存的 bodyptr 指针,也可以是作为 32 位数据的 bodyval。bodyptr 总是可以被释放的指针,应始终确保 bodyptr 是可以被释放的。消息应该使用 bodyptr 或 bodyval,但不应同时使用两者!
👀
关于链表节点,我曾经在这篇文章中讨论过。
sched_history_item
代码位置:
这个数据结构同于记录调度器的历史记录。
在 Scheduler 模块中,有一个全局变量 sched_history,它是一个数组,用于记录最近的 WLAN_SCHED_HISTORY_SIZE 条历史记录。
1 | static struct sched_history_item sched_history[WLAN_SCHED_HISTORY_SIZE]; |
提醒一下,这些代码是否生效取决于 WLAN_SCHED_HISTORY_SIZE 是否被定义。如果没有定义 WLAN_SCHED_HISTORY_SIZE,那么和调度器历史有关的代码都不会参与编译。
sched_pool 🌶️
又到了内核代码极其抽象的宏定义时间了 🤣
这部分由 ChatGPT 给我们协助讲解。
其实内存池应该在一个单独的文章中讲解。
如果你学习过 malloc 的原理的话,其实可以把这里的
sched_pool理解为用于分配scheduler_msg的数据结构。
代码位置:
定义一个内存池,用下面这个宏函数。
1 | DEFINE_QDF_FLEX_MEM_POOL(sched_pool, sizeof(struct scheduler_msg), |
这个宏函数被写成了下面的样子。
1 | /** |
这些代码被写在 qdf 模块里,已经超出了本文所介绍的范围,关于 QDF_FM_BITMAP_BITS, QDF_LIST_NODE_INIT_SINGLE, QDF_LIST_ANCHOR, QDF_LIST_INIT_SINGLE 等宏定义就不再继续展开了。
我们可以简单理解一下这个宏定义做了什么事。
对话 GPT-4o
Q1:
这个宏函数做了什么?
A1:
这个宏 DEFINE_QDF_FLEX_MEM_POOL 用于在C程序中定义并初始化一个带有一个段(segment)的灵活内存池。我们来详细解释一下这个宏的功能,然后应用到给定的例子中。
宏的详细解析
-
定义一个内存池结构体变量:
1
struct qdf_flex_mem_pool name;
这行代码定义了一个类型为
struct qdf_flex_mem_pool的新变量name,用于表示内存池。 -
为段头部分配字节:
1
uint8_t __ #
这行代码定义了一个名为
__name_head_bytes的字节数组,大小为QDF_FM_BITMAP_BITS * (size_of_item)。这个数组用于存储内存池的第一个段的数据。其中,QDF_FM_BITMAP_BITS可能是一个常量,表示位图的位数,而size_of_item是每个池分配项的大小。 -
定义初始段:
1
2
3
4
5struct qdf_flex_mem_segment __ ## name ## _head = { \
.node = QDF_LIST_NODE_INIT_SINGLE( \
QDF_LIST_ANCHOR(name.seg_list)), \
.bytes = __ #这段代码定义了一个名为
__name_head的段结构体:node: 使用QDF_LIST_NODE_INIT_SINGLE和QDF_LIST_ANCHOR初始化为连接到内存池段列表的单节点。bytes: 指向前面定义的__name_head_bytes数组,这是这个段实际的内存区域。
-
初始化内存池:
1
2
3
4
5struct qdf_flex_mem_pool name = { \
.seg_list = QDF_LIST_INIT_SINGLE(__ #这行代码初始化名为
name的内存池结构体:seg_list: 初始化为一个只包含一个段(__name_head.node)的列表。reduction_limit: 设置为rm_limit,表示在减少内存池时保留的最小段数。item_size: 设置为size_of_item,表示内存池管理的每个项的大小。
应用该宏
给定的用法:
1 | DEFINE_QDF_FLEX_MEM_POOL(sched_pool, sizeof(struct scheduler_msg), WLAN_SCHED_REDUCTION_LIMIT); |
name = sched_pool: 这会创建一个名为sched_pool的struct qdf_flex_mem_pool类型的变量。size_of_item = sizeof(struct scheduler_msg): 该池中每个项的大小为struct scheduler_msg的大小。rm_limit = WLAN_SCHED_REDUCTION_LIMIT: 在减少内存池时保留的最小段数由WLAN_SCHED_REDUCTION_LIMIT定义。
宏的功能总结
宏 DEFINE_QDF_FLEX_MEM_POOL:
- 定义一个带有指定名称的新的灵活内存池。
- 根据它将管理的项的大小为第一个段分配预定义数量的内存。
- 初始化内存池,设置链表和其他属性,比如项的大小和减少时保留的最小段数。
这个宏简化了灵活内存池的创建和初始化,使得能够更容易地使用设置好的结构和参数动态管理内存。
Q2:
如果理解“带有一个段(segment)”
A2:
“带有一个段(segment)” 指的是在创建内存池时,内存池初始化时包含一个内存段。每个段是内存池中的一个独立块,用于存储特定数量的内存项。
在这个宏的上下文中,一个“段”就是一个内存块,其中包含了若干个固定大小的内存项。段的用途在于管理内存的分配和释放,通常在需要动态分配和回收大量小内存块的情况下使用。通过使用段,可以减少内存碎片,优化内存使用,同时也便于内存管理操作(如扩展、缩减内存池)。
具体来说,“带有一个段”在这里的含义可以理解为:
-
初始内存分配: 宏定义的内存池在创建时,会先分配一段固定大小的内存,这段内存称为“段”。在代码中,使用一个字节数组
__name_head_bytes来实际存储这些内存数据。 -
段的结构: 每个段都有一个管理节点(
node),用于在池的段列表中进行链表管理;并且有一个指向实际数据的指针(bytes),即__name_head_bytes。 -
段的数量: 虽然这里初始化时只有一个段,但整个设计是支持多个段的(如通过链表结构管理多个段)。段的数量可以根据需要增加或减少,
reduction_limit参数指定了在减少段时,池中最少需要保留的段数量。
总结来说,“带有一个段”意味着这个内存池一开始就包含了一个已经分配好的内存块,这个块被组织成段,用于存储和管理内存项。随着需求变化,可以动态调整段的数量来管理内存使用。
Q3:
当需要更多内存时,这个内存池会做哪些事情
A3:
当需要更多内存时,这个内存池可能会执行以下操作来分配更多的内存段。这些操作的具体实现取决于内存池的设计和用途。一般来说,灵活的内存池在内存需求增加时会按需扩展,并进行以下步骤:
-
分配新的内存段:
- 如果现有的段已满或无法满足新的内存请求,内存池会分配一个新的内存段(segment)。
- 这涉及为新段分配一个大小为
QDF_FM_BITMAP_BITS * size_of_item的字节数组。QDF_FM_BITMAP_BITS和size_of_item共同决定了每个段中可以存储多少项。 - 新的段通常会使用类似于初始化第一个段的过程,即创建一个新字节数组来保存数据,并将其链接到段链表中。
-
将新段加入段链表:
- 新分配的段通过链表加入到内存池的段列表中。这些段是通过链表结构(如双向链表或单向链表)管理的,以便快速插入和删除。
- 在宏中,有
QDF_LIST_INIT_SINGLE和QDF_LIST_NODE_INIT_SINGLE这样的宏,可能用于初始化和操作链表节点,这些节点帮助管理不同段之间的关系。
-
更新内存池的管理结构:
- 内存池的元数据需要更新,例如:
- 段列表的头部指针或尾部指针。
- 内存池中当前段的数量。
- 可能还有内存池的总大小或已使用的内存总量。
- 内存池的元数据需要更新,例如:
-
扩展段的管理:
- 如果内存池有内存回收机制,当内存段不再需要时(例如,当内存项被释放后),池会检查是否可以回收某些段,以节省内存资源。
- 宏中提到的
reduction_limit是用于减少内存段时的最小段数限制,在增加段时也会考虑到这个限制,以确保内存管理的稳定性和性能。
-
重分配失败时的错误处理:
- 如果内存池在尝试分配新段时失败(例如,没有足够的系统内存),内存池需要有一定的错误处理机制,如记录错误、触发报警、或执行垃圾回收来释放未使用的内存。
-
可能的内存对齐和优化:
- 在某些情况下,内存池可能会进行内存对齐操作,以确保内存访问的效率。
- 对齐和优化可以避免内存碎片,并确保高效的内存使用。
代码示例
虽然你提供的宏并没有完整展示内存池的动态扩展机制,但假设这部分逻辑在内存池实现的其他部分中可以是这样的:
1 | void expand_memory_pool(struct qdf_flex_mem_pool *pool) { |
总结
当需要更多内存时,这个内存池会通过分配新的内存段、将新段加入管理链表、更新元数据,以及可能的错误处理来满足需求。这种机制使得内存池能够灵活地适应内存需求的变化,同时有效管理内存的分配和回收。
Q4:
现在让我来解释一下内存池,你来给我指正:
这个内存池就是预先申请了一大块内存,然后把这个内存组织成若干小块。每个小块包含两部分,一部分是实际使用的内存项,另一部分相当于一个头部,利用头部的指针信息把这些小块串联成一个链表。这个内存池可以对外部提供 get 内存项 和 free 内存项的 api,就像 malloc 和 free 一样。当一个内存项被 free 给内存池时,内存池不一定把这个内存交给操作系统,而是通过调整"头部"的指针信息来合并一些小的内存块,避免内存碎片。
A4:
你的解释总体来说是正确的,清晰地描述了内存池的基本工作原理。内存池的确通过预先申请一大块内存,将其分割成小块来高效管理内存分配和释放。下面是一些具体的指正和补充,帮助更好地理解内存池的运作:
-
头部与数据部分的关系:
- 你提到每个小块包含两部分:实际使用的内存项和一个头部。这是准确的。头部通常包含管理信息,例如指向下一个块的指针、块的状态(空闲或已用),以及其他管理所需的元数据。
- 这种设计可以让内存池在不需要额外的内存分配和释放的情况下管理内存块,减少内存碎片的产生。
-
链表的使用:
- 你提到利用头部的指针信息把这些小块串联成一个链表。这也是正确的。链表结构在内存池中很常见,因为它允许高效地插入和删除块,适合动态内存分配的场景。
- 通常,空闲块会被组织成一个空闲链表。每次分配时,内存池从空闲链表中取出一个合适大小的块;每次释放时,将块插回空闲链表。
-
get 和 free API 的实现:
- 内存池的 get 和 free 操作类似于
malloc和free,但通常会更高效,因为它们不涉及系统调用。get可能从空闲链表中找到一个适当大小的块并返回给调用者。free则把块返回到空闲链表中。 - 内存池的好处是减少了频繁的系统级内存分配操作,系统调用通常较慢且可能引入碎片。而内存池管理的内存通常在堆上,是应用级的。
- 内存池的 get 和 free 操作类似于
-
内存碎片管理:
- 你提到内存池在释放时可能不会将内存返还给操作系统,而是通过调整头部指针来合并小的内存块。这种做法可以减少内存碎片,提高内存使用效率。
- 具体来说,内存池可能实现某种内存合并策略,如相邻空闲块的合并(coalescing),以此来减少内存碎片,使得内存池中有足够大的连续空间来满足未来的内存分配请求。
-
可能的扩展:
- 当内存池中没有足够的空闲块时,内存池可能会扩展,即再申请一块内存并将其加入内存池的管理结构中。这可以通过追加新的段来实现,如前面描述的那样。
总结
你对内存池的描述基本上是正确的,并且涵盖了主要概念。内存池通过预先申请一大块内存,并将其组织成多个小块来管理内存。这种方法避免了频繁的系统调用,从而提高了性能,并通过合并和管理内存块的策略来减少碎片。内存池的实现涉及对内存块的分配、释放、以及碎片管理策略的设计。总体来说,你对内存池的理解是准确的,描述也是很清楚的!
逻辑部分
初始化
Scheduler 模块的初始化函数是 scheduler_init, 它在 hdd_module_init 中被调用。
整个函数的调用链如下:
module_init(hdd_module_init);
–>
hdd_module_init
–>
hdd_driver_load
–>
hdd_component_init
–>
dispatcher_init
–>
/*
- scheduler INIT has to be the last as each component’s
- initialization has to happen first and then at the end
- scheduler needs to start accepting the service.
*/
if (QDF_STATUS_SUCCESS !=scheduler_init())
goto scheduler_init_fail;
然后进入我们的主题:scheduler_init。
1 | QDF_STATUS scheduler_init(void) |
scheduler_all_queues_init:
1 | static qdf_atomic_t __sched_queue_depth; |
Enable
开启调度器的入口是 Kernel 来调用驱动程序的 probe 函数,在这里是 wlan_hdd_pld_probe。
1 | struct pld_driver_ops wlan_drv_ops = { |
整个函数的调用链如下:
wlan_hdd_pld_probe
–>
hdd_soc_probe
–>
__hdd_soc_probe
–>
hdd_wlan_startup
–>
hdd_wlan_start_modules
–>
cds_open
–>
dispatcher_enable
–>
scheduler_enable
到这里就进入了本节主题:scheduler_enable。
这里也是调度器多线程开始的地方。
1 | QDF_STATUS scheduler_enable(void) |
那么 sch_start_event 什么时候会变成非 0 呢?应该在 scheduler_thread 函数里面进行设置。
我先大概画了一下这两个线程的状态迁移过程,如下图。
然后我们再继续看代码。
1 | int scheduler_thread(void *arg) |
scheduler_thread_process_queues 用于处理 6 个队列中的消息,这 6 个队列按优先级排序,首先处理第一个队列的所有消息,如果第一个队列的所有消息处理完毕,然后检查第二个队列,以此类推,直到所有队列的消息都处理完毕,或者收到 shutdown 信号。
这里再解释一下上面为什么要先清除 MC_POST_EVENT_MASK 位,再处理消息。
我们首先要知道 MC_POST_EVENT_MASK 位是在哪里设置的,这个位是在 scheduler_post_message 函数中设置的,这个函数是用来向调度器线程发送消息的,当发送消息时,会设置 MC_POST_EVENT_MASK 位,这个位是用来通知调度器线程有新的消息需要处理。
考虑下面这一种情况。
- 调度器处理完成了第一个队列的所有消息,然后开始检查第二个队列。
- 此时有一个新消息被 post 到第一个消息队列,然后设置了
MC_POST_EVENT_MASK位。 - 调度器依次检查了所有队列,处理完成了所有消息,然后执行了
qdf_atomic_clear_bit(MC_POST_EVENT_MASK, &sch_ctx->sch_event_flag);,这就导致了再调度器线程进行循环检查消息队列时,会发现暂时没有MC_POST_EVENT_MASK被设置,就会持续等待。但请注意,此时第一个队列中有一个新消息,这个消息并没有被处理。它会等到下一次 post 消息到来时,才会被处理。这就导致了延迟。
1 | static void scheduler_thread_process_queues(struct scheduler_ctx *sch_ctx, |
如果 sch_ctx->timeout 时间内 handler 没有处理完成,那么就会调用 scheduler_watchdog_timeout
1 | static void scheduler_watchdog_timeout(void *arg) |
handler 注册
上一节中提到了每个消息队列有自己的handler,这个 handler 是在哪里注册的呢?
在上一节中的 cds_open 函数中,调用了 cds_register_all_modules 函数,这个函数会调用scheduler_register_module来为每个队列注册 handler。
cds_open
–>
dispatcher_enable
cds_register_all_modules
–>
scheduler_register_module
1 | static QDF_STATUS cds_register_all_modules(void) |
每个 handler 都会调用 msg 中设置的 callback 函数,但针对不同队列的消息,可能会有属于这个队列的特殊处理,所以要为每个队列注册一个 handler。
本文写到这里我已经发现我写的很烂。想要用文字讲解代码还是很困难。有时我又比较拘泥于细节,但仍然还有很多细节没有讲到。我觉的我继续讲下去只会更加混乱,所以本文暂时讲到这里。
希望对你有所帮助!
外部 API
2024-09-22 更新
最后再补充一下外部模块如何和调度器模块交互的。
最频繁被调用的外部 API 就是 scheduler_post_message,这个函数用来向调度器线程发送消息。
它是一个宏定义,参数分别是 源模块 ID,目标模块 ID,队列 ID,消息指针。
它被定义为 scheduler_post_message_debug,这个函数会在原始参数的基础上加上行号和函数名两个参数,这样在调试的时候可以知道是哪个函数调用了 scheduler_post_message。
1 | /** |
scheduler_post_message_debug 会继续调用 scheduler_post_msg,这个函数会将消息放入到消息队列中。
只不过这里做了一个简单的加工,将消息的源模块 ID,目标模块 ID,队列 ID 转换为 qid。
1 | QDF_STATUS scheduler_post_message_debug(QDF_MODULE_ID src_id, |
scheduler_get_qid 极其相关的宏定义如下。
0x3FF 的二进制表示就是 10 个 1,也就是说,scheduler_get_src_id 取的是 qid 的高 10 位,scheduler_get_dest_id 取的是 qid 的中间 10 位,scheduler_get_que_id 取的是 qid 的低 10 位。
scheduler_get_qid 则是一种逆运算,将 src_id,dest_id,que_id 通过移位的方式放在 qid 的不同位置。
这样做可以理解为一种压缩内存的方式,将三个取值范围小于 1024 的数值压缩到一个 32 位的整数中。
1 |
继续向下追踪,
scheduler_post_msg 会调用 scheduler_post_msg_by_priority 函数,这个函数会根据消息的优先级,将消息放入消息队列的不同位置中。
1 | static inline QDF_STATUS scheduler_post_msg(uint32_t qid, |
scheduler_post_msg_by_priority 才是最通用的函数,它的第三个参数是一个布尔值,用来决定是否按照优先级插入消息队列。
在常规模式下,如果外部模块调用的是 scheduler_post_message,那么这个参数就是 false。
这个函数的实现比较长,大家可以查看我的注释。
1 | QDF_STATUS scheduler_post_msg_by_priority(uint32_t qid, |
至此,关于 scheduler_post_message 的调用过程就介绍完了。
下面看一个例子,在 scan 模块中,如何向调度器模块发送消息。
wlan_scan_start 函数内部调用了 scheduler_post_message 函数,向调度器模块发送消息。
1 | QDF_STATUS wlan_scan_start(struct scan_start_request *req) |
根据上面的介绍,这个消息被放到了 QDF_MODULE_ID_OS_IF 的消息队列中,然后调度器线程会处理这个消息。
在调度器模块中,为 QDF_MODULE_ID_OS_IF 消息队列注册的 handler 是 scheduler_os_if_mq_handler,
也就是说调度器线程会调用 scheduler_os_if_mq_handler 函数来处理这个消息。
这个函数的内部实现是这样的。
本质就是调用消息的 callback 函数,然后参数是消息本身。
1 | QDF_STATUS scheduler_os_if_mq_handler(struct scheduler_msg *msg) |
对于上面的 scan 消息,当执行完 scheduler_post_message 后,调度器线程会被唤醒,然后调用 scheduler_os_if_mq_handler 函数,这个函数会调用 scm_scan_start_req 函数,然后将消息作为参数传递给 scm_scan_start_req 函数。
我们可以继续看一下 scm_scan_start_req 函数的实现。
1 | QDF_STATUS |
当我起初查看 WLAN 驱动的代码时,每次追踪函数调用时,看到
scheduler_post_message函数我总是很困惑。因为这里发生了线程切换,无法继续向下追踪。但好在 scheduler 模块的代码比较简单,只要理解了消息队列的概念,就能很好的理解这个模块的作用。
下次再看到
scheduler_post_message函数时,只需要知道接下来将切换到调度器线程中执行msg->callback函数,参数是msg本身就可以继续追踪了。


