把操作系统的进程、同步、死锁,放进 FreeRTOS 里看

操作系统课上的东西,很多都是干巴巴的文字:进程和线程的区别、PV 操作、死锁的四个必要条件。背的时候能背下来,但总觉得隔着一层——“到底什么时候会用到?”

直到我开始用 FreeRTOS 写四足机器人的固件,才发现这些东西根本不是课本里的抽象概念,而是每天都在代码里撞见的东西。这篇文章就把进程、同步互斥、死锁这三块,挨个搬到 FreeRTOS 里看一遍。

进程和线程,FreeRTOS 里只有一个 task

课本上最经典的一组概念:进程是资源分配的基本单位,有独立的地址空间;线程是调度的基本单位,共享所属进程的地址空间。

放到 FreeRTOS 里,这组概念会直接塌缩成一个东西:task

原因很简单,FreeRTOS 跑在裸机 MCU 上,没有 MMU、没有虚拟内存,整个系统就一个地址空间。每个 task 有自己的栈和 TCB(Task Control Block),但所有 task 共享同一块全局内存。所以:

  • 它像线程——独立执行流、独立栈、统一调度;
  • 但它不是进程——没有地址空间隔离,一个 task 写坏内存,可能整个系统一起崩。

换句话说,FreeRTOS 里根本不存在"进程"这个概念,task 就是最小、也是唯一的调度单位。

我写四足机器人时就是四个 task 在跑:

xTaskCreate(remote_ctrl_task, "remote", 512,  NULL, 4, NULL); // 遥控接收,优先级最高
xTaskCreate(est_task, "est", 1024, NULL, 3, NULL); // 状态估计
xTaskCreate(ctrl_task, "ctrl", 2048, NULL, 2, NULL); // 运动解算

这四个 task 共享全局的 IMU 数据、关节状态,但各有各的栈。这跟 Linux 里"多线程共享地址空间、各有各的栈"是同一套逻辑,只是 FreeRTOS 把"进程"这一层整个砍掉了。

理解了这一点,课本上那句"线程共享地址空间"就不再是空话了——你写的每一个 FreeRTOS 全局变量,都是在亲自践行这句话。

调度:抢占式加时间片轮转

说完 task 是什么,再说 FreeRTOS 怎么决定"先跑谁"。它的调度核心就两招,正好对应课本里的两个算法:

  • 抢占式(优先级调度):高优先级任务一旦就绪,立刻抢占低优先级任务。由 configUSE_PREEMPTION 控制,默认开。
  • 时间片轮转(RR):同优先级的多个任务,按时间片轮流跑,每个 tick 切一次。由 configUSE_TIME_SLICING 控制,默认开。

所以 FreeRTOS 的调度就是"优先级抢占 + 同优先级轮转"。课本里那堆 FCFS、SJF、多级反馈队列,它一个都没用——只认优先级,同优先级就轮流。

我四足机器人的四个任务,遥控优先级 4、状态估计 3、运动解算 2,靠的就是抢占:遥控数据一到(信号量唤醒),立刻抢过 CPU。

还有个隐藏角色:空闲任务(idle),优先级最低(0)。没有任务可跑时它就上场,低功耗模式下顺便进 WFI 让 CPU 休眠。

再往里看一层,调度器是怎么找"下一个该跑的任务"的?核心是两个数据结构:

  • 就绪链表 pxReadyTasksLists:每个优先级对应一个链表,就绪的任务挂在自己优先级的链表上。
  • 就绪位图 uxTopReadyPriority:一个位图,记录"哪个优先级还有就绪任务",调度器按位直接找到最高的那个,不用从高到低逐个扫。

每次 tick 中断(SysTick) 触发,调度器问一句:有没有比当前任务更高优先级的就绪了?有,就切;同优先级的,看时间片用完没,用完了轮到同优先级链表里的下一个。

优先级范围由 configMAX_PRIORITIES 定,默认 5(0 到 4)。别以为够用——我四足的任务优先级就用到 4 了,实际项目里经常要调大。

任务状态:四个状态

课本里进程有运行、就绪、阻塞(五态模型再加新建、终止)。FreeRTOS 的 task 有四个状态:

  • 运行态(Running):正在 CPU 上跑,单核同一时刻只有一个。
  • 就绪态(Ready):能跑,但 CPU 被更高优先级占着。
  • 阻塞态(Blocked):在等事件(vTaskDelay 延时、等信号量、等队列),这段时间不占 CPU。
  • 挂起态(Suspended):被 vTaskSuspend() 挂起,只能 vTaskResume() 唤醒,调度器彻底不管它。

对应关系几乎一比一:运行↔运行、就绪↔就绪、阻塞↔阻塞、挂起↔挂起。最值得记住的是阻塞不占 CPU:vTaskDelay 不是空转,是把 CPU 让给别人——这也是任务能"睡"的原因。

同步互斥,就是信号量和队列

课本里的 PV 操作、生产者-消费者、读者-写者,在 FreeRTOS 里有很直接的对应:

课本概念 FreeRTOS 对应
P 操作 xSemaphoreTake()
V 操作 xSemaphoreGive()
临界区 taskENTER_CRITICAL() / taskEXIT_CRITICAL()
生产者-消费者 消息队列 xQueueSend / xQueueReceive
互斥 互斥量 Mutex

说两个我在四足机器人里真实用到的场景。

第一个是中断和任务之间同步。遥控数据用 UART + DMA 双缓冲接收,收到一帧后在空闲中断里给个信号量,唤醒处理任务:

// UART 空闲中断里
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(rx_sem, &woken);
portYIELD_FROM_ISR(woken);

// 遥控处理任务里
if (xSemaphoreTake(rx_sem, portMAX_DELAY) == pdTRUE) {
// 解析刚收到的那帧数据
}

这就是课本里"信号量用于同步"的最直接落地——中断是生产者,任务是消费者。

第二个是运动解算任务和定时器中断之间传数据。100Hz 定时器中断负责把伺服指令发出去,但指令是运动解算任务算出来的,两者用队列衔接:

// 运动解算任务(生产者)
ServoCmd_t cmd = compute_control();
xQueueSend(servo_cmd_queue, &cmd, portMAX_DELAY);

// 定时器中断(消费者)
BaseType_t woken = pdFALSE;
ServoCmd_t cmd;
if (xQueueReceiveFromISR(servo_cmd_queue, &cmd, &woken) == pdTRUE) {
rs485_send_all(&cmd);
}

生产者-消费者模型,换了个名字叫"队列",但本质一模一样。到这里,PV 操作就不再是需要死记的概念了。

除了信号量和队列,FreeRTOS 还有两个进阶原语,也对应课本里的概念:

  • 事件标志组(Event Group):一个任务能同时等多个事件,还能指定"全部满足(AND)“还是"任意一个(OR)”。课本里的读者-写者、多条件同步,用它能写得更干净。
  • 任务通知(Task Notification):每个任务自带一个 32 位通知值,不用先创建信号量对象就能做轻量同步。xTaskNotifyGive / ulTaskNotifyTake 能顶替二值信号量,更快更省内存,代价是只能一对一通知。

说回队列本身。它内部就是一个环形缓冲,而且默认是按值拷贝的——数据会真正复制进队列,所以发送方改原数据不影响队列里的副本,代价是传大数据时开销大。想省拷贝,可以改成按引用传指针,但得自己管好指针指向的内存生命周期。

队列还能阻塞:队列满时发送方阻塞、空时接收方阻塞,都可以带超时。这其实就是课本里"生产者-消费者"的"有界缓冲"版——缓冲满了,生产者就得等。

死锁,在 FreeRTOS 里长成"优先级反转"

课本讲死锁,会先给四个必要条件:互斥、占有并等待、不可剥夺、循环等待,然后是银行家算法。

但在 FreeRTOS 里,你更可能先撞见的是它的亲戚——优先级反转。一个经典场景:

  1. 低优先级任务拿到了一个 Mutex;
  2. 高优先级任务也想拿这个 Mutex,只能阻塞等待;
  3. 这时中优先级任务就绪,抢占了低优先级任务;
  4. 结果高优先级任务被中优先级"卡住"了——它等的是低优先级手里的锁,而低优先级又跑不过中优先级。

高优先级任务反而被中优先级挡住,这就是优先级反转。

FreeRTOS 的 Mutex 自带优先级继承来解决它:当高优先级任务阻塞在 Mutex 上时,持有这个 Mutex 的低优先级任务会被临时提升到高优先级的水平,让它赶紧干完、释放锁,中优先级就没法插队了。

// 低优先级任务(比如传感器采集)
xSemaphoreTake(spi_mutex, portMAX_DELAY); // 拿锁,可能被临时提优先级
spi_read_imu(&data); // 读 SPI,别在里面磨蹭
xSemaphoreGive(spi_mutex); // 尽快释放

而真正的死锁,FreeRTOS 里也会发生——两个任务各持一个锁,互相等另一个:

// 任务 A:先拿 mutex1 再拿 mutex2
xSemaphoreTake(mutex1, portMAX_DELAY);
xSemaphoreTake(mutex2, portMAX_DELAY);

// 任务 B:先拿 mutex2 再拿 mutex1 ← 死锁
xSemaphoreTake(mutex2, portMAX_DELAY);
xSemaphoreTake(mutex1, portMAX_DELAY);

循环等待,四个必要条件凑齐。区别在于:课本里的银行家算法在嵌入式里几乎没人用——MCU 资源是编译期就定死的,没有"动态申请"这回事。实际做法就两条:

  1. 加锁顺序全局统一(所有任务都按 mutex1 → mutex2 的顺序拿);
  2. 带超时获取(xSemaphoreTake(mutex, pdMS_TO_TICKS(100)),拿不到就放弃、报错,别傻等)。

软件定时器

课本里讲"定时器/时钟管理",FreeRTOS 里对应的是软件定时器(Software Timer)。

注意它和硬件定时器是两回事:软件定时器不占用额外的硬件定时器,而是由一个专门的**定时器服务任务(daemon task)**统一管理。你创建定时器、设好周期,时间到了,回调函数是在这个 daemon 任务里执行的——不是在中断里

这意味着回调里可以用会阻塞的 API(中断里不行),但也要遵守"别磨蹭"的原则,不然会拖累其他定时器。定时器分一次性周期两种,适合做超时判断、周期采样这类"过一会儿干点啥"的活。

内存管理:heap_1 到 heap_5

FreeRTOS 的内存管理和 Linux 完全不是一个量级,它只解决"任务栈和内核对象从哪来"这一件事,但给你 5 种堆分配策略(编译期选一个):

  • heap_1:只分配、不释放,最简单,适合从来不删任务的场景。
  • heap_2:能释放,但不合并相邻空闲块(旧版,已不推荐)。
  • heap_3:包一层标准 malloc/free,但线程不安全,得自己加锁。
  • heap_4:能释放 + 合并相邻空闲块,最常用。
  • heap_5:heap_4 的基础上,支持堆分散在多个不连续内存区。

这其实就是课本"连续内存分配"的简化版:heap_4 的"合并相邻空闲块"就是空闲块合并的思想,只是没有分页、没有虚拟内存——裸机没 MMU,玩不了那套。

配套还有一个选择:创建任务时 xTaskCreate 是动态的(栈从堆里分),xTaskCreateStatic 是静态的(栈和 TCB 自己给,编译期定死)。嵌入式资源紧、要确定性,很多项目偏好静态。再加一个 configCHECK_FOR_STACK_OVERFLOW 做栈溢出检测——任务跑飞、数据莫名其妙,先查栈。

栈溢出是嵌入式最经典、也最难查的 bug,FreeRTOS 给了两种检测手段(configCHECK_FOR_STACK_OVERFLOW):

  • 方法 1:在任务栈顶放一个哨兵值,每次任务切换时检查它有没有被冲掉。开销小,但只能发现"已经溢出到栈顶"的情况。
  • 方法 2:创建任务时把栈初始化为一个固定模式(比如 0xA5),切换时从栈底往回数还剩多少没被改动。更准,能估算实际用了多少栈,但前提是栈确实被初始化过。

实战里方法 2 更适合"查还剩多少栈",方法 1 适合"兜底报警"。真遇到任务随机跑飞、变量被莫名改写,十有八九是栈溢出。

中断和临界区

最后补一块 FreeRTOS 特有、课本里没有的:中断里怎么和任务打交道。

  • 中断里只能用带 FromISR 后缀的 API(xSemaphoreGiveFromISRxQueueSendFromISR),而且不能阻塞——中断里没有"等"这回事。
  • configMAX_SYSCALL_INTERRUPT_PRIORITY 是个阈值:比它更"紧急"的中断(ARM 上数值越小优先级越高),绝不能用 FreeRTOS API,否则会破坏内核临界区;只有比它"低"的中断,才能用 FromISR 那套。
  • 延迟中断处理的思路:中断里只干最少的活(给个信号量、塞个队列),重活丢给任务。中断要短,这是铁律。

我四足里定时器中断发伺服、UART 空闲中断给信号量,就是这条思路的落地:中断只"通知",不"干活"。

写在最后

写完这篇,再回头看课本,很多概念突然就"活了":

  • 进程和线程,是地址空间问题,而 FreeRTOS 的 task 把它简化到了极致;
  • 调度,是优先级抢占加同优先级时间片轮转;
  • 进程状态,是运行、就绪、阻塞、挂起四态;
  • PV 操作、生产者-消费者,是信号量、队列、事件组;
  • 死锁和优先级反转,是加锁顺序和优先级继承;
  • 内存管理,是 heap_1 到 heap_5 的连续分配。

抽象概念记不住的时候,找一个真实系统对一遍,比反复背诵有用得多。FreeRTOS 就是那个能让操作系统的概念落地的真实系统。