4、任务与线程概念:进程与线程的区别、MTK平台下的任务模型、任务控制块(TCB)结构

好,咱们今天聊聊任务和线程。说实话,这两个概念在嵌入式开发里太基础了,但也是很多人容易搞混的地方。我记得刚入行那会儿,面试官问我「进程和线程有什么区别」,我背了一堆教科书上的答案,结果人家一句「那在MTK平台上怎么体现?」就把我问住了。

嗯,今天咱们就把这事儿彻底说透。

4.1 进程与线程:别再傻傻分不清

先说说最核心的区别。你想想看,进程是什么?进程是资源分配的最小单位。线程呢?线程是CPU调度的最小单位。

说白了,进程就像一家公司,有自己的办公室、资金、人员(资源)。线程就像公司里的员工,干活的。公司可以有好几个员工,共享办公室和资金。但公司倒闭了,员工也就没了。

在嵌入式系统里,尤其是MTK这种RTOS环境下,我们其实很少谈「进程」这个概念。为什么?因为MTK平台没有完整的MMU(内存管理单元),不支持虚拟内存。所以严格来说,MTK上跑的是「任务」,而不是进程。

核心区别一览:

对比项 进程 线程
资源拥有 独立地址空间 共享进程资源
切换开销 大(需要切换页表) 小(只需切换栈和寄存器)
通信方式 IPC(管道、消息队列等) 直接读写共享内存
独立性 一个崩溃不影响其他 一个崩溃可能导致整个进程挂掉
MTK平台 基本不用 任务(Task)就是线程

我在项目中遇到过一件事:有个同事在MTK平台上用Linux的思路写代码,创建了好几个「进程」来隔离任务。结果发现内存不够用,而且任务间通信搞得特别复杂。后来我告诉他,在MTK上你就老老实实用任务(线程),共享内存加信号量,简单又高效。

4.2 MTK平台下的任务模型

MTK的任务模型,说白了就是一套轻量级的线程实现。它基于Nucleus RTOS(或者FreeRTOS,看具体版本),每个任务都有自己的栈空间、优先级和状态。

任务的状态有几种:

  • 就绪(Ready):任务准备好了,就等CPU来调度
  • 运行(Running):正在占用CPU
  • 阻塞(Blocked):在等某个事件或资源
  • 挂起(Suspended):被人为暂停了
  • 终止(Terminated):任务结束了

你想想看,MTK平台上一个典型的手机系统,同时会有多少个任务在跑?我告诉你,少说三四十个。有处理触摸屏的、有管理音频的、有处理网络协议的、有更新UI的……这些任务都在抢CPU时间片。

我的经验:任务数量不是越多越好。我曾经在一个项目里创建了60多个任务,结果系统响应慢得像蜗牛。后来精简到30个左右,性能反而上来了。记住,每个任务都要消耗栈空间,栈空间就是内存,内存是有限的。

4.3 任务控制块(TCB)结构

TCB,全称Task Control Block。它是操作系统中最重要的数据结构之一。每个任务都有一个TCB,操作系统就是靠TCB来管理和调度任务的。

TCB里都存了啥?我直接给你看代码,这是MTK平台上典型的TCB结构:

typedef struct {
    UINT32  task_id;          // 任务ID,唯一标识
    UINT8   *stack_ptr;       // 栈指针
    UINT32  stack_size;       // 栈大小
    UINT8   priority;         // 优先级(0最高,255最低)
    UINT8   state;            // 任务状态
    UINT32  *registers;       // 寄存器保存区
    void    (*entry)(void);   // 任务入口函数
    char    name[16];         // 任务名称
    UINT32  delay_ticks;      // 延时计数
    void    *msg_queue;       // 消息队列指针
    UINT32  flags;            // 任务标志位
} TCB;

嗯,这里要注意几个关键字段:

  • task_id:每个任务唯一的身份证号。我习惯从0开始编号,方便调试。
  • stack_ptr和stack_size:栈空间。栈溢出是嵌入式开发最常见的坑之一。我曾经调试过一个bug,系统运行几个小时就死机,查了三天才发现是一个任务的栈设小了,导致栈溢出把隔壁任务的TCB给覆盖了。
  • priority:优先级。MTK支持抢占式调度,高优先级任务可以打断低优先级任务。但要注意优先级反转的问题。
  • registers:任务切换时,CPU寄存器的值就保存在这里。这是任务能够「断点续传」的关键。

避坑指南:我曾经在调试一个音频卡顿的问题时,发现某个任务的栈空间只有256字节,但实际运行需要512字节。结果就是任务跑着跑着就崩了,而且崩得毫无规律。后来我把栈大小改成1024字节,问题就解决了。所以,栈空间宁大勿小,但也不能太大,否则内存浪费。

4.4 任务创建与调度流程

在MTK平台上,创建一个任务很简单,大概就这几步:

  1. 分配TCB内存
  2. 分配栈空间
  3. 初始化TCB各字段(优先级、入口函数、任务名等)
  4. 将任务加入就绪队列
  5. 调度器开始工作

代码示例:

// 创建一个任务
TCB *task_create(void (*entry)(void), UINT8 priority, 
                 UINT32 stack_size, char *name) {
    TCB *new_task = (TCB *)malloc(sizeof(TCB));
    if (new_task == NULL) return NULL;
    
    new_task->stack_ptr = (UINT8 *)malloc(stack_size);
    new_task->stack_size = stack_size;
    new_task->priority = priority;
    new_task->entry = entry;
    strncpy(new_task->name, name, 15);
    new_task->state = READY;
    
    // 初始化栈帧(模拟第一次运行时的寄存器状态)
    init_stack_frame(new_task);
    
    // 加入就绪队列
    ready_queue_add(new_task);
    
    return new_task;
}

调度器怎么工作的?说白了就是:每次时钟中断来了,调度器就看看当前运行的任务是不是优先级最高的。如果不是,就保存当前任务的寄存器,恢复新任务的寄存器,然后跳转执行。

这个过程叫「上下文切换」。在MTK平台上,一次上下文切换大概需要几微秒。嗯,这个时间很短,但如果任务切换太频繁,系统开销也会变大。

4.5 任务间通信

任务之间怎么交换数据?MTK提供了几种方式:

  • 消息队列:最常用。一个任务发消息,另一个任务收消息。异步通信,解耦好。
  • 信号量:用于同步和互斥。比如两个任务共享一个串口,就得用信号量保护。
  • 事件标志组:一个任务等多个事件,或者多个任务等一个事件。
  • 共享内存:直接读写全局变量。简单但要注意竞态条件。

我的建议:能用消息队列就别用共享内存。消息队列虽然慢一点,但安全。共享内存一旦出现竞态条件,bug极难复现。我有个项目就是因为共享内存没加锁,导致数据错乱,查了整整一周才找到原因。

4.6 总结

好了,咱们今天聊了不少。总结一下:

  • 进程和线程的区别,核心就是「资源分配」和「调度执行」的分离
  • MTK平台用的是任务模型,本质上就是线程
  • TCB是任务的核心数据结构,里面存了栈指针、优先级、状态等关键信息
  • 任务创建、调度、通信,是嵌入式多任务编程的三大基本功

下一章咱们会深入聊聊MTK的调度策略,包括优先级调度、时间片轮转,以及那个让人头疼的优先级反转问题。到时候我会分享一个我亲身经历的「血泪史」,保证让你印象深刻。

嗯,今天就到这儿。有问题随时交流。