3. 外设驱动开发模型
各位同学,咱们今天聊点实在的。ThreadX 的内核对象,说白了就是 RTOS 的「手脚」和「嘴巴」。线程是干活的人,消息队列是传话的信使,信号量和互斥量是管资源的保安。你如果连这几个东西的创建和销毁都搞不清楚,后面写驱动肯定要踩坑。
我个人习惯,在项目初期就把这些对象的生命周期画清楚。什么时候创建?谁来销毁?销毁后资源能不能彻底回收?这些问题想明白了,代码的稳定性至少提升一半。
3.1 线程(Thread)的创建与销毁
线程是 RTOS 里最基本的执行单元。ThreadX 里每个线程都有自己的栈空间、优先级和时间片。
3.1.1 创建线程:tx_thread_create
这个函数的原型长这样:
UINT tx_thread_create(
TX_THREAD *thread_ptr, // 线程控制块指针
CHAR *name_ptr, // 线程名称(调试用)
VOID (*entry_function)(ULONG), // 入口函数
ULONG entry_input, // 入口参数
VOID *stack_start, // 栈起始地址
ULONG stack_size, // 栈大小(字节)
UINT priority, // 优先级(0-31,0最高)
UINT preempt_threshold, // 抢占阈值
ULONG time_slice, // 时间片(时钟滴答数)
UINT auto_start // 是否自动启动
);
嗯,这里要注意几个关键点:
- 栈大小:别抠门。我见过有人给 64 字节,结果一调用函数就栈溢出。建议至少 512 字节起步,复杂任务给 1024 以上。
- 优先级:0 最高,31 最低。驱动任务一般给高优先级,比如 5-10。别把两个任务设成同一个优先级,除非你明确要用时间片轮转。
- 抢占阈值:这个参数很多人忽略。它限制了任务被抢占的优先级范围。比如你设优先级 10,抢占阈值 5,那只有优先级 0-5 的任务能抢你。这在临界区优化时特别好用。
preempt_threshold 设成和 priority 一样,也就是允许所有更高优先级的任务抢占。等性能调优时再收紧这个阈值。
3.1.2 销毁线程:tx_thread_delete
销毁线程比创建更讲究。函数原型:
UINT tx_thread_delete(TX_THREAD *thread_ptr);
销毁前必须确保线程已经终止。怎么终止?有两种方式:
- 自然返回:线程函数执行完 return。
- 强制终止:调用
tx_thread_terminate或tx_thread_suspend。
TX_COMPLETED 或 TX_TERMINATED 状态才能删除。所以正确的流程是:先终止线程,等待它进入终止状态,再调用删除。
3.2 消息队列(Queue)的创建与销毁
消息队列是任务间通信的主力。驱动里经常用它把中断收到的数据发给处理任务。
3.2.1 创建队列:tx_queue_create
UINT tx_queue_create(
TX_QUEUE *queue_ptr, // 队列控制块
CHAR *name_ptr, // 队列名称
UINT message_size, // 每条消息的大小(字节)
VOID *queue_start, // 队列存储区起始地址
ULONG queue_size // 队列存储区总大小(字节)
);
这里有个容易搞混的地方:message_size 和 queue_size 的关系。队列能存放的消息数量 = queue_size / message_size。比如你设消息大小 4 字节,队列总大小 64 字节,那最多存 16 条消息。
| 参数 | 说明 | 常见值 |
|---|---|---|
| message_size | 每条消息的字节数 | 4、8、16、32 |
| queue_size | 队列存储区总大小 | 64、128、256、512 |
| 最大消息数 | queue_size / message_size | 16、32、64 |
3.2.2 销毁队列:tx_queue_delete
UINT tx_queue_delete(TX_QUEUE *queue_ptr);
销毁队列前,要确保没有任务在等待队列中的消息。如果有任务在 tx_queue_receive 上阻塞,删除队列会导致这些任务返回 TX_QUEUE_ERROR。我个人习惯在删除前先清空队列,或者确保所有消费者已经停止。
3.3 信号量(Semaphore)的创建与销毁
信号量分两种:计数信号量和二值信号量。ThreadX 里用同一个 API 创建,区别在于初始计数值。
3.3.1 创建信号量:tx_semaphore_create
UINT tx_semaphore_create(
TX_SEMAPHORE *semaphore_ptr, // 信号量控制块
CHAR *name_ptr, // 信号量名称
ULONG initial_count // 初始计数值
);
初始计数值决定了信号量的行为:
- initial_count = 0:二值信号量,初始不可用。常用于同步,比如中断通知任务。
- initial_count = 1:二值信号量,初始可用。常用于互斥访问(但不如互斥量专业)。
- initial_count > 1:计数信号量。比如管理 5 个缓冲区,初始给 5。
我记得有个项目,用信号量管理 DMA 描述符池。初始计数值设成描述符数量,每次分配一个就获取一次信号量,释放时给回。简单又高效。
3.3.2 销毁信号量:tx_semaphore_delete
UINT tx_semaphore_delete(TX_SEMAPHORE *semaphore_ptr);
销毁信号量时,如果有任务在等待,这些任务会收到 TX_SEMAPHORE_ERROR。你想想看,这可能导致任务永远等不到资源。所以销毁前最好确保没有任务在等待,或者你有明确的错误处理机制。
3.4 互斥量(Mutex)的创建与销毁
互斥量和信号量最大的区别是:互斥量有优先级继承机制,能防止优先级反转。这在驱动开发里太重要了。
3.4.1 创建互斥量:tx_mutex_create
UINT tx_mutex_create(
TX_MUTEX *mutex_ptr, // 互斥量控制块
CHAR *name_ptr, // 互斥量名称
UINT inherit // 是否启用优先级继承
);
inherit 参数:
TX_INHERIT:启用优先级继承。推荐使用。TX_NO_INHERIT:不启用。省一点性能,但可能出优先级反转。
tx_mutex_get。互斥量不能在中断里使用,因为它的优先级继承机制依赖任务调度。中断里只能用信号量。那次排查了整整一天,最后发现是新手同事把互斥量当信号量用了。
3.4.2 销毁互斥量:tx_mutex_delete
UINT tx_mutex_delete(TX_MUTEX *mutex_ptr);
销毁互斥量的规则和信号量类似。但有一点不同:互斥量可以被同一个任务多次获取(递归持有)。销毁前要确保持有者已经释放了所有层级的锁。
3.5 内核对象生命周期管理
说了这么多创建和销毁,咱们来张图总结一下:
这张图把四个内核对象的生命周期串起来了。你仔细看,三个阶段——创建、使用、销毁——每个阶段都有各自的注意事项。
3.6 常见错误与调试技巧
最后,分享几个我实际项目中遇到的典型问题:
| 错误类型 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 栈溢出 | 任务跑飞、异常复位 | 线程栈太小 | 用 tx_thread_stack_error_notify 注册栈溢出回调 |
| 队列满 | 发送失败,数据丢失 | 队列容量不足或消费者处理慢 | 增大队列或优化消费者处理速度 |
| 死锁 | 两个任务互相等待对方释放资源 | 互斥量获取顺序不一致 | 固定获取顺序,或使用 tx_mutex_get 超时 |
| 优先级反转 | 高优先级任务被低优先级任务阻塞 | 互斥量未启用优先级继承 | 创建时使用 TX_INHERIT |
tx_thread_info_get、tx_queue_info_get 等 API,可以在运行时查看内核对象的状态。我习惯在调试串口里加一个命令,输入 "info" 就打印所有线程的状态、栈使用率和队列占用情况。这比猜问题快多了。
好了,内核对象的创建和销毁就讲到这里。这些 API 看着简单,但用好了能让你的系统稳如磐石。下次咱们聊中断管理,看看怎么把中断和这些内核对象结合起来。