4.3 POSIX skin的不兼容性
4.3.1 mlockall 与栈大小
在 Xenomai 等实时系统中,确保程序运行的确定性和低延迟是至关重要的。为了实现这一点,Xenomai 在其初始化过程中使用了一个关键的 Linux 系统调用mlockall(),以提升内存访问效率并避免潜在的页面错误(page fault)。
本小节将深入解析mlockall的原理、作用及其对线程栈大小的影响,并探讨如何在系统中合理配置。
1. 什么是mlockall()
mlockall()是一个 Linux 系统调用,用于将进程的所有虚拟内存页锁定在物理内存中,防止它们被交换到磁盘或因未分配而引发页面错误。该调用的原型如下:
intmlockall(intflags);其中,常用的标志包括:
MCL_CURRENT:锁住当前已分配的所有内存页。MCL_FUTURE:锁住将来可能分配的内存页。
当一个程序调用mlockall(MCL_CURRENT | MCL_FUTURE)后,Linux 内核会尝试将所有当前和未来使用的内存页都映射到物理内存中,并禁止这些页被换出(swap out)。
调用mlockall(MCL_CURRENT | MCL_FUTURE)不会直接导致立即分配物理页面。mlockall的作用是锁定当前和将来映射的内存页,防止它们被交换到磁盘上的交换空间(swap)。然而,这并不意味着所有虚拟内存都会立即对应到物理内存页。
当你使用mlockall(MCL_CURRENT | MCL_FUTURE)时:
- MCL_CURRENT标志表示锁定当前已映射的内存页面。
- MCL_FUTURE标志表示锁定将来映射的内存页面。
这意味着对于已经分配并映射的内存区域(由MCL_CURRENT指示),操作系统将确保这些页面不会被交换出去。但是,如果这些页面之前没有被访问过(即没有发生缺页异常),那么它们可能还没有对应的物理内存页。在这种情况下,物理内存页会在首次访问这些页面时通过缺页处理机制分配。
对于MCL_FUTURE,当新的内存被映射到进程地址空间时(例如通过malloc或mmap),这些新映射的页面也将自动被锁定,以防止它们被换出。但是,同样地,这并不会触发对这些页面的实际物理内存分配,直到它们被程序实际访问为止。
因此,虽然mlockall可以保证锁定的内存不会被交换到磁盘上,但它并不能保证所有的虚拟内存页在调用mlockall时都已经被分配了物理内存页。物理内存页的实际分配仍然遵循按需分页的原则,即在第一次访问某个页面时才会真正分配物理内存。 若要确保某些内存区域的物理页面已经被分配,你可能需要显式地访问这些页面的内容(例如,通过读写操作)来触发缺页处理和物理内存的分配。
2. 为什么需要mlockall()
在标准 Linux 中,默认采用按需分页(on-demand paging)机制。也就是说,程序请求内存后并不会立即分配物理页,而是等到第一次访问该内存区域时才触发一次页面错误(page fault),由内核动态分配物理页。
这种机制虽然节省了内存资源,但在实时系统中却存在严重问题:
- 页面错误会导致中断处理:发生 page fault 时,内核必须介入进行物理页分配,这会打断当前正在执行的线程。
- 切换执行模式:如果当前线程处于实时优先级(primary mode),则页面错误会强制将其降级为普通优先级(secondary mode),从而破坏实时性。
- 不可预测的延迟:在极端情况下(如内存不足且需要写回磁盘数据释放页),延迟可能达到毫秒级别。
因此,在 Xenomai 实时框架中,为了避免任何非预期的页面错误,通常会在初始化阶段调用mlockall(),一次性提交并锁定所有内存,从而消除 page fault 带来的不确定性。
在Xenomai应用程序启动时,cobalt_init()初始化过程会自动执行mlockall(MCL_CURRENT | MCL_FUTURE)。
//lib/cobalt/init.c cobalt_init() | |--> cobalt_init_1() | | | |--> cobalt_init_2() | | | |--> low_init() | | | |--> mlockall(MCL_CURRENT | MCL_FUTURE)3. mlockall 的副作用:线程栈大小问题
尽管mlockall()可以显著提高系统的确定性,但它也会带来一些副作用,尤其是在多线程环境中,最明显的就是线程栈大小的分配问题。
(1)默认栈大小的问题
在大多数 Linux 平台上,线程默认的栈大小为2MiB甚至更高。例如Ubuntu22.04/RHEL8.9的默认栈大小为8MiB。
ulimit-s8192这意味着,每当创建一个新线程时,系统都会立即为其分配 8MiB 的物理内存空间。在内存受限的嵌入式系统中,如果有大量线程同时运行,这可能会迅速耗尽可用内存。
更糟糕的是,由于mlockall()已经启用,这些栈空间会被立即提交并锁定在内存中,无法延迟分配,进一步加剧内存压力。
(2) Xenomai 的应对策略
为了解决这个问题,Xenomai 对线程栈进行了优化:
- 默认栈大小被减小:缩小到一个更合理的默认值,以减少内存占用。
PTHREAD_STACK_MIN 是定义在 POSIX 线程库(pthreads)中的一个宏,用于表示创建线程时允许的最小栈大小。这个值并不是指实际分配给每个新线程的栈大小,而是系统支持的最小安全栈大小,以确保程序能正常运行而不出现栈溢出等问题。
在 Linux 系统中,PTHREAD_STACK_MIN 的具体数值可能会根据不同的处理器架构有所不同,但通常它被设置为一个足够小的值,用来作为开发者设定自定义栈大小时的下限标准。例如,在适配ARM64的glibc 2.41上,它的默认值2个64KB的页面,即128KB。
Xenomai定义的宏PTHREAD_STACK_DEFAULT代表了默认栈大小,它取PTHREAD_STACK_MIN和64KB二者中的最大值。
#ifndefPTHREAD_STACK_DEFAULT#definePTHREAD_STACK_DEFAULT\({\int__ret=PTHREAD_STACK_MIN;\if(__ret<65536)\__ret=65536;\__ret;\})#endif/* !PTHREAD_STACK_DEFAULT */- 建议显式设置栈大小:对于确实需要更大栈空间的线程,应通过
pthread_attr_setstacksize()显式指定更大的栈大小。
示例代码:
pthread_attr_tattr;size_tstack_size=64*1024;// 64 KiBpthread_attr_init(&attr);pthread_attr_setstacksize(&attr,stack_size);pthread_create(&thread_id,&attr,thread_func,NULL);⚠️ 注意:某些标准库函数(如
printf)会在内部使用较多栈空间。如果将栈大小设得太小(如 4KiB),可能导致栈溢出并引发段错误(Segmentation Fault)。
4. 主线程的特殊处理
与普通线程不同,主线程(main thread)并不是通过pthread_create()创建的,因此不能使用pthread_attr_setstacksize()来修改其栈大小。此时,可以通过 shell 命令ulimit来调整主线程的栈限制。
例如,在启动程序前运行:
ulimit-s256# 将栈大小限制为 256 KiB./my_program此外,即使启用了mlockall(),主线程的栈仍可能在运行时增长(因为它是自动扩展的)。为了防止在关键实时路径上出现 page fault,建议在进入实时模式之前主动“预触碰”主线程栈,即通过访问栈上的变量或数组来强制分配物理页。
示例代码:
chardummy[64*1024];memset(dummy,0,sizeof(dummy));// 强制分配栈空间因此,在开发 Xenomai 应用程序时,开发者应在保证功能正确性的前提下,合理配置线程栈大小,结合mlockall()和预分配策略,最大化系统的实时性能与稳定性。
4.3.2 实时线程的调度策略
1. 实时线程的基本要求
原生Linux中,使用pthread_create创建的线程,支持以下几种调度策略:
- SCHED_FIFO:先进先出调度策略。线程一旦开始运行,除非被更高优先级的线程抢占,或者主动放弃 CPU(如调用
sched_yield()),否则将持续运行。优先级范围1~99。 - SCHED_RR:轮转调度策略,类似于 FIFO,但每个线程有一个时间片,时间片用完后会排到队列末尾等待下一轮执行。优先级范围等同于SCHED_FIFO,优先级范围1~99。
- SCHED_NORMAL:Linux 默认的分时调度策略,不适合实时任务。所有调度策略SCHED_NORMAL的线程,在
pthread_create后优先级为0。为了给这些线程在Linux内核中进行优先级排序,Linux通过nice值来重新调节优先级。在Linux系统中,SCHED_OTHER 和 SCHED_NORMAL 实际上指的是同一个调度策略。SCHED_OTHER 是POSIX标准中定义的名称,SCHED_NORMAL 是Linux内核内部使用的别名。
下表列出了Xenomai支持的调度策略及其调度类。相比于Linux中常用的SCHED_NORMAL,SCHED_FIFO和SCHED_RR,Xenomai自行定义了其特有的调度策略:
| Linux 调度策略 | Xenomai 调度策略 | Xenomai 调度类 | Xenomai 适应范围 |
|---|---|---|---|
| SCHED_NORMAL | SCHED_NORMAL | xnsched_class_weak | 弱实时调度类,优先级为 0 |
| SCHED_NORMAL | SCHED_NORMAL | xnsched_class_rt | 当没有打开 weak 调度类时,使用实时调度类,但是优先级强制为 0 |
| SCHED_FIFO | SCHED_FIFO | xnsched_class_rt | 实时调度类,优先级支持范围1~256,Linux 实际只传入 1~99 |
| SCHED_RR | SCHED_RR | xnsched_class_rt | 实时调度类,优先级支持范围1~256,Linux 实际只传入 1~99 |
| N/A | SCHED_IDLE | xnsched_class_idle | 用于空闲调度,优先级必须为 -1 |
| N/A | SCHED_COBALT | xnsched_class_rt | 实时调度类,优先级范围0~259 |
| N/A | SCHED_WEAK | xnsched_class_weak | 弱实时调度类,优先级范围 0~99 |
| N/A | SCHED_SPORADIC | xnsched_class_sporadic | 用于处理偶发任务,优先级范围 1~255 |
| N/A | SCHED_TP | xnsched_class_tp | 用于时间分区调度,优先级范围 1~255 |
| N/A | SCHED_QUOTA | xnsched_class_quota | 用于配额调度,优先级范围 1~255 |
注意,Xenomai 默认情况下只支持两种调度类:实时调度类(xnsched_class_rt)和 空闲调度类(xnsched_class_idle)。其它调度类需要通过编译选项开启,而且一般来说并不常用。
[*] Xenomai/cobalt ---> Core features ---> [*] Extra scheduling classes [ ] Weak scheduling class (NEW) [ ] Temporal partitioning (NEW) [ ] Sporadic scheduling (NEW) [ ] Thread groups with runtime quota (NEW)要使一个线程被 Xenomai 调度器识别为实时线程,必须使用SCHED_FIFO或SCHED_RR调度策略。
如果一个线程的调度策略被设置为SCHED_NORMAL,会被等同于SCHED_WEAK对待,对应的调度类为xnsched_class_weak,提供相对较弱的实时性保障。虽然在 Linux 中 SCHED_NORMAL 线程的优先级必须为 0,但在 Xenomai 中,SCHED_NORMAL 线程的优先级可以设置为 0~99。如果没有打开weak调度类,则 SCHED_NORMAL 线程的调度类为xnsched_class_rt,优先级强制为 0,且实时线程的状态被标记为XNWEAK。
Linux的调度策略到Xenomai的调度策略的映射关系,可以参考POSIX skin中的pthread_createAPI执行过程。
在用户层应用程序中,可以调用POSIX skin中的pthread_createAPI来创建Xenomai 实时线程。pthread_create的执行过程比较复杂,其中有一个环节,会执行sc_cobalt_thread_create系统调用,陷入内核层并执行Xenomai系统调用函数CoBaLt_thread_create。
CoBaLt_thread_create经过层层调用,会执行Cobalt内核实现的pthread_create函数。
// kernel/cobalt/posix/thread.cstaticintpthread_create(structcobalt_thread**thread_p,intpolicy,conststructsched_param_ex*param_ex,structtask_struct*task){...snip...sched_class=cobalt_sched_policy_param(¶m,policy,param_ex,&tslice);if(sched_class==NULL){xnfree(thread);return-EINVAL;}...snip...}在上述pthread_create的代码片段中,主要关注 cobalt_sched_policy_param 函数。
cobalt_sched_policy_param 函数的核心功能是将用户空间指定的调度策略(如 SCHED_FIFO、SCHED_RR 等)和调度参数,转换为内核空间使用的调度类和调度策略参数。它会根据不同的调度策略进行相应处理,验证优先级是否合法,最终返回对应的调度类指针。
2. 设置线程的调度属性
有两种方式可以设置线程的调度策略和参数:
- 方法一:创建线程前设置属性
使用pthread_attr_t属性对象,在调用pthread_create()之前设置调度策略和参数。
#include<pthread.h>#include<sched.h>void*thread_func(void*arg){// 实时线程逻辑returnNULL;}intmain(){pthread_tthread;pthread_attr_tattr;structsched_paramparam;// 初始化属性pthread_attr_init(&attr);// 设置继承调度策略为显式设置pthread_attr_setinheritsched(&attr,PTHREAD_EXPLICIT_SCHED);// 设置调度策略为 SCHED_FIFOpthread_attr_setschedpolicy(&attr,SCHED_FIFO);// 设置优先级(0 ~ 99)param.sched_priority=50;pthread_attr_setschedparam(&attr,¶m);// 创建实时线程pthread_create(&thread,&attr,thread_func,NULL);// 销毁属性对象pthread_attr_destroy(&attr);// 等待线程结束pthread_join(thread,NULL);return0;}- 方法二:修改已有线程的调度参数
如果你希望修改主线程或其他已存在的线程的调度属性,可以使用pthread_setschedparam():
structsched_paramparam;param.sched_priority=90;if(pthread_setschedparam(pthread_self(),SCHED_FIFO,¶m)!=0){perror("Failed to set real-time priority");}3. 使用SCHED_FIFO注意事项
⚠️注意:使用
SCHED_FIFO时,若线程进入死循环而没有让出 CPU,整个系统可能“冻结”。务必保证线程能定期释放 CPU,或设计合理的退出机制。
以下代码片段可能导致线程永远阻塞,尤其是在未正确初始化互斥量或条件变量时:
pthread_mutex_lock(&mutex);while(!cond)pthread_cond_wait(&cond,&mutex);// 如果 cond/mutex 未初始化,可能造成死锁pthread_mutex_unlock(&mutex);为了避免此类问题,请确保:
- 所有同步对象(mutex、cond)都正确初始化;
- 在销毁前确保没有线程正在等待;