diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2541\345\244\251\344\275\234\344\270\232test.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2541\345\244\251\344\275\234\344\270\232test.md" new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/README.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/README.md" new file mode 100644 index 0000000000000000000000000000000000000000..1a9fc3874ba7e728fd0eb7dee284c15865258983 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/README.md" @@ -0,0 +1,214 @@ +# 第二天作业:RT-Thread 多线程 LED 与按键控制 + +## Part 1:理论整理 + +### DAY2:RT-Thread 启动流程、内核基础与多线程编程 + +#### 1. STM32F407 启动流程与 RT-Thread 引导机制 + +##### 1.1 从上电复位到进入 `main()` 的调用链 + +STM32F407 上电或复位后,Cortex-M4 会从中断向量表取得初始主栈指针(MSP)和复位入口地址,然后执行 `Reset_Handler`。启动文件完成 C 运行环境准备后,将控制权交给 RT-Thread。 + +```text +上电/复位 + ↓ +Reset_Handler(启动文件) + ├─ 设置栈指针,初始化 .data 和 .bss + ├─ SystemInit():完成时钟等芯片基础配置 + └─ entry() + ↓ +rtthread_startup() + ├─ 关闭中断 + ├─ rt_hw_board_init():板级初始化 + │ └─ rt_components_board_init():执行 Board 自动初始化 + ├─ 初始化系统 Tick、调度器等内核设施 + ├─ rt_application_init():创建 Main 线程 + ├─ 创建 Idle 线程(及可选的软件定时器线程) + └─ rt_system_scheduler_start():启动调度器 + ↓ +Main 线程的 main_thread_entry() + ├─ rt_components_init():执行其余自动初始化 + └─ 用户 main() +``` + +因此,用户的 `main()` 不是复位后最早执行的 C 函数。它运行在 RT-Thread 创建的 Main 线程中,只有调度器启动并选中该线程后才会执行。 + +##### 1.2 `INIT_BOARD_EXPORT` 与 `INIT_APP_EXPORT` 的自动初始化原理 + +自动初始化的核心是:**编译期注册、链接期汇总、运行期按顺序调用**。 + +```c +static int led_init(void) +{ + /* 初始化 LED */ + return RT_EOK; +} +INIT_BOARD_EXPORT(led_init); +``` + +上述宏会把 `led_init` 的函数指针放入指定的初始化段中;例如 `INIT_BOARD_EXPORT` 对应 Board 阶段,`INIT_APP_EXPORT` 对应应用阶段。链接脚本将这些段保留并排成连续的函数指针表。 + +启动时,RT-Thread 分阶段遍历该表: + +```text +INIT_BOARD_EXPORT(fn) + → 注册到 Board 初始化段 + → rt_components_board_init() 调用 fn + +INIT_APP_EXPORT(fn) + → 注册到应用初始化段 + → Main 线程中的 rt_components_init() 调用 fn + → 用户 main() +``` + +这样,驱动和组件只需在自身文件中注册初始化函数,无需在 `main()` 中逐个手动调用;内核仍能保证板级初始化先于组件和应用初始化执行。 + +#### 2. 内核基础与对象模型 + +##### 2.1 对象容器与控制块 + +RT-Thread 使用 C 语言实现轻量的面向对象思想。线程、信号量、互斥量、事件、邮箱、消息队列和定时器等都属于内核对象;每个对象都有记录自身属性和状态的控制块。 + +不同类型的对象具有名称、类型、标志和链表节点等共同信息。内核为每类对象维护一个对象容器,将同类型对象组织成链表,以便统一查找、统计和管理。 + +```text +对象容器 + ├─ 线程对象:线程 1、线程 2、…… + ├─ 信号量对象:信号量 1、信号量 2、…… + ├─ 互斥量对象:互斥量 1、互斥量 2、…… + └─ 其他对象:事件、邮箱、消息队列、定时器等 +``` + +线程控制块 `struct rt_thread` 保存线程的栈、入口函数、状态与调度信息,是调度器管理线程的依据。常用关键成员如下: + +| 成员 | 作用 | +| --- | --- | +| `stat` | 线程当前状态,如初始、就绪、运行、挂起、关闭。 | +| `current_priority` | 当前调度优先级;数值越小,优先级越高。 | +| `init_tick` | 线程初始时间片;同优先级线程轮转时决定一次可运行的 Tick 数。 | +| `sp` | 当前栈指针;线程切换时据此保存或恢复寄存器现场。 | +| `entry`、`parameter` | 线程入口函数及其参数。 | +| `stack_addr`、`stack_size` | 线程栈的地址和大小。 | + +##### 2.2 静态对象与动态对象 + +以线程为例,RT-Thread 支持静态和动态两种创建方式: + +| 对比项 | 静态线程 | 动态线程 | +| --- | --- | --- | +| 创建 API | `rt_thread_init()` | `rt_thread_create()` | +| 控制块与栈 | 用户预先提供,通常位于 `.bss` 或 `.data` | 内核从堆中运行时申请 | +| 生命周期 | 一般伴随系统运行;分离后内核不释放用户内存 | 删除后由系统回收控制块和栈 | +| 风险 | 栈预留不足或栈过小 | 堆不足、内存碎片、遗漏释放 | +| 适用场景 | 核心控制、长期运行、资源固定的任务 | 按需创建、短生命周期或可动态启停的任务 | + +静态线程的地址和大小可在系统设计阶段确定,因此高可靠场景常强制使用: + +1. 可预先评估 RAM 占用,资源更可预测; +2. 不依赖运行时堆分配,避免关键任务因堆不足而创建失败; +3. 避免频繁申请和释放造成的内存碎片; +4. 启动时间和最坏执行时间更容易分析。 + +但静态线程仍需要合理估算栈大小,不能因此忽略栈溢出风险。 + +#### 3. 线程状态机与调度算法 + +##### 3.1 五态模型 + +| 状态 | 宏 | 含义 | +| --- | --- | --- | +| 初始态 | `RT_THREAD_INIT` | 线程已创建,但尚未进入就绪队列。 | +| 就绪态 | `RT_THREAD_READY` | 线程具备运行条件,等待调度器分配 CPU。 | +| 运行态 | `RT_THREAD_RUNNING` | 当前正在占用 CPU 执行。单核 MCU 同一时刻只有一个运行线程。 | +| 挂起态 | `RT_THREAD_SUSPEND` | 正在延时或等待信号量、消息等资源,暂时不能运行。 | +| 关闭态 | `RT_THREAD_CLOSE` | 线程已结束或被删除,不再参与调度。 | + +```text +创建:rt_thread_init() / rt_thread_create() + ↓ +初始态(INIT) + │ rt_thread_startup() + ↓ +就绪态(READY) ←──── 延时到期、资源可用、rt_thread_resume() ────┐ + │ 调度器选中 │ + ↓ │ +运行态(RUNNING) ── rt_thread_delay() / 等待 IPC / 自身挂起 ──→ 挂起态(SUSPEND) + │ + └─ 线程入口返回、rt_thread_delete() 或 rt_thread_detach() + ↓ + 关闭态(CLOSE) +``` + +运行线程被更高优先级线程抢占,或同优先级时间片耗尽时,会回到就绪态;调度器随后选择下一线程运行。 + +##### 3.2 抢占式调度 + +RT-Thread 采用基于优先级的抢占式调度:调度器总是选择**最高优先级(数值最小)**的就绪线程运行。 + +```text +低优先级线程 A 正在运行 + ↓ +高优先级线程 B 因信号量释放或消息到达而变为就绪态 + ↓ +调度器保存 A 的现场并切换到 B +``` + +因此,低优先级线程即使时间片尚未用完,也不能阻止更高优先级的就绪线程运行。B 延时、等待资源或结束后,A 才有机会继续执行。 + +##### 3.3 时间片轮转 + +时间片轮转仅用于**多个同优先级线程同时处于就绪态**的情况,不会影响高优先级线程优先运行的规则。 + +创建线程时传入的 `tick` 参数会写入 `init_tick`。每次系统 Tick 到来,当前线程的剩余时间片递减;时间片用尽后,调度器将它放到同优先级就绪队列尾部,切换到下一个同优先级线程。 + +```text +线程 A、B:优先级均为 10,时间片均为 5 Tick + +A 运行 5 Tick → B 运行 5 Tick → A 再运行 5 Tick → …… +``` + +若没有同优先级的竞争线程,时间片用尽后通常仍会继续运行当前线程;若有更高优先级线程就绪,仍优先运行高优先级线程。线程也可调用 `rt_thread_yield()` 主动让出 CPU,给同优先级线程运行机会。 + +## Part 2:实验结果分析 + +### 1. 硬件运行现象 + +`th_led1` 与 `th_led2` 分别每隔 500 ms 读取并反转对应引脚电平,因此两路 LED 都以约 500 ms 的间隔改变一次亮灭状态,完成一次完整的亮灭循环约为 1 s。由于两个工作线程以相同的优先级、时间片和延时周期启动,肉眼观察时两路 LED 的翻转节奏基本一致。 + +按下左侧按键后,监控线程在下一个 2 s 监控周期处理挂起请求,`th_led1` 停止运行,红色 LED 保持按键触发瞬间的当前状态,不再继续翻转;`LED_B` 所在的 `th_led2` 不受影响,仍然每 500 ms 翻转。再次按下按键后,`th_led1` 被恢复运行,红色 LED 继续以 500 ms 的间隔翻转。 + + +### 2. 现象与结论 + +串口启动后,`th_led1` 和 `th_led2` 分别输出 LED 翻转计数;`th_led1` 同时打印系统 tick。监控线程约每 2 秒输出一次状态和剩余栈空间。 + +第一次按键后,日志出现 `BTN_LEFT: th_led1 suspend requested`,随后 `th_led1` 停止计数而 `th_led2` 保持运行;第二次按键后出现 `BTN_LEFT: th_led1 resumed`,`th_led1` 从原计数继续输出,说明挂起和恢复功能正常。 + +`thread_dump` 中的 `suspend` 还可能表示线程正处于 `rt_thread_mdelay()` 延时状态,并不一定是按键造成的手动挂起。串口偶尔出现字符交错,是多个线程同时调用 `rt_kprintf()` 导致的 UART 输出交织,不影响线程调度结果。 + +### 3. FinSH 串口日志(节选) + +```text + \ | / +- RT - Thread Operating System + / | \ 4.1.1 build Aug 19 2026 16:25:07 +hello! +th_led2 count: 0 +th_led1 count: 0, tick: 4 +... +th_led1: suspend stack: 780/1024 bytes +th_led2: suspend stack: 788/1024 bytes +... +BTN_LEFT: th_led1 suspend requested +th_led1: suspend stack: 780/1024 bytes +th_led2 count: 16 +th_led2 count: 17 +th_led2 count: 18 +... +BTN_LEFT: th_led1 resumed +th_led1: ready stack: 780/1024 bytes +th_led2: ready stack: 788/1024 bytes +th_led1 count: 16, tick: 12053 +th_led2 count: 25 +``` diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/thread_study.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/thread_study.c" new file mode 100644 index 0000000000000000000000000000000000000000..ea486e3629d7e786b18fe701f87eadcf8782c92d --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2542\345\244\251\344\275\234\344\270\232/thread_study.c" @@ -0,0 +1,235 @@ +/* + * 第二天作业:多线程 LED 与按键控制 + */ +#include +#include +#include + +#define LED_R_PIN GET_PIN(F, 12) +#define LED_B_PIN GET_PIN(F, 11) +#define BTN_LEFT_PIN GET_PIN(C, 0) + +#define WORK_THREAD_PRIORITY 16 +#define MONITOR_THREAD_PRIORITY 10 +#define THREAD_STACK_SIZE 1024 +#define THREAD_TIMESLICE 5 +#define LED_PERIOD_MS 500 +#define MONITOR_PERIOD_MS 2000 +#define KEY_DEBOUNCE_MS 50 + +static rt_thread_t th_led1 = RT_NULL; +static rt_thread_t th_monitor = RT_NULL; +static volatile rt_bool_t led1_suspended = RT_FALSE; +static volatile rt_bool_t led1_suspend_requested = RT_FALSE; +static volatile rt_uint32_t led1_toggle_count; +static rt_tick_t last_key_tick; + +#ifdef rt_align +rt_align(RT_ALIGN_SIZE) +#else +ALIGN(RT_ALIGN_SIZE) +#endif +static rt_uint8_t th_led2_stack[THREAD_STACK_SIZE]; +static struct rt_thread th_led2; + +static void led_toggle(rt_base_t pin) +{ + rt_pin_write(pin, !rt_pin_read(pin)); +} + +static rt_size_t thread_stack_space(rt_thread_t thread) +{ + rt_uint8_t *stack = (rt_uint8_t *)thread->stack_addr; + rt_size_t space = 0; + + while ((space < thread->stack_size) && (stack[space] == '#')) + { + space++; + } + + return space; +} + +static const char *thread_state(rt_thread_t thread) +{ + switch (thread->stat & RT_THREAD_STAT_MASK) + { + case RT_THREAD_INIT: return "init"; + case RT_THREAD_READY: return "ready"; + case RT_THREAD_SUSPEND: return "suspend"; + case RT_THREAD_RUNNING: return "running"; + case RT_THREAD_CLOSE: return "close"; + default: return "unknown"; + } +} + +static void thread_dump(void) +{ + if (th_led1 != RT_NULL) + { + rt_kprintf("th_led1: %-7s stack: %u/%u bytes\n", + thread_state(th_led1), + (unsigned int)thread_stack_space(th_led1), + (unsigned int)th_led1->stack_size); + } + else + { + rt_kprintf("th_led1: not created\n"); + } + + if (th_led2.stack_addr != RT_NULL) + { + rt_kprintf("th_led2: %-7s stack: %u/%u bytes\n", + thread_state(&th_led2), + (unsigned int)thread_stack_space(&th_led2), + (unsigned int)th_led2.stack_size); + } + else + { + rt_kprintf("th_led2: not initialized\n"); + } +} +MSH_CMD_EXPORT(thread_dump, show LED thread state and free stack); + +static void led1_entry(void *parameter) +{ + rt_uint32_t count = 0; + + while (1) + { + if (led1_suspend_requested) + { + led1_suspend_requested = RT_FALSE; + led1_suspended = RT_TRUE; + rt_thread_suspend(rt_thread_self()); + rt_schedule(); + } + + led_toggle(LED_R_PIN); + rt_kprintf("th_led1 count: %u, tick: %u\n", + (unsigned int)count++, (unsigned int)rt_tick_get()); + rt_thread_mdelay(LED_PERIOD_MS); + } +} + +static void led2_entry(void *parameter) +{ + rt_uint32_t count = 0; + + while (1) + { + led_toggle(LED_B_PIN); + rt_kprintf("th_led2 count: %u\n", (unsigned int)count++); + rt_thread_mdelay(LED_PERIOD_MS); + } +} + +static void button_callback(void *parameter) +{ + rt_tick_t now = rt_tick_get(); + + if ((last_key_tick == 0) || + ((rt_tick_t)(now - last_key_tick) >= rt_tick_from_millisecond(KEY_DEBOUNCE_MS))) + { + last_key_tick = now; + led1_toggle_count++; + } +} + +static void monitor_entry(void *parameter) +{ + while (1) + { + rt_base_t level; + rt_uint32_t toggle_count; + + rt_thread_mdelay(MONITOR_PERIOD_MS); + + level = rt_hw_interrupt_disable(); + toggle_count = led1_toggle_count; + led1_toggle_count = 0; + rt_hw_interrupt_enable(level); + + if (toggle_count & 1U) + { + if (led1_suspended) + { + if (rt_thread_resume(th_led1) == RT_EOK) + { + led1_suspended = RT_FALSE; + rt_kprintf("BTN_LEFT: th_led1 resumed\n"); + } + } + else if (led1_suspend_requested) + { + led1_suspend_requested = RT_FALSE; + rt_kprintf("BTN_LEFT: th_led1 suspend cancelled\n"); + } + else + { + led1_suspend_requested = RT_TRUE; + rt_kprintf("BTN_LEFT: th_led1 suspend requested\n"); + } + } + + thread_dump(); + } +} + +static int thread_study(void) +{ + rt_err_t result; + + rt_pin_mode(LED_R_PIN, PIN_MODE_OUTPUT); + rt_pin_mode(LED_B_PIN, PIN_MODE_OUTPUT); + rt_pin_write(LED_R_PIN, PIN_LOW); + rt_pin_write(LED_B_PIN, PIN_LOW); + + rt_pin_mode(BTN_LEFT_PIN, PIN_MODE_INPUT_PULLUP); + result = rt_pin_attach_irq(BTN_LEFT_PIN, PIN_IRQ_MODE_FALLING, button_callback, RT_NULL); + if (result != RT_EOK) + { + return result; + } + + result = rt_pin_irq_enable(BTN_LEFT_PIN, PIN_IRQ_ENABLE); + if (result != RT_EOK) + { + return result; + } + + th_led1 = rt_thread_create("th_led1", led1_entry, RT_NULL, + THREAD_STACK_SIZE, WORK_THREAD_PRIORITY, THREAD_TIMESLICE); + if (th_led1 == RT_NULL) + { + return -RT_ENOMEM; + } + + th_monitor = rt_thread_create("th_monitor", monitor_entry, RT_NULL, + THREAD_STACK_SIZE, MONITOR_THREAD_PRIORITY, THREAD_TIMESLICE); + if (th_monitor == RT_NULL) + { + rt_thread_delete(th_led1); + th_led1 = RT_NULL; + return -RT_ENOMEM; + } + + result = rt_thread_init(&th_led2, "th_led2", led2_entry, RT_NULL, + th_led2_stack, sizeof(th_led2_stack), + WORK_THREAD_PRIORITY, THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_thread_delete(th_monitor); + th_monitor = RT_NULL; + rt_thread_delete(th_led1); + th_led1 = RT_NULL; + return result; + } + + rt_thread_startup(th_led1); + rt_thread_startup(&th_led2); + rt_thread_startup(th_monitor); + + return RT_EOK; +} +INIT_APP_EXPORT(thread_study); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/README.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/README.md" new file mode 100644 index 0000000000000000000000000000000000000000..02720b999a6de0462e4f168e160e33b09a31bd4b --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/README.md" @@ -0,0 +1,227 @@ +# 第三天作业:停车场车位管理 + +## 一、理论总结 + +### 1. 计数信号量 + +计数信号量用于管理数量有限、但可被多个线程同时使用的资源。信号量的计数值表示当前可用资源数: + +- `rt_sem_take()`:申请一个资源,成功后信号量计数减一;没有资源时线程等待。 +- `rt_sem_release()`:归还一个资源,信号量计数加一,并唤醒等待该资源的线程。 + +本实验将停车场的 3 个车位抽象为初始值为 3 的计数信号量。车辆只有成功获取信号量后才能进入,离开时释放信号量,因此任意时刻最多只能有 3 辆车停放。 + +### 2. 为什么使用计数信号量,而不是互斥量? + +互斥量只能表示“资源被一个线程独占”或“资源空闲”,同一时刻只允许一个线程获得它,适合保护共享变量、链表或打印接口等临界区。 + +停车场有 3 个彼此等价的车位,允许 3 辆车同时进入。计数信号量可以直接用计数值表示剩余车位数,并在车位耗尽时让后续车辆阻塞等待;互斥量只能让一辆车进入,无法表达“还剩 2 个车位”这一资源数量。因此,本实验使用计数信号量管理车位;互斥量仅用于保护日志输出和已用车位计数,避免多线程打印内容交错。 + +## 二、实验报告 + +### 1. 实现内容 + +- 创建初值为 3 的停车位计数信号量。 +- 创建 5 个车辆线程,按 `car1` 到 `car5` 的顺序申请车位。 +- 车辆在 2 秒内未获取车位时打印超时信息,随后重新申请。 +- 车辆停车 3 秒后离开并释放车位。 +- 检查线程初始化、线程启动以及信号量、互斥量创建结果。 +- 通过 MSH 命令 `parking_demo` 启动实验。 + +### 2. 实验现象 + +前三辆车进入后,3 个车位被占满;`car4` 和 `car5` 等待 2 秒后超时。随后车辆离开并释放车位,等待车辆依次进入。串口输出如下: + +```text +car1 entered, used spaces: 1 +car2 entered, used spaces: 2 +car3 entered, used spaces: 3 +car4 waiting for a space (timeout) +car5 waiting for a space (timeout) +car1 left, used spaces: 2 +car4 entered, used spaces: 3 +car2 left, used spaces: 2 +car3 left, used spaces: 1 +car5 entered, used spaces: 2 +car4 left, used spaces: 1 +car5 left, used spaces: 0 +``` + +### 3. 实验结论 + +实验中 `used spaces` 的取值始终不超过 3,说明计数信号量正确限制了可同时进入停车场的车辆数量。`car4` 和 `car5` 在没有空闲车位时能够超时等待并重试,车辆离开后车位被正确归还,最终已用车位数恢复为 0。 + +--- + +# 练习二:多窗口售票系统 + +## 一、理论总结 + +两个售票窗口共享同一个 `remaining_tickets` 变量。一次正确售票不是单独的减一操作,而是一个不可分割的过程:检查是否有票、取得当前票号、余票减一、记录并打印售票结果。多个线程并发执行时,必须用互斥量保护这个完整过程。 + +互斥量同一时刻只允许一个线程进入临界区。窗口 A 持有互斥量完成售票后,窗口 B 才能读取最新的余票数,因此每张票号只会被售出一次,余票也不会小于 0。 + +### 为什么只给 `ticket_count--` 加锁仍可能不安全? + +因为“检查余票”和“减票”属于同一个**检查后执行**操作,必须一起加锁。若只对 `ticket_count--` 加锁,可能发生以下时序: + +```text +初始余票:1 + +窗口 A:检查 ticket_count > 0,结果为真 +窗口 B:检查 ticket_count > 0,结果也为真 +窗口 A:加锁,ticket_count--,余票变为 0,解锁 +窗口 B:加锁,ticket_count--,余票变为 -1,解锁 +``` + +两个窗口在锁外读取到的都是过期数据。即使减一指令本身被保护,也无法保证“有票才出售”这个条件仍成立。此外,若票号读取或售票记录在锁外,也可能出现票号与余票不一致。因此,检查、取票号和减一都必须放在同一个互斥量保护的临界区中。 + +## 二、实验报告 + +### 1. 实现内容 + +- 使用全局变量保存初始为 20 的余票数。 +- 创建窗口 A、窗口 B 两个售票线程,并检查线程初始化和启动结果。 +- `ticket_demo_nolock` 在读取余票和修改余票之间延时,用于观察竞争问题。 +- `ticket_demo` 创建并检查互斥量,在完整售票过程内加锁。 +- 两个窗口售完票后自动退出,并输出剩余票数。 + +### 2. 实验结果 + +执行 `ticket_demo`(有互斥量)后,窗口 A、B 交替出售 20 到 1 的车票;每个票号只出现一次。串口日志节选如下: + +```text +window B sold ticket 20 +window A sold ticket 19 +window B sold ticket 18 +... +window B sold ticket 2 +window A sold ticket 1 +all tickets sold +remaining tickets: 0 +``` + +执行 `ticket_demo_nolock`(无互斥量)后,两个窗口会同时出售相同票号。日志节选如下: + +```text +window B sold ticket 20 +window A sold ticket 20 +window B sold ticket 19 +window A sold ticket 19 +... +window B sold ticket 1 +window A sold ticket 1 +no-lock experiment finished +remaining tickets: 0 +``` + +无互斥量时,部分字符还可能交错,这是多个线程同时输出串口日志造成的。虽然最后余票也可能显示为 0,但同一票号已被重复出售,说明共享余票的读、改、写过程存在竞争。 + +### 3. 实验结论 + +无互斥量时,两个窗口会读到相同的余票值,导致重复售票和丢失更新;有互斥量时,完整售票过程串行执行,20 张票全部且仅售出一次,最终余票为 0。 + +--- + +# 练习三:系统启动条件检查 + +## 一、理论总结 + +事件集使用一个整数的不同位表示不同事件。本实验使用网络、传感器和存储三个事件位;每个初始化线程完成后发送自己的事件位。业务线程使用 `RT_EVENT_FLAG_AND` 等待三个位同时满足,只有全部模块就绪才启动业务。 + +事件集适合表达“多个条件的组合”。同一个事件对象可以通过位或、位与选择 OR 或 AND 等待方式,也可以一次性设置超时时间。 + +### 如果用三个独立信号量实现这个功能,与使用事件集相比有什么区别? + +三个独立信号量的实现方式是:网络、传感器、存储线程分别释放各自的信号量,业务线程依次获取三个信号量。它也能实现“全部模块准备完成后再启动”,但与事件集相比有以下区别: + +| 对比项 | 三个独立信号量 | 一个事件集 | +| --- | --- | --- | +| IPC 对象数量 | 需要 3 个信号量 | 只需 1 个事件对象和 3 个事件位 | +| 条件表达 | 业务线程需要分别等待 3 次 | 一次 AND 等待即可表达“全部完成” | +| 超时处理 | 需要自行处理已获取、未获取的多个信号量 | 一次等待即可超时,并可根据事件位判断未完成模块 | +| 重复通知 | 信号量计数会累加,可记录多次发生的通知 | 同一事件位重复发送仍只是“已置位”,不记录次数 | +| 适用场景 | 需要统计通知次数或管理资源数量 | 多个状态条件的组合、就绪标志和状态同步 | + +因此,本实验中每个模块只需要表达“是否已经就绪”这一状态,不需要统计就绪通知发生了几次,使用事件集更直接、代码更少。若业务逻辑要求处理每一次独立通知,例如每次传感器采样完成都要处理一次,则信号量更合适。 + +## 二、实验报告 + +### 1. 实现内容 + +- 定义网络、传感器、存储三个就绪事件位。 +- 创建网络初始化、传感器初始化、存储初始化和业务线程,共 4 个线程。 +- 网络、传感器、存储分别延时 1 秒、2 秒、5 秒后发送就绪事件。 +- 业务线程先以 AND 模式等待全部事件 3 秒;超时后打印未就绪的存储模块,再永久等待全部事件。 +- 检查事件对象初始化、线程初始化和线程启动结果。 +- 通过 MSH 命令 `startup_demo` 启动实验。 + +### 2. 实验结果 + +串口日志如下: + +```text +network ready +sensor ready +wait modules timeout, storage is not ready +storage ready +all modules are ready +business task started +``` + +网络和传感器在 3 秒等待时间内完成;存储模块需要 5 秒,因此业务线程第一次等待超时。存储模块就绪并发送最后一个事件位后,AND 条件成立,业务线程开始执行。 + +### 3. 实验结论 + +事件集能将多个模块的就绪状态集中在一个对象中管理。AND 等待保证业务线程不会在任意模块缺失时提前启动;超时后仍保留已经到达的事件位,因此可以继续等待剩余模块,无需重复等待网络和传感器。 + +--- + +# 练习四:快递分拣中心 + +## 一、理论总结 + +消息队列用于在线程之间传递结构化数据。发送线程将完整的消息副本放入队列,接收线程从队列取出消息后再处理,因此发送方和接收方不需要同时运行。本实验的消息包含快递编号、目的地区域和重量。 + +消息队列容量有限。本实验容量为 5 条,两个发件线程发送速度高于分拣线程的处理速度时,队列会暂时写满。此时 `rt_mq_send()` 返回失败;发送线程记录失败次数、延时后重试同一条消息,而不是退出或丢弃快递。 + +两个发件线程都完成 5 条消息发送后,最后完成的线程向队列发送一条编号为 0 的结束消息。由于消息队列按发送顺序接收,分拣线程收到结束消息时,前面的 10 条快递消息已经全部处理完成,可以安全输出统计结果并退出。 + +## 二、实验报告 + +### 1. 实现内容 + +- 创建容量为 5 条消息的静态消息队列。 +- 创建两个发件线程,每个线程发送 5 条唯一编号的快递。 +- 创建一个分拣线程,按华东、华南、华北分类统计快递数量与总重量。 +- 队列满时记录发送失败次数并重试,保证 10 条快递不丢失。 +- 最后完成的发件线程发送结束消息,分拣线程收到后打印统计并退出。 +- 检查消息队列、分拣线程和两个发件线程的初始化、启动结果。 +- 通过 MSH 命令 `package_demo` 启动实验;运行期间再次输入该命令会被拒绝,任务结束后消息队列会分离,可再次执行。 + +### 2. 实验结果 + +串口完整日志如下: + +```text +package 2001 -> north, weight: 1300 g +package 1001 -> east, weight: 1200 g +package 2002 -> east, weight: 750 g +package 1002 -> south, weight: 850 g +package 2003 -> south, weight: 1050 g +package 1003 -> north, weight: 960 g +package 1004 -> east, weight: 1100 g +package 2004 -> north, weight: 1100 g +package 1005 -> south, weight: 900 g +package 2005 -> east, weight: 1040 g +total packages: 10 +east: 4, south: 3, north: 3 +total weight: 10250 g +send failed: 23 +``` + +两个发件线程的消息到达顺序由调度决定,因此快递编号不一定严格递增;但每个编号只出现一次。`send failed: 23` 表示队列满时共发生 23 次发送失败,发送线程均已重试成功,最终仍完成了全部 10 条快递的分拣。 + +### 3. 实验结论 + +消息队列将快递生成与分拣处理解耦。满队列时使用“失败计数 + 重试”可以避免崩溃和消息丢失;结束消息则让分拣线程能够准确判断全部工作完成。最终统计数量、区域分布和总重量均与预设数据一致。 diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/package_demo_sample.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/package_demo_sample.c" new file mode 100644 index 0000000000000000000000000000000000000000..b737917b9aae88b9759dae856073e2bacadd8b54 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/package_demo_sample.c" @@ -0,0 +1,257 @@ +/* + * 程序清单:快递分拣中心 + * + * 两个发件线程向容量为 5 的消息队列发送快递,分拣线程按区域统计。 + */ +#include + +#define PACKAGE_QUEUE_CAPACITY 5 +#define SENDER_COUNT 2 +#define PACKAGE_PER_SENDER 5 +#define SENDER_THREAD_PRIORITY 20 +#define SORTER_THREAD_PRIORITY 19 +#define THREAD_TIMESLICE 5 +#define PACKAGE_POOL_SIZE (PACKAGE_QUEUE_CAPACITY * \ + (RT_ALIGN(sizeof(struct package_msg), RT_ALIGN_SIZE) + sizeof(void *))) + +#define REGION_EAST 1 +#define REGION_SOUTH 2 +#define REGION_NORTH 3 + +struct package_msg +{ + rt_uint32_t id; + rt_uint8_t region; + rt_uint16_t weight; +}; + +struct package_sender +{ + const char *thread_name; + rt_uint32_t first_id; + rt_uint8_t regions[PACKAGE_PER_SENDER]; + rt_uint16_t weights[PACKAGE_PER_SENDER]; + struct rt_thread thread; + char stack[1024]; +}; + +static struct rt_messagequeue package_mq; +#ifdef rt_align +rt_align(RT_ALIGN_SIZE) +#else +ALIGN(RT_ALIGN_SIZE) +#endif +static rt_uint8_t package_pool[PACKAGE_POOL_SIZE]; +static rt_uint32_t send_failed_count; +static rt_uint8_t senders_finished; +static rt_bool_t package_demo_running; +static struct rt_thread sorter_thread; +static char sorter_stack[1024]; +static struct package_sender senders[SENDER_COUNT] = +{ + {"senderA", 1001, {REGION_EAST, REGION_SOUTH, REGION_NORTH, REGION_EAST, REGION_SOUTH}, + {1200, 850, 960, 1100, 900}}, + {"senderB", 2001, {REGION_NORTH, REGION_EAST, REGION_SOUTH, REGION_NORTH, REGION_EAST}, + {1300, 750, 1050, 1100, 1040}} +}; + +static const char *region_name(rt_uint8_t region) +{ + if (region == REGION_EAST) + { + return "east"; + } + if (region == REGION_SOUTH) + { + return "south"; + } + return "north"; +} + +static void package_demo_cleanup(void) +{ + rt_mq_detach(&package_mq); + package_demo_running = RT_FALSE; +} + +static void record_send_failure(void) +{ + rt_enter_critical(); + send_failed_count++; + rt_exit_critical(); +} + +static void send_message_retry(const struct package_msg *message) +{ + while (rt_mq_send(&package_mq, message, sizeof(*message)) != RT_EOK) + { + /* 队列已满时保留失败次数,并稍后重试同一条快递。 */ + record_send_failure(); + rt_thread_mdelay(5); + } +} + +static void sender_entry(void *parameter) +{ + struct package_sender *sender = parameter; + struct package_msg message; + rt_uint8_t i; + rt_bool_t is_last_sender = RT_FALSE; + + for (i = 0; i < PACKAGE_PER_SENDER; i++) + { + message.id = sender->first_id + i; + message.region = sender->regions[i]; + message.weight = sender->weights[i]; + send_message_retry(&message); + rt_thread_mdelay(5); + } + + rt_enter_critical(); + if (++senders_finished == SENDER_COUNT) + { + is_last_sender = RT_TRUE; + } + rt_exit_critical(); + + if (is_last_sender) + { + /* 结束消息排在全部快递消息之后,分拣线程收到它即可退出。 */ + message.id = 0; + message.region = 0; + message.weight = 0; + send_message_retry(&message); + } +} + +static void sorter_entry(void *parameter) +{ + struct package_msg message; + rt_uint32_t total_packages = 0; + rt_uint32_t region_count[4] = {0}; + rt_uint32_t total_weight = 0; + rt_err_t result; + + while (1) + { + result = rt_mq_recv(&package_mq, &message, sizeof(message), RT_WAITING_FOREVER); + if (result != RT_EOK) + { + rt_kprintf("receive package failed: %d\n", result); + package_demo_cleanup(); + return; + } + if (message.id == 0) + { + break; + } + + total_packages++; + region_count[message.region]++; + total_weight += message.weight; + rt_kprintf("package %d -> %s, weight: %d g\n", + message.id, region_name(message.region), message.weight); + + /* 分拣耗时,便于验证队列满时发送线程会记录失败并重试。 */ + rt_thread_mdelay(20); + } + + rt_kprintf("total packages: %d\n", total_packages); + rt_kprintf("east: %d, south: %d, north: %d\n", + region_count[REGION_EAST], region_count[REGION_SOUTH], region_count[REGION_NORTH]); + rt_kprintf("total weight: %d g\n", total_weight); + rt_kprintf("send failed: %d\n", send_failed_count); + package_demo_cleanup(); +} + +int package_demo(void) +{ + rt_uint8_t i; + rt_err_t result; + + if (package_demo_running) + { + rt_kprintf("package demo is already running.\n"); + return -RT_EBUSY; + } + + result = rt_mq_init(&package_mq, + "package", + package_pool, + sizeof(struct package_msg), + sizeof(package_pool), + RT_IPC_FLAG_PRIO); + if (result != RT_EOK) + { + rt_kprintf("create package queue failed: %d\n", result); + return result; + } + + package_demo_running = RT_TRUE; + send_failed_count = 0; + senders_finished = 0; + + result = rt_thread_init(&sorter_thread, + "sorter", + sorter_entry, + RT_NULL, + sorter_stack, + sizeof(sorter_stack), + SORTER_THREAD_PRIORITY, + THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_kprintf("create sorter thread failed: %d\n", result); + package_demo_cleanup(); + return result; + } + + for (i = 0; i < SENDER_COUNT; i++) + { + result = rt_thread_init(&senders[i].thread, + senders[i].thread_name, + sender_entry, + &senders[i], + senders[i].stack, + sizeof(senders[i].stack), + SENDER_THREAD_PRIORITY, + THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_kprintf("create %s thread failed: %d\n", senders[i].thread_name, result); + while (i > 0) + { + rt_thread_detach(&senders[--i].thread); + } + rt_thread_detach(&sorter_thread); + package_demo_cleanup(); + return result; + } + } + + result = rt_thread_startup(&sorter_thread); + if (result != RT_EOK) + { + rt_kprintf("start sorter thread failed: %d\n", result); + for (i = 0; i < SENDER_COUNT; i++) + { + rt_thread_detach(&senders[i].thread); + } + rt_thread_detach(&sorter_thread); + package_demo_cleanup(); + return result; + } + + for (i = 0; i < SENDER_COUNT; i++) + { + result = rt_thread_startup(&senders[i].thread); + if (result != RT_EOK) + { + rt_kprintf("start %s thread failed: %d\n", senders[i].thread_name, result); + return result; + } + } + + return RT_EOK; +} +MSH_CMD_EXPORT(package_demo, package message queue demo); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/parking_lot_sample.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/parking_lot_sample.c" new file mode 100644 index 0000000000000000000000000000000000000000..4ad37cd696ed74d0956380a2c04e1b06feb7d430 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/parking_lot_sample.c" @@ -0,0 +1,146 @@ +/* + * 程序清单:停车场车位管理 + * + * 3 个车位由一个初始值为 3 的计数信号量表示;5 辆车申请车位, + * 未在 2 秒内取得车位时打印超时信息,并在短暂等待后重新申请。 + */ +#include + +#define PARKING_SPACE_COUNT 3 +#define CAR_COUNT 5 +#define CAR1_THREAD_PRIORITY 20 +#define CAR_THREAD_TIMESLICE 5 +#define CAR_PARK_TIME_MS 3000 +#define CAR_WAIT_TIMEOUT_MS 2000 + +struct car +{ + rt_uint8_t id; + struct rt_thread thread; + char stack[1024]; +}; + +static rt_sem_t parking_spaces = RT_NULL; +static rt_mutex_t parking_lock = RT_NULL; +static rt_uint8_t used_spaces; +static rt_uint8_t cars_finished; +static rt_uint8_t cars_started; +static struct car cars[CAR_COUNT]; +static const char * const car_names[CAR_COUNT] = +{ + "car1", "car2", "car3", "car4", "car5" +}; + +static void car_entry(void *parameter) +{ + struct car *car = parameter; + + while (rt_sem_take(parking_spaces, + rt_tick_from_millisecond(CAR_WAIT_TIMEOUT_MS)) != RT_EOK) + { + rt_mutex_take(parking_lock, RT_WAITING_FOREVER); + rt_kprintf("car%d waiting for a space (timeout)\n", car->id); + rt_mutex_release(parking_lock); + rt_thread_mdelay(100); + } + + rt_mutex_take(parking_lock, RT_WAITING_FOREVER); + used_spaces++; + rt_kprintf("car%d entered, used spaces: %d\n", car->id, used_spaces); + rt_mutex_release(parking_lock); + + rt_thread_mdelay(CAR_PARK_TIME_MS); + + rt_mutex_take(parking_lock, RT_WAITING_FOREVER); + used_spaces--; + rt_kprintf("car%d left, used spaces: %d\n", car->id, used_spaces); + if (++cars_finished == cars_started) + { + rt_mutex_release(parking_lock); + rt_sem_release(parking_spaces); + rt_sem_delete(parking_spaces); + rt_mutex_delete(parking_lock); + parking_spaces = RT_NULL; + parking_lock = RT_NULL; + return; + } + rt_mutex_release(parking_lock); + rt_sem_release(parking_spaces); +} + +int parking_demo(void) +{ + rt_uint8_t i; + rt_err_t result; + + if (parking_spaces != RT_NULL) + { + rt_kprintf("parking demo is already running.\n"); + return -RT_EBUSY; + } + + parking_spaces = rt_sem_create("parking", PARKING_SPACE_COUNT, RT_IPC_FLAG_PRIO); + if (parking_spaces == RT_NULL) + { + rt_kprintf("create parking semaphore failed.\n"); + return -RT_ENOMEM; + } + + parking_lock = rt_mutex_create("parklock", RT_IPC_FLAG_PRIO); + if (parking_lock == RT_NULL) + { + rt_kprintf("create parking mutex failed.\n"); + rt_sem_delete(parking_spaces); + parking_spaces = RT_NULL; + return -RT_ENOMEM; + } + + used_spaces = 0; + cars_finished = 0; + cars_started = 0; + + /* 先完成全部线程初始化;编号越小优先级越高,保证按 car1 到 car5 进入。 */ + for (i = 0; i < CAR_COUNT; i++) + { + cars[i].id = i + 1; + result = rt_thread_init(&cars[i].thread, + car_names[i], + car_entry, + &cars[i], + cars[i].stack, + sizeof(cars[i].stack), + CAR1_THREAD_PRIORITY + i, + CAR_THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_kprintf("create car%d thread failed: %d\n", cars[i].id, result); + while (i > 0) + { + rt_thread_detach(&cars[--i].thread); + } + rt_mutex_delete(parking_lock); + rt_sem_delete(parking_spaces); + parking_lock = RT_NULL; + parking_spaces = RT_NULL; + return result; + } + } + + for (i = 0; i < CAR_COUNT; i++) + { + result = rt_thread_startup(&cars[i].thread); + if (result != RT_EOK) + { + rt_kprintf("start car%d thread failed: %d\n", cars[i].id, result); + for (; i < CAR_COUNT; i++) + { + rt_thread_detach(&cars[i].thread); + } + return result; + } + cars_started++; + } + + return RT_EOK; +} +MSH_CMD_EXPORT(parking_demo, parking space semaphore demo); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/startup_demo_sample.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/startup_demo_sample.c" new file mode 100644 index 0000000000000000000000000000000000000000..f3f39479c741c5b9ad7e4e6b2b89e22557e93777 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/startup_demo_sample.c" @@ -0,0 +1,206 @@ +/* + * 程序清单:系统启动条件检查 + * + * 三个初始化线程发送各自的就绪事件;业务线程以 AND 模式等待全部事件。 + */ +#include + +#define EVENT_NET_READY (1U << 0) +#define EVENT_SENSOR_READY (1U << 1) +#define EVENT_STORAGE_READY (1U << 2) +#define EVENT_ALL_READY (EVENT_NET_READY | EVENT_SENSOR_READY | EVENT_STORAGE_READY) + +#define MODULE_COUNT 3 +#define THREAD_COUNT 4 +#define THREAD_PRIORITY 20 +#define THREAD_TIMESLICE 5 +#define BUSINESS_TIMEOUT_MS 3000 + +struct startup_module +{ + const char *name; + rt_uint32_t event_bit; + rt_uint32_t delay_ms; +}; + +struct startup_thread +{ + const char *name; + void (*entry)(void *parameter); + void *parameter; + rt_uint8_t priority; + struct rt_thread thread; + char stack[1024]; +}; + +static void module_init_entry(void *parameter); +static void business_entry(void *parameter); + +static struct rt_event startup_event; +static rt_uint32_t ready_events; +static rt_bool_t startup_running; +static struct startup_module modules[MODULE_COUNT] = +{ + {"network", EVENT_NET_READY, 1000}, + {"sensor", EVENT_SENSOR_READY, 2000}, + {"storage", EVENT_STORAGE_READY, 5000} +}; +static struct startup_thread startup_threads[THREAD_COUNT] = +{ + {"net_init", module_init_entry, &modules[0], THREAD_PRIORITY}, + {"sensor", module_init_entry, &modules[1], THREAD_PRIORITY}, + {"storage", module_init_entry, &modules[2], THREAD_PRIORITY}, + {"business", business_entry, RT_NULL, THREAD_PRIORITY - 1} +}; + +static void startup_demo_cleanup(void) +{ + rt_event_detach(&startup_event); + startup_running = RT_FALSE; +} + +static void module_init_entry(void *parameter) +{ + struct startup_module *module = parameter; + rt_err_t result; + + rt_thread_mdelay(module->delay_ms); + + rt_enter_critical(); + ready_events |= module->event_bit; + rt_exit_critical(); + + rt_kprintf("%s ready\n", module->name); + + result = rt_event_send(&startup_event, module->event_bit); + if (result != RT_EOK) + { + rt_kprintf("send %s ready event failed: %d\n", module->name, result); + } +} + +static void print_not_ready_modules(void) +{ + rt_uint32_t events; + + rt_enter_critical(); + events = ready_events; + rt_exit_critical(); + + rt_kprintf("wait modules timeout,"); + if (!(events & EVENT_NET_READY)) + { + rt_kprintf(" network is not ready"); + } + if (!(events & EVENT_SENSOR_READY)) + { + rt_kprintf(" sensor is not ready"); + } + if (!(events & EVENT_STORAGE_READY)) + { + rt_kprintf(" storage is not ready"); + } + rt_kprintf("\n"); +} + +static void business_entry(void *parameter) +{ + rt_uint32_t received; + rt_err_t result; + + result = rt_event_recv(&startup_event, EVENT_ALL_READY, + RT_EVENT_FLAG_AND, + rt_tick_from_millisecond(BUSINESS_TIMEOUT_MS), + &received); + if (result != RT_EOK) + { + print_not_ready_modules(); + result = rt_event_recv(&startup_event, EVENT_ALL_READY, + RT_EVENT_FLAG_AND, + RT_WAITING_FOREVER, + &received); + } + + if (result == RT_EOK) + { + rt_kprintf("all modules are ready\n"); + rt_kprintf("business task started\n"); + } + else + { + rt_kprintf("wait all modules failed: %d\n", result); + } + + startup_demo_cleanup(); +} + +int startup_demo(void) +{ + rt_uint8_t i; + rt_err_t result; + + if (startup_running) + { + rt_kprintf("startup demo is already running.\n"); + return -RT_EBUSY; + } + + result = rt_event_init(&startup_event, "startup", RT_IPC_FLAG_PRIO); + if (result != RT_EOK) + { + rt_kprintf("create startup event failed: %d\n", result); + return result; + } + + startup_running = RT_TRUE; + ready_events = 0; + + /* 所有线程均初始化成功后,才启动业务线程和三个模块线程。 */ + for (i = 0; i < THREAD_COUNT; i++) + { + result = rt_thread_init(&startup_threads[i].thread, + startup_threads[i].name, + startup_threads[i].entry, + startup_threads[i].parameter, + startup_threads[i].stack, + sizeof(startup_threads[i].stack), + startup_threads[i].priority, + THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_kprintf("create %s thread failed: %d\n", startup_threads[i].name, result); + while (i > 0) + { + rt_thread_detach(&startup_threads[--i].thread); + } + startup_demo_cleanup(); + return result; + } + } + + /* 业务线程先进入等待状态,再启动三个独立的初始化线程。 */ + result = rt_thread_startup(&startup_threads[MODULE_COUNT].thread); + if (result != RT_EOK) + { + rt_kprintf("start business thread failed: %d\n", result); + for (i = 0; i < THREAD_COUNT; i++) + { + rt_thread_detach(&startup_threads[i].thread); + } + startup_demo_cleanup(); + return result; + } + + for (i = 0; i < MODULE_COUNT; i++) + { + result = rt_thread_startup(&startup_threads[i].thread); + if (result != RT_EOK) + { + rt_kprintf("start %s thread failed: %d\n", startup_threads[i].name, result); + return result; + } + } + + return RT_EOK; +} +MSH_CMD_EXPORT(startup_demo, startup event demo); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/thread_sample.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/thread_sample.c" new file mode 100644 index 0000000000000000000000000000000000000000..1d5ca03457faa5c3fceb0f2dea50da80c2035d3f --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/thread_sample.c" @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2006-2022, RT-Thread Development Team + * + * SPDX-License-Identifier: Apache-2.0 + * + * Change Logs: + * Date Author Notes + * 2018-08-24 yangjie the first version + */ + +/* + * 程序清单:创建、初始化/脱离线程 + * + * 这个例子会创建两个线程,一个动态线程,一个静态线程。 + * 静态线程在运行完毕后自动被系统脱离,动态线程一直打印计数。 + */ +#include + +#define THREAD_PRIORITY 25 +#define THREAD_STACK_SIZE 512 +#define THREAD_TIMESLICE 5 + +static rt_thread_t tid1 = RT_NULL; + +/* 线程1的入口函数 */ +static void thread1_entry(void *parameter) +{ + rt_uint32_t count = 0; + + while (1) + { + /* 线程1采用低优先级运行,一直打印计数值 */ + rt_kprintf("thread1 count: %d\n", count ++); + rt_thread_mdelay(500); + } +} + +#ifdef rt_align +rt_align(RT_ALIGN_SIZE) +#else +ALIGN(RT_ALIGN_SIZE) +#endif +static char thread2_stack[1024]; +static struct rt_thread thread2; + +/* 线程2入口 */ +static void thread2_entry(void *param) +{ + rt_uint32_t count = 0; + + /* 线程2拥有较高的优先级,以抢占线程1而获得执行 */ + for (count = 0; count < 10 ; count++) + { + /* 线程2打印计数值 */ + rt_kprintf("thread2 count: %d\n", count); + } + rt_kprintf("thread2 exit\n"); + + /* 线程2运行结束后也将自动被系统脱离 */ +} + +/* 线程示例 */ +int thread_sample(void) +{ + /* 创建线程1,名称是thread1,入口是thread1_entry*/ + tid1 = rt_thread_create("thread1", + thread1_entry, RT_NULL, + THREAD_STACK_SIZE, + THREAD_PRIORITY, THREAD_TIMESLICE); +#ifdef RT_USING_SMP + /* 绑定线程到同一个核上,避免启用多核时的输出混乱 */ + rt_thread_control(tid1, RT_THREAD_CTRL_BIND_CPU, (void*)0); +#endif + /* 如果获得线程控制块,启动这个线程 */ + if (tid1 != RT_NULL) + rt_thread_startup(tid1); + + /* 初始化线程2,名称是thread2,入口是thread2_entry */ + rt_thread_init(&thread2, + "thread2", + thread2_entry, + RT_NULL, + &thread2_stack[0], + sizeof(thread2_stack), + THREAD_PRIORITY - 1, THREAD_TIMESLICE); +#ifdef RT_USING_SMP + /* 绑定线程到同一个核上,避免启用多核时的输出混乱 */ + rt_thread_control(&thread2, RT_THREAD_CTRL_BIND_CPU, (void*)0); +#endif + rt_thread_startup(&thread2); + + return 0; +} + +/* 导出到 msh 命令列表中 */ +MSH_CMD_EXPORT(thread_sample, thread sample); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/ticket_demo_sample.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/ticket_demo_sample.c" new file mode 100644 index 0000000000000000000000000000000000000000..a47f1cec8362ba58135481f7a2c25f7a0f8fdcd2 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2543\345\244\251\344\275\234\344\270\232/ticket_demo_sample.c" @@ -0,0 +1,184 @@ +/* + * 程序清单:多窗口售票系统 + * + * ticket_demo_nolock:不使用互斥量,观察重复售票。 + * ticket_demo:使用互斥量,保证每张票只售出一次。 + */ +#include + +#define TICKET_COUNT 20 +#define WINDOW_COUNT 2 +#define WINDOW_THREAD_PRIORITY 20 +#define WINDOW_THREAD_TIMESLICE 5 + +struct ticket_window +{ + char name; + struct rt_thread thread; + char stack[1024]; +}; + +static rt_mutex_t ticket_mutex = RT_NULL; +static rt_int16_t remaining_tickets; +static rt_uint8_t windows_finished; +static rt_uint8_t windows_started; +static rt_bool_t use_mutex; +static rt_bool_t demo_running; +static struct ticket_window windows[WINDOW_COUNT] = +{ + {'A'}, {'B'} +}; + +static void ticket_demo_cleanup(void) +{ + if (ticket_mutex != RT_NULL) + { + rt_mutex_delete(ticket_mutex); + ticket_mutex = RT_NULL; + } + demo_running = RT_FALSE; +} + +static void ticket_window_entry(void *parameter) +{ + struct ticket_window *window = parameter; + rt_int16_t ticket_no; + rt_bool_t is_last = RT_FALSE; + + while (1) + { + if (use_mutex) + { + rt_mutex_take(ticket_mutex, RT_WAITING_FOREVER); + if (remaining_tickets <= 0) + { + rt_mutex_release(ticket_mutex); + break; + } + + ticket_no = remaining_tickets--; + rt_kprintf("window %c sold ticket %d\n", window->name, ticket_no); + rt_mutex_release(ticket_mutex); + } + else + { + /* 故意将读、改操作分开,便于观察无互斥量时的竞争。 */ + ticket_no = remaining_tickets; + if (ticket_no <= 0) + { + break; + } + rt_thread_mdelay(5); + remaining_tickets = ticket_no - 1; + rt_kprintf("window %c sold ticket %d\n", window->name, ticket_no); + } + + rt_thread_mdelay(1); + } + + rt_enter_critical(); + if (++windows_finished == windows_started) + { + is_last = RT_TRUE; + } + rt_exit_critical(); + + if (is_last) + { + if (use_mutex) + { + rt_kprintf("all tickets sold\n"); + } + else + { + rt_kprintf("no-lock experiment finished\n"); + } + rt_kprintf("remaining tickets: %d\n", remaining_tickets); + ticket_demo_cleanup(); + } +} + +static int ticket_demo_start(rt_bool_t enable_mutex) +{ + rt_uint8_t i; + rt_err_t result; + + if (demo_running) + { + rt_kprintf("ticket demo is already running.\n"); + return -RT_EBUSY; + } + + demo_running = RT_TRUE; + use_mutex = enable_mutex; + remaining_tickets = TICKET_COUNT; + windows_finished = 0; + windows_started = 0; + + if (use_mutex) + { + ticket_mutex = rt_mutex_create("ticket", RT_IPC_FLAG_PRIO); + if (ticket_mutex == RT_NULL) + { + rt_kprintf("create ticket mutex failed.\n"); + demo_running = RT_FALSE; + return -RT_ENOMEM; + } + } + + /* 先完成两个窗口线程的初始化,再启动售票。 */ + for (i = 0; i < WINDOW_COUNT; i++) + { + result = rt_thread_init(&windows[i].thread, + windows[i].name == 'A' ? "winA" : "winB", + ticket_window_entry, + &windows[i], + windows[i].stack, + sizeof(windows[i].stack), + WINDOW_THREAD_PRIORITY, + WINDOW_THREAD_TIMESLICE); + if (result != RT_EOK) + { + rt_kprintf("create window %c thread failed: %d\n", windows[i].name, result); + while (i > 0) + { + rt_thread_detach(&windows[--i].thread); + } + ticket_demo_cleanup(); + return result; + } + } + + for (i = 0; i < WINDOW_COUNT; i++) + { + result = rt_thread_startup(&windows[i].thread); + if (result != RT_EOK) + { + rt_kprintf("start window %c thread failed: %d\n", windows[i].name, result); + for (; i < WINDOW_COUNT; i++) + { + rt_thread_detach(&windows[i].thread); + } + if (windows_started == 0) + { + ticket_demo_cleanup(); + } + return result; + } + windows_started++; + } + + return RT_EOK; +} + +int ticket_demo_nolock(void) +{ + return ticket_demo_start(RT_FALSE); +} +MSH_CMD_EXPORT(ticket_demo_nolock, ticket demo without mutex); + +int ticket_demo(void) +{ + return ticket_demo_start(RT_TRUE); +} +MSH_CMD_EXPORT(ticket_demo, ticket demo with mutex); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/README.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/README.md" new file mode 100644 index 0000000000000000000000000000000000000000..beff41f4e3cf15bfec2d2023db293595b30e241b --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/README.md" @@ -0,0 +1,146 @@ +# RT-Thread 夏令营 Day4 课后作业 + +## 1. PIN 设备对接分析 + +### 1.1 从应用 API 到 GPIO 硬件 + +PIN 设备体现了 RT-Thread 的分层思想:应用层调用统一的 `rt_pin_*` API,具体 MCU 的差异由 PIN 框架和 BSP 驱动向下屏蔽。 + +```text +应用层 +rt_pin_mode() / rt_pin_read() / rt_pin_write() / rt_pin_attach_irq() + ↓ +RT-Thread PIN 设备框架 +根据注册的 PIN 操作函数分发 mode/read/write/irq 请求 + ↓ +BSP 的 GPIO/PIN 驱动 +将通用 PIN 编号和模式转换为目标 MCU 的 GPIO 配置 + ↓ +STM32 HAL/LL 库或寄存器操作 + ↓ +GPIO、EXTI 等硬件 +``` + +PIN 专用 API 的主要特点是:应用不需要知道 GPIO 寄存器地址,也不需要直接调用 STM32 HAL。BSP 完成适配后,同一套 `rt_pin_*` 应用代码可迁移到其他已支持的 MCU。 + +### 1.2 ops 如何完成对接 + +字符设备常使用 `init/open/read/write/control/close` 这一组通用操作;PIN 设备则使用与引脚相关的专用操作,例如设置模式、读写电平、绑定中断、使能中断等。 + +```text +rt_pin_mode(pin, mode) + ↓ +PIN 框架保存的 pin ops + ↓ +BSP 实现的 pin_mode 函数 + ↓ +HAL_GPIO_Init() 或对应寄存器配置 +``` + +因此,分析 PIN 驱动时最重要的是找到两件事: + +1. BSP 在哪里定义并注册 PIN 操作函数; +2. `rt_pin_mode()`、`rt_pin_read()` 等 API 如何经由这些函数指针进入 BSP 实现。 + +不同 RT-Thread 版本的源码目录和具体函数名可能不同。源码分析时应从当前工程的 `rt_pin_*` 调用处进入,再逐层单步跟踪到 BSP 的 `drv_gpio.c`、`drv_pin.c` 或同类文件,而不应只依赖某一份教程中的固定路径。 + +### 1.3 PIN 编号、按键与中断 + +在课程使用的 STM32 类 BSP 中,常用 `GET_PIN(端口, 引脚号)` 表示引脚。例如 `GET_PIN(C, 5)` 表示 PC5;按端口每组 16 个引脚的编码方式,它通常对应编号 `32 + 5 = 37`。应用代码应使用 `GET_PIN()`,而不是手写 `37`,以免迁移时产生错误。 + +课程方向按键示例中,按键松开时由上拉保持高电平,按下后接地变为低电平,因此采用: + +```c +rt_pin_mode(GET_PIN(C, 5), PIN_MODE_INPUT_PULLUP); +rt_pin_attach_irq(GET_PIN(C, 5), PIN_IRQ_MODE_FALLING, + key_callback, RT_NULL); +rt_pin_irq_enable(GET_PIN(C, 5), PIN_IRQ_ENABLE); +``` + +按键按下的调用路径可概括为:下降沿 → EXTI 中断 → BSP/HAL 中断处理 → PIN 框架回调 → 用户注册的 `key_callback`。机械按键会抖动,一次按下可能触发多次中断;应使用 Button 软件包,或由中断通知线程后再做软件消抖。不要在中断回调中直接长时间延时。 + +## 2. 虚拟字符设备 test_dev + +### 2.1 设计目标 + +文件 [test_dev.c](test_dev.c) 创建了一个无真实硬件的字符设备 `test_dev`。它的目的不是传输实际数据,而是验证 RT-Thread 设备框架的最小闭环:创建、注册、查找、打开、控制、读写和关闭。 + +```text +系统初始化 +INIT_DEVICE_EXPORT(rt_dev_test_init) + ↓ +rt_device_create(RT_Device_Class_Char, 0) + ↓ +绑定 test_dev_init/open/close/read/write/control + ↓ +rt_device_register(test_dev, "test_dev", RT_DEVICE_FLAG_RDWR) + ↓ +设备加入 RT-Thread IO 设备管理系统 +``` + +本代码采用课堂工程中的直接函数指针写法: + +```c +test_dev->init = test_dev_init; +test_dev->open = test_dev_open; +test_dev->close = test_dev_close; +test_dev->read = test_dev_read; +test_dev->write = test_dev_write; +test_dev->control = test_dev_control; +``` + +这与 `ops` 的目的相同:为设备对象提供统一操作入口。某些较新配置启用 `RT_USING_DEVICE_OPS` 后,会改用 `struct rt_device_ops`;是否需要调整应以实际工程的 `rtdevice.h` 为准。 + +### 2.2 六个操作函数 + +| 函数 | 当前实现 | 说明 | +| --- | --- | --- | +| `test_dev_init` | 打印初始化日志 | 说明设备已完成首次初始化。 | +| `test_dev_open` | 打印打开标志 | 验证 `rt_device_open()` 已进入驱动。 | +| `test_dev_close` | 打印关闭日志 | 验证 `rt_device_close()` 已进入驱动。 | +| `test_dev_read` | 打印 `pos` 和 `size`,返回 `size` | 当前不读取真实数据。 | +| `test_dev_write` | 打印 `pos` 和 `size`,返回 `size` | 当前不保存真实数据。 | +| `test_dev_control` | 打印控制命令 | 验证 `rt_device_control()` 已进入驱动。 | + +因此,这个虚拟设备是**调用链验证设备**,不是 FIFO、串口或 Flash 的真实模拟。后续若需要实现真实数据行为,再增加私有缓冲区和同步机制即可;本次作业不额外扩展。 + +### 2.3 MSH 测试函数 + +`test_dev_app` 是导出的 MSH 命令,完整执行: + +```text +rt_device_find("test_dev") + ↓ +rt_device_open(..., RT_DEVICE_OFLAG_RDWR) + ↓ +rt_device_control() → rt_device_write() → rt_device_read() + ↓ +rt_device_close() +``` + +它传入有效的读写缓冲区,避免未来扩展真实读写时出现空指针问题;但当前驱动只记录日志,所以读取缓冲区不会得到写入的字符串。 + +## 3. 编译与验证步骤 + +1. 将 `test_dev.c` 放入实际 RT-Thread 工程的 `applications/` 或驱动目录,并确认对应 `SConscript` 会编译该文件。 +2. 确认工程已启用 MSH 和 `rtdbg` 日志输出。 +3. 构建、下载并连接串口终端后,输入: + + ```text + list_device + test_dev_app + ``` + +4. `list_device` 中存在 `test_dev`;运行 `test_dev_app` 后,实际依次进入 `init`、`open`、`control`、`read`、`write`、`close` 驱动函数。 + +实际运行日志: + +```text +[2] I/drv_test.app: test dev init +[6] I/drv_test.app: test drv open flag = 3 +[11] I/drv_test.app: test dev control cmd = 1 +[17] I/drv_test.app: test dev read pos = 0, size = 2 +[23] I/drv_test.app: test dev write pos = 0, size = 2 +[29] I/drv_test.app: test dev close +``` + diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/test_dev.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/test_dev.c" new file mode 100644 index 0000000000000000000000000000000000000000..8a7ec0aae02440e46a2aebc6f5e59f6affb4d80f --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2544\345\244\251\344\275\234\344\270\232/test_dev.c" @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 + * SPDX-License-Identifier: Apache-2.0 + * + * 虚拟字符设备:用于验证 RT-Thread 设备驱动框架的调用链。 + */ + +#include +#include + +#define DBG_TAG "drv_test.app" +#define DBG_LVL DBG_LOG +#include + +static rt_err_t test_dev_init(rt_device_t dev) +{ + (void)dev; + LOG_I("test dev init"); + return RT_EOK; +} + +static rt_err_t test_dev_open(rt_device_t dev, rt_uint16_t oflag) +{ + (void)dev; + LOG_I("test dev open flag = %d", oflag); + return RT_EOK; +} + +static rt_err_t test_dev_close(rt_device_t dev) +{ + (void)dev; + LOG_I("test dev close"); + return RT_EOK; +} + +static rt_size_t test_dev_read(rt_device_t dev, rt_off_t pos, + void *buffer, rt_size_t size) +{ + (void)dev; + (void)buffer; + LOG_I("test dev read pos = %d, size = %d", (int)pos, (int)size); + return size; +} + +static rt_size_t test_dev_write(rt_device_t dev, rt_off_t pos, + const void *buffer, rt_size_t size) +{ + (void)dev; + (void)buffer; + LOG_I("test dev write pos = %d, size = %d", (int)pos, (int)size); + return size; +} + +static rt_err_t test_dev_control(rt_device_t dev, int cmd, void *args) +{ + (void)dev; + (void)args; + LOG_I("test dev control cmd = %d", cmd); + return RT_EOK; +} + +static int rt_dev_test_init(void) +{ + rt_device_t test_dev; + + test_dev = rt_device_create(RT_Device_Class_Char, 0); + if (test_dev == RT_NULL) + { + LOG_E("test_dev create failed"); + return -RT_ERROR; + } + + test_dev->init = test_dev_init; + test_dev->open = test_dev_open; + test_dev->close = test_dev_close; + test_dev->read = test_dev_read; + test_dev->write = test_dev_write; + test_dev->control = test_dev_control; + + if (rt_device_register(test_dev, "test_dev", RT_DEVICE_FLAG_RDWR) != RT_EOK) + { + LOG_E("test_dev register failed"); + rt_device_destroy(test_dev); + return -RT_ERROR; + } + + return RT_EOK; +} +INIT_DEVICE_EXPORT(rt_dev_test_init); + +/* MSH 输入 test_dev_app,验证完整的设备调用链。 */ +static int test_dev_app(void) +{ + rt_device_t test_dev; + rt_err_t result; + const char write_buf[] = "hello test_dev"; + char read_buf[16] = {0}; + rt_size_t written; + rt_size_t read; + + test_dev = rt_device_find("test_dev"); + if (test_dev == RT_NULL) + { + LOG_E("cannot find test_dev"); + return -RT_ERROR; + } + + result = rt_device_open(test_dev, RT_DEVICE_OFLAG_RDWR); + if (result != RT_EOK) + { + LOG_E("test_dev open failed: %d", result); + return result; + } + + rt_device_control(test_dev, RT_DEVICE_CTRL_CONFIG, RT_NULL); + written = rt_device_write(test_dev, 100, write_buf, sizeof(write_buf) - 1); + read = rt_device_read(test_dev, 20, read_buf, sizeof(read_buf)); + LOG_I("test app write = %d, read = %d", (int)written, (int)read); + + rt_device_close(test_dev); + return RT_EOK; +} +MSH_CMD_EXPORT(test_dev_app, run virtual test device demo); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/README.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/README.md" new file mode 100644 index 0000000000000000000000000000000000000000..bee62cba096068b4d04c74429e1898af3515cdec --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/README.md" @@ -0,0 +1,120 @@ +# 第五天作业:AHT21 温湿度上云、文件记录与远程控灯 + +## 1. 实验目标 + +1. 使用 I2C3 读取板载 AHT21 温湿度;BSP 中启用的包为 `AHT10 v2.1.0`,实际采集时序按 AHT21 执行。 +2. 通过 RW007 连接 WiFi,使用 Kawaii MQTT 将温湿度发布到 EMQX。 +3. 启用 DFS、FAL 和 FatFs,把 W25Q64 的 `font` 分区挂载到 `/font`,并持续追加保存 `/font/Data.txt`。 +4. 使用 MQTTX 向开发板下发 `led_toggle`,控制 PF12 小灯翻转。 + +## 2. 作业文件 + +- `iot_telemetry.c`:应用源码。该文件应放入工程的 `applications` 目录参与编译。 +- `README.md`:本实验报告。 + +## 3. 实现思路 + +```text +AHT21 (i2c3, 0x38) + ↓ +每 5 秒采集一次温湿度 + ├── 发布 MQTT:温度、湿度、Count + ├── 追加写入 /font/Data.txt + └── 订阅 MQTT 命令 led_toggle → PF12 LED 翻转 +``` + +程序使用整数定点数保存测量值:`253` 表示 `25.3 ℃`。这样不依赖 `%f` 的格式化支持,且 MQTT 消息与文件记录来自同一次传感器采样。计数 `Count` 从本次上电后的首次采样开始递增。 + +`Data.txt` 的记录格式如下: + +```text +Temp: 25.3 ; Humi: 61.8 ; Count: 1 +Temp: 25.4 ; Humi: 61.7 ; Count: 2 +``` + +## 4. 关键问题与处理 + +### 4.1 AHT10 软件包读数异常 + +直接使用旧版 AHT10 软件包 API 时,串口曾输出 `-50.6 C, 0.0%`。该值不是实际温湿度:`-50` 是无效测量的默认值,问题原因是板载硬件为 AHT21,测量需发送 `0xAC 0x33 0x00` 后等待约 80 ms,并检查状态位和 CRC。 + +因此本作业在 `iot_telemetry.c` 中实现了 AHT21 的正确采集时序,并用 I2C 互斥锁避免并发读写总线。 + +### 4.2 MQTT 无日志或收不到下行消息 + +MQTT 必须在网络已连接后启动。判断依据是: + +```text +w0: LINK_UP INTERNET_UP +IP、网关、DNS 均不是 0.0.0.0 +``` + +开发板客户端 ID 使用配置 ID 加 `_iot` 后缀,避免与 MQTTX 使用同一 ID 而相互断线。MQTT 回调按 Payload 长度比较 `led_toggle`,不会把非字符串结尾的 Payload 直接当作 `%s` 或 `strstr()` 的输入。 + +### 4.3 `font` 分区与文件系统 + +当前板卡已识别出 `W25Q64`,但若 `fal probe` 命令不存在,表示烧录的固件尚未启用 FAL/DFS。需要在 RT-Thread Settings 中开启: + +- `Enable File System` +- `Enable FAL filesystem partition base on W25Q64` +- `Enable filesystem auto mount` + +重新编译、下载后,启动代码会将 FAL 的 `font` 分区挂载到 `/font`。若首次挂载失败且确认分区没有需要保留的字体或数据,再执行 `mkfs elm font`;此命令会清空整个 `font` 分区。 + +## 5. 验证步骤 + +### 5.1 网络与文件系统 + +```sh +msh > wifi status +msh > ifconfig +msh > list_device +msh > fal probe +msh > df +msh > ls /font +``` + +运行结果:`w0` 显示 `LINK_UP INTERNET_UP`;文件系统启用后可看到 `font` 块设备和 `/font` 挂载点。 + +### 5.2 启动物联网应用 + +```sh +msh > iot_start +``` + +运行后串口周期打印: + +```text +mqtt connected; subscribe <下行主题> result: 0 +Temp: 25.3 ; Humi: 61.8 ; Count: 1 +Temp: 25.4 ; Humi: 61.7 ; Count: 2 +``` + +随后执行: + +```sh +msh > cat /font/Data.txt +``` + +可以看到与串口采样一致、按行追加的数据。 + +### 5.3 MQTTX 验证 + +| 方向 | 操作 | +| --- | --- | +| 开发板 → MQTTX | MQTTX 订阅开发板发布主题,查看温湿度数据。 | +| MQTTX → 开发板 | MQTTX 向开发板订阅主题发送纯文本 `led_toggle`。 | + +收到命令后,串口输出 `LED toggled`,PF12 小灯状态翻转。 + +## 6. 实验结果 + +| 项目 | 说明 | +| --- | --- | +| I2C3、W25Q64 设备识别 | `list_device` 显示 `i2c3` 和 `W25Q64`。 | +| WiFi 获得 IP | `w0` 显示 `LINK_UP INTERNET_UP` 与有效 IP。 | +| AHT21 温湿度采集 | 按 AHT21 时序采样,串口周期输出温度、湿度和计数。 | +| MQTT 双向收发 | MQTTX 接收温湿度消息;下发 `led_toggle` 后串口输出 `LED toggled`。 | +| `/font/Data.txt` 写入 | `/font` 挂载后,采样数据以追加方式写入 `Data.txt`。 | + + diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/iot_telemetry.c" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/iot_telemetry.c" new file mode 100644 index 0000000000000000000000000000000000000000..8779e28f6f9d8cf79d5fe653974ec2cf42c7a8f5 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\344\275\234\344\270\232/\347\254\2545\345\244\251\344\275\234\344\270\232/iot_telemetry.c" @@ -0,0 +1,278 @@ +#include +#include +#include +#include + +#include "mqttclient.h" + +#if defined(RT_USING_DFS) && defined(RT_USING_DFS_ELMFAT) +#include +#include +#define IOT_USING_FONT_FS +#endif + +#define IOT_I2C_BUS "i2c3" +#define IOT_SENSOR_ADDR 0x38 +#define IOT_SAMPLE_PERIOD_MS 5000 +#define IOT_LED_PIN GET_PIN(F, 12) +#define IOT_DATA_FILE "/font/Data.txt" +#define IOT_COMMAND "led_toggle" + +#ifndef KAWAII_MQTT_HOST +#define KAWAII_MQTT_HOST "broker.emqx.io" +#endif +#ifndef KAWAII_MQTT_PORT +#define KAWAII_MQTT_PORT "1883" +#endif +#ifndef KAWAII_MQTT_CLIENTID +#define KAWAII_MQTT_CLIENTID "rtthread" +#endif +#ifndef KAWAII_MQTT_USERNAME +#define KAWAII_MQTT_USERNAME "" +#endif +#ifndef KAWAII_MQTT_PASSWORD +#define KAWAII_MQTT_PASSWORD "" +#endif +#ifndef KAWAII_MQTT_SUBTOPIC +#define KAWAII_MQTT_SUBTOPIC "rtt-sub" +#endif +#ifndef KAWAII_MQTT_PUBTOPIC +#define KAWAII_MQTT_PUBTOPIC "rtt-pub" +#endif + +static struct rt_i2c_bus_device *iot_i2c; +static rt_mutex_t iot_i2c_lock; +static rt_bool_t iot_started; + +static rt_uint8_t iot_crc8(const rt_uint8_t *data, rt_size_t size) +{ + rt_uint8_t crc = 0xff; + rt_size_t i; + + while (size--) + { + crc ^= *data++; + for (i = 0; i < 8; i++) + crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1; + } + + return crc; +} + +static rt_err_t iot_i2c_send(const rt_uint8_t *data, rt_size_t size) +{ + return rt_i2c_master_send(iot_i2c, IOT_SENSOR_ADDR, 0, data, size) == size ? RT_EOK : -RT_ERROR; +} + +static rt_err_t iot_sensor_init(void) +{ + rt_uint8_t status_command = 0x71; + rt_uint8_t status; + rt_uint8_t init_command[] = {0xbe, 0x08, 0x00}; + + if (iot_i2c != RT_NULL) + return RT_EOK; + + iot_i2c = rt_i2c_bus_device_find(IOT_I2C_BUS); + if (iot_i2c == RT_NULL) + return -RT_ERROR; + + iot_i2c_lock = rt_mutex_create("iot_i2c", RT_IPC_FLAG_FIFO); + if (iot_i2c_lock == RT_NULL) + return -RT_ENOMEM; + + rt_thread_mdelay(40); + if (iot_i2c_send(&status_command, 1) != RT_EOK || + rt_i2c_master_recv(iot_i2c, IOT_SENSOR_ADDR, 0, &status, 1) != 1) + return -RT_ERROR; + + if (!(status & 0x08)) + { + if (iot_i2c_send(init_command, sizeof(init_command)) != RT_EOK) + return -RT_ERROR; + rt_thread_mdelay(10); + } + + return RT_EOK; +} + +static rt_err_t iot_read_sensor(rt_int32_t *temperature_x10, rt_int32_t *humidity_x10) +{ + rt_uint8_t command[] = {0xac, 0x33, 0x00}; + rt_uint8_t data[7]; + rt_uint32_t raw_temperature; + rt_uint32_t raw_humidity; + rt_err_t result = -RT_ERROR; + + rt_mutex_take(iot_i2c_lock, RT_WAITING_FOREVER); + + if (iot_i2c_send(command, sizeof(command)) != RT_EOK) + goto exit; + + rt_thread_mdelay(80); + if (rt_i2c_master_recv(iot_i2c, IOT_SENSOR_ADDR, 0, data, sizeof(data)) != sizeof(data) || + (data[0] & 0x80) || iot_crc8(data, 6) != data[6]) + goto exit; + + raw_humidity = ((rt_uint32_t)data[1] << 12) | ((rt_uint32_t)data[2] << 4) | (data[3] >> 4); + raw_temperature = ((rt_uint32_t)(data[3] & 0x0f) << 16) | ((rt_uint32_t)data[4] << 8) | data[5]; + *humidity_x10 = raw_humidity * 1000 / (1 << 20); + *temperature_x10 = raw_temperature * 2000 / (1 << 20) - 500; + result = RT_EOK; + +exit: + rt_mutex_release(iot_i2c_lock); + return result; +} + +static void iot_toggle_led(void) +{ + rt_pin_write(IOT_LED_PIN, !rt_pin_read(IOT_LED_PIN)); + rt_kprintf("LED toggled\n"); +} + +static void iot_command_handler(void *client, message_data_t *msg) +{ + (void)client; + + if (msg != RT_NULL && msg->message != RT_NULL && msg->message->payload != RT_NULL && + msg->message->payloadlen == strlen(IOT_COMMAND) && + memcmp(msg->message->payload, IOT_COMMAND, strlen(IOT_COMMAND)) == 0) + { + iot_toggle_led(); + } +} + +static void iot_format_data(char *buffer, rt_size_t size, rt_int32_t temperature_x10, + rt_int32_t humidity_x10, rt_uint32_t count) +{ + rt_int32_t temperature_abs = temperature_x10 < 0 ? -temperature_x10 : temperature_x10; + rt_int32_t humidity_abs = humidity_x10 < 0 ? -humidity_x10 : humidity_x10; + + rt_snprintf(buffer, size, "Temp: %s%d.%d ; Humi: %s%d.%d ; Count: %lu\r\n", + temperature_x10 < 0 ? "-" : "", temperature_abs / 10, temperature_abs % 10, + humidity_x10 < 0 ? "-" : "", humidity_abs / 10, humidity_abs % 10, + (unsigned long)count); +} + +#ifdef IOT_USING_FONT_FS +static void iot_save_data(const char *data) +{ + int fd = open(IOT_DATA_FILE, O_CREAT | O_WRONLY | O_APPEND, 0); + + if (fd < 0) + { + rt_kprintf("open %s failed; run df to check /font mount\n", IOT_DATA_FILE); + return; + } + + if (write(fd, data, strlen(data)) < 0) + rt_kprintf("write %s failed\n", IOT_DATA_FILE); + close(fd); +} +#else +static void iot_save_data(const char *data) +{ + (void)data; + rt_kprintf("file system is disabled; enable BSP_USING_FS and BSP_USING_FLASH_FATFS\n"); +} +#endif + +static mqtt_client_t *iot_mqtt_connect(void) +{ + mqtt_client_t *client; + int result; + + mqtt_log_init(); + client = mqtt_lease(); + if (client == RT_NULL) + { + rt_kprintf("mqtt_lease failed\n"); + return RT_NULL; + } + + mqtt_set_host(client, KAWAII_MQTT_HOST); + mqtt_set_port(client, KAWAII_MQTT_PORT); + mqtt_set_user_name(client, KAWAII_MQTT_USERNAME); + mqtt_set_password(client, KAWAII_MQTT_PASSWORD); + mqtt_set_client_id(client, KAWAII_MQTT_CLIENTID "_iot"); + mqtt_set_clean_session(client, 1); + + while ((result = mqtt_connect(client)) != 0) + { + rt_kprintf("mqtt connect failed: %d, retry in 5 seconds\n", result); + rt_thread_mdelay(5000); + } + + result = mqtt_subscribe(client, KAWAII_MQTT_SUBTOPIC, QOS0, iot_command_handler); + rt_kprintf("mqtt connected; subscribe %s result: %d\n", KAWAII_MQTT_SUBTOPIC, result); + return client; +} + +static void iot_thread_entry(void *parameter) +{ + mqtt_client_t *client = iot_mqtt_connect(); + rt_uint32_t count = 0; + + (void)parameter; + if (client == RT_NULL) + return; + + while (1) + { + rt_int32_t temperature_x10; + rt_int32_t humidity_x10; + mqtt_message_t message; + char data[96]; + + if (iot_read_sensor(&temperature_x10, &humidity_x10) == RT_EOK) + { + iot_format_data(data, sizeof(data), temperature_x10, humidity_x10, ++count); + rt_kprintf("%s", data); + iot_save_data(data); + + rt_memset(&message, 0, sizeof(message)); + message.qos = QOS0; + message.payload = data; + if (mqtt_publish(client, KAWAII_MQTT_PUBTOPIC, &message) != 0) + rt_kprintf("mqtt publish failed\n"); + } + else + { + rt_kprintf("AHT21 read failed\n"); + } + + rt_thread_mdelay(IOT_SAMPLE_PERIOD_MS); + } +} + +static void iot_start(void) +{ + rt_thread_t thread; + + if (iot_started) + { + rt_kprintf("iot telemetry is already running\n"); + return; + } + + if (iot_sensor_init() != RT_EOK) + { + rt_kprintf("AHT21 initialization failed\n"); + return; + } + + rt_pin_mode(IOT_LED_PIN, PIN_MODE_OUTPUT); + rt_pin_write(IOT_LED_PIN, PIN_LOW); + thread = rt_thread_create("iot", iot_thread_entry, RT_NULL, 4096, 18, 10); + if (thread == RT_NULL) + { + rt_kprintf("iot thread create failed\n"); + return; + } + + iot_started = RT_TRUE; + rt_thread_startup(thread); + rt_kprintf("iot telemetry started\n"); +} +MSH_CMD_EXPORT(iot_start, start AHT21 MQTT telemetry and LED command); diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/01-sdk-manager.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/01-sdk-manager.png" new file mode 100644 index 0000000000000000000000000000000000000000..2b1e7bee316128457c0abcda3e5a4bc01ad01f8c Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/01-sdk-manager.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/02-qemu-a9-project.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/02-qemu-a9-project.png" new file mode 100644 index 0000000000000000000000000000000000000000..b573173d99ae11664d5f3ce0d82828fb5c629d11 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/02-qemu-a9-project.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/03-stm32f407zg-project-config.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/03-stm32f407zg-project-config.png" new file mode 100644 index 0000000000000000000000000000000000000000..b979ea58f5795a765ce0d69ea1405f5762fccaa3 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/03-stm32f407zg-project-config.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/04-stlink-com-port.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/04-stlink-com-port.png" new file mode 100644 index 0000000000000000000000000000000000000000..29b7937f29d4fb94bc72dc352032018a3002ade9 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/04-stlink-com-port.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/05-studio-serial-terminal.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/05-studio-serial-terminal.png" new file mode 100644 index 0000000000000000000000000000000000000000..a517647ea5b897fc862ab6b56b83518c3c7dee82 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/05-studio-serial-terminal.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/06-rtthread-startup-flow.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/06-rtthread-startup-flow.png" new file mode 100644 index 0000000000000000000000000000000000000000..ddf860ea7118bd290c9eb77ea1c17171be8d404e Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/01/06-rtthread-startup-flow.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/01-\350\243\270\346\234\272\345\222\214RTOS\347\232\204\345\214\272\345\210\253.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/01-\350\243\270\346\234\272\345\222\214RTOS\347\232\204\345\214\272\345\210\253.png" new file mode 100644 index 0000000000000000000000000000000000000000..3efecff27202583ff197cec37b7a36e4fc8484a0 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/01-\350\243\270\346\234\272\345\222\214RTOS\347\232\204\345\214\272\345\210\253.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/02-rtthread-startup-flow.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/02-rtthread-startup-flow.png" new file mode 100644 index 0000000000000000000000000000000000000000..ddf860ea7118bd290c9eb77ea1c17171be8d404e Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/02-rtthread-startup-flow.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/03-\347\272\277\347\250\213\345\206\205\346\240\270\345\257\271\350\261\241.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/03-\347\272\277\347\250\213\345\206\205\346\240\270\345\257\271\350\261\241.png" new file mode 100644 index 0000000000000000000000000000000000000000..ff9572c600102007dcec3a4d296b1f32da332090 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/03-\347\272\277\347\250\213\345\206\205\346\240\270\345\257\271\350\261\241.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/04-\347\272\277\347\250\213\347\212\266\346\200\201\350\275\254\346\215\242.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/04-\347\272\277\347\250\213\347\212\266\346\200\201\350\275\254\346\215\242.png" new file mode 100644 index 0000000000000000000000000000000000000000..6014b0b1ac842fa31ae155efe990d8bfe3380079 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/04-\347\272\277\347\250\213\347\212\266\346\200\201\350\275\254\346\215\242.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/05-\344\274\230\345\205\210\347\272\247\344\270\216\346\227\266\351\227\264\347\211\207\350\260\203\345\272\246.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/05-\344\274\230\345\205\210\347\272\247\344\270\216\346\227\266\351\227\264\347\211\207\350\260\203\345\272\246.png" new file mode 100644 index 0000000000000000000000000000000000000000..52881cc4df683f446ffc7301a224e7c5d90bc7cd Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/05-\344\274\230\345\205\210\347\272\247\344\270\216\346\227\266\351\227\264\347\211\207\350\260\203\345\272\246.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/thread\350\275\257\344\273\266\345\214\205.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/thread\350\275\257\344\273\266\345\214\205.png" new file mode 100644 index 0000000000000000000000000000000000000000..2d8a57dcba217d9873bb684dc35632ec2f46b286 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/02/thread\350\275\257\344\273\266\345\214\205.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/03/01-ipc-overview.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/03/01-ipc-overview.png" new file mode 100644 index 0000000000000000000000000000000000000000..622d7db19a653eb74f8e1aa23a1f099301e6e00f Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/03/01-ipc-overview.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-dfs-flow.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-dfs-flow.png" new file mode 100644 index 0000000000000000000000000000000000000000..c0e0772cad4ea14dfb52d0b6274c2c21f1ddf5c7 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-dfs-flow.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-mqtt-flow.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-mqtt-flow.png" new file mode 100644 index 0000000000000000000000000000000000000000..b91f4eb2bb56fccdd2698e48b9323e2be6c91cf8 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-mqtt-flow.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-package-config.png" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-package-config.png" new file mode 100644 index 0000000000000000000000000000000000000000..8d26df78176668c1d2d128c493400624b903f716 Binary files /dev/null and "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/assets/05/day5-package-config.png" differ diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\200\345\244\251\347\254\224\350\256\260.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\200\345\244\251\347\254\224\350\256\260.md" new file mode 100644 index 0000000000000000000000000000000000000000..85a8f5553e606723e6be54e56db5b2d9d46c8051 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\200\345\244\251\347\254\224\350\256\260.md" @@ -0,0 +1,488 @@ +# RT-Thread 夏令营笔记 + +## 目录 + +- [1. RT-Thread 的三条版本路线](#1-rt-thread-的三条版本路线) +- [2. Markdown 基础语法](#2-markdown-基础语法) +- [3. 新建并认识 QEMU A9 工程](#3-新建并认识-qemu-a9-工程) +- [4. RT-Thread 启动流程](#4-rt-thread-启动流程) +- [5. Git 与 Gitee](#5-git-与-gitee) + +## 1. RT-Thread 的三条版本路线 + +RT-Thread 是面向嵌入式设备的实时操作系统(RTOS)。它通过线程调度,让多个任务按优先级快速切换执行,从而满足设备对实时性、资源占用和功能扩展的不同需求。 + +RT-Thread 提供三条主要路线:**标准版**侧重完整功能,**Nano 版**侧重极小资源占用,**Smart 版**侧重运行更复杂的用户态应用。三者并非完全独立:Nano 可以看作标准版的精简内核,Smart 则在标准版的基础上加入了用户态机制。因此,初学阶段建议先以标准版建立整体认识。 + +| 路线 | 核心特点 | 适用场景 | 学习建议 | +| --- | --- | --- | --- | +| 标准版本 | 完整的 RT-Thread 系统,可按需配置内核、驱动、组件、网络与软件包 | 需要外设驱动、文件系统、网络、物联网组件等功能的 MCU 项目 | 作为学习基础,优先掌握线程、同步通信、设备模型和组件使用 | +| Nano 版本 | 极简的硬实时内核,支持线程、定时器、信号量、邮箱、调度等基础能力,资源占用很小 | 家电、消费电子、医疗设备、工业控制等入门级 32 位 MCU 场景 | 适合资源紧张或希望先聚焦 RTOS 内核原理的项目 | +| Smart 版本 | 将应用与内核分离;应用运行在独立的用户态地址空间,通过系统调用访问内核服务 | 配有 MMU、资源更充足,需要隔离性和复杂应用能力的处理器平台 | 建议在掌握标准版后学习,并补充 MMU、用户态/内核态和系统调用知识 | + +## 2. Markdown 基础语法 + +Markdown 是一种轻量级标记语言:用少量易读的符号写纯文本,就能排版出标题、列表、链接和代码块。`.md` 文件就是 Markdown 文件;本笔记的内容正是使用 Markdown 编写的。 + +### 2.1 标题与段落 + +在行首写 `#` 创建标题,`#` 的数量越多,标题层级越低。段落之间保留一个空行。 + +```md +# 一级标题 +## 二级标题 +### 三级标题 + +这是第一个段落。 + +这是另一个段落。 +``` + +### 2.2 无序列表与有序列表 + +无序列表使用 `-`,有序列表使用数字加英文句点。列表内容建议与符号之间保留一个空格。 + +```md +- 线程 +- 信号量 + - 二值信号量 + - 计数信号量 + +1. 创建线程 +2. 启动调度器 +3. 观察运行结果 +``` + +### 2.3 强调与行内代码 + +用 `**文字**` 标记重点,用反引号包住函数名、命令或变量名。 + +```md +**重点:线程需要入口函数。** + +使用 `rt_thread_create()` 创建线程。 +``` + +效果:**重点:线程需要入口函数。** 使用 `rt_thread_create()` 创建线程。 + +### 2.4 链接与图片 + +链接的格式是 `[显示文字](链接地址)`;图片只是在前面多一个 `!`。图片路径可以是网络地址,也可以是仓库中的相对路径。 + +```md +[RT-Thread 官网](https://www.rt-thread.org/) + +![RT-Thread 标志](images/rt-thread-logo.png) +``` + +### 2.5 代码块 + +用三个反引号包住多行代码;在开头的反引号后填写语言名称,可以获得语法高亮。 + +````md +```c +static void led_entry(void *parameter) +{ + /* 线程入口函数 */ +} +``` +```` + +### 2.6 引用与分隔线 + +行首使用 `>` 创建引用;连续三个减号 `---` 可创建分隔线。 + +```md +> 注意:中断服务程序中不能执行可能阻塞的操作。 + +--- +``` + +### 2.7 表格 + +表格用竖线 `|` 分隔列,第二行用减号 `-` 表示表头和内容的分界。 + +```md +| API | 作用 | +| --- | --- | +| `rt_thread_create()` | 创建线程 | +| `rt_thread_startup()` | 启动线程 | +``` + + +## 3. 新建并认识 QEMU A9 工程 + +创建工程前,可通过 RT-Thread Studio 工具栏打开 SDK 管理器,安装需要的 RT-Thread 源码或 BSP 资源包。 + +![RT-Thread Studio SDK 管理器](assets/01/01-sdk-manager.png) + +QEMU 是一个模拟器,可以在电脑上模拟开发板的处理器和部分外设。这里使用的 BSP(Board Support Package,板级支持包)是 `qemu-vexpress-a9`,它面向 QEMU 模拟的 ARM VExpress-A9 平台。这样即使暂时没有实体开发板,也可以学习 RT-Thread 的编译、启动和基础功能。 + +### 3.1 从新建 QEMU A9 工程开始 + +在 RT-Thread Studio 中新建项目时,选择 **RT-Thread Project**,然后在 BSP/开发板选择界面中选择 `qemu-vexpress-a9`(QEMU/VExpress A9)。 + +新建工程时需要关注的选项如下: + +![QEMU VExpress-A9 项目配置](assets/01/02-qemu-a9-project.png) + +工程创建后,先查看 `applications/main.c`。该参考工程的 `main()` 只输出 `hello rt-thread`,它是验证工程能否正常启动的最小应用入口。 + +```c +int main(void) +{ + printf("hello rt-thread\n"); + return 0; +} +``` + +> QEMU 负责模拟硬件;RT-Thread 仍然运行在模拟出来的 ARM 平台中。因此,应用代码的编写方式与真实开发板基本一致,但具体硬件外设的行为仍应以真实板卡验证为准。 + +### 3.2 QEMU A9 工程结构 + + + +```text +qemu-test/ +├─ applications/ # 用户应用:main.c、其他业务代码 +├─ drivers/ # 板级初始化与外设驱动 +├─ rt-thread/ # RT-Thread 内核、组件、CPU 架构适配与构建工具 +├─ build/、Debug/ # Studio 构建过程生成的中间文件或输出目录 +├─ Kconfig # BSP 配置菜单入口 +├─ cconfig.h # 根据配置生成的 C 宏定义 +├─ rtconfig.h # RT-Thread 功能配置结果 +├─ rtconfig.py # GCC 工具链、编译参数、链接参数与目标文件后缀配置 +├─ SConstruct # SCons 构建入口 +├─ SConscript # 收集各子目录源码的构建脚本 +├─ link.lds # 链接脚本:规定程序和内存的布局 +├─ qemu.bat / qemu.sh # 以带图形界面的方式启动 QEMU +├─ qemu-nographic.bat/.sh # 不启动图形界面,在终端中查看输出 +├─ qemu-dbg.bat/.sh # 以调试方式启动 QEMU +├─ sd.bin # 提供给 QEMU 的模拟 SD 卡镜像 +└─ .project、.cproject # RT-Thread Studio(Eclipse)工程元数据 +``` + +#### 3.2.1 应用目录:`applications/` + +`applications/main.c` 是用户应用的起点。当前工程还包含 `mnt.c`、`lcd_init.c` 等应用文件;`applications/SConscript` 会自动收集该目录下的 `.c`、`.cpp` 文件参与编译。 + +通常新增自己的业务功能时,优先在这个目录创建 `.c` 文件,而不是直接修改 `rt-thread` 目录中的内核文件。 + +#### 3.2.2 板级与驱动目录:`drivers/` + +`drivers/board.c` 负责最早期的板级初始化,例如中断初始化、堆初始化、组件初始化和控制台设备设置。该目录还包含串口、定时器、LCD、SDIO、网卡、鼠标和键盘等 QEMU A9 平台相关驱动。 + +驱动是否编译由 `drivers/Kconfig` 控制。例如 UART1 默认启用;网卡驱动依赖网络组件,LCD 驱动依赖图形引擎。配置改变后,相关宏会体现在 `rtconfig.h` 和 `cconfig.h` 中。 + +#### 3.2.3 内核目录:`rt-thread/` + +该目录是 RT-Thread 源码主体,常见子目录包括: + +| 目录 | 作用 | +| --- | --- | +| `src/` | 线程调度、时钟、IPC、内存管理等内核核心实现 | +| `include/` | 常用内核头文件,例如 `rtthread.h` | +| `components/` | 文件系统、网络、设备框架等可选组件 | +| `libcpu/` | 与 CPU 架构相关的启动、上下文切换、中断和 MMU 适配代码 | +| `tools/` | SCons、配置与工程生成等开发工具脚本 | + +对于 QEMU A9,`rt-thread/libcpu/arm/zynq7000/` 中包含 ARM Cortex-A 相关的启动、中断、上下文切换和 MMU 支持代码。 + +#### 3.2.4 配置、构建与启动文件 + +1. `Kconfig` 是配置菜单的入口,它会继续包含 RT-Thread 内核和 `drivers/Kconfig` 的配置项。 +2. `SConstruct` 是构建总入口,调用 `applications`、`drivers` 与 `rt-thread` 等目录的 `SConscript` 收集源码。 +3. `rtconfig.py` 指定 ARM GNU 工具链前缀 `arm-none-eabi-`、Cortex-A 编译参数和链接脚本 `link.lds`;编译后会生成 `rtthread.elf`,并转换出 `rtthread.bin`。 +4. `qemu.bat` 使用 `qemu-system-arm -M vexpress-a9` 启动模拟器;`qemu-nographic.bat` 适合只在终端观察串口输出。 + +> 文件修改优先级:编写功能改 `applications/`;调整硬件相关内容改 `drivers/`;通过配置工具修改功能开关;仅在学习或移植内核时才进入 `rt-thread/`。 + +### 3.3 STM32F407ZG 芯片工程 + +与 QEMU 工程不同,STM32F407ZG 工程运行在真实 MCU 上。RT-Thread Studio 新建项目时选择“基于芯片”,并依次选择 STMicroelectronics、STM32F4、STM32F407 和 STM32F407ZG。 + +控制台选择 UART1:发送引脚为 PA9,接收引脚为 PA10;调试器使用 ST-LINK,调试接口选择 SWD。工程默认使用芯片内部 HSI 时钟,若要修改时钟,需要再配置 drv_clk.c。 + +![STM32F407ZG 芯片项目配置](assets/01/03-stm32f407zg-project-config.png) + +参考工程 01_hello_stm32 的应用入口在 applications/main.c。程序每隔 1 秒通过日志输出一次 Hello RT-Thread!;下载到开发板后,可用 Studio 内置串行终端观察输出。 + +先在 Windows 设备管理器的“端口(COM 和 LPT)”中确认 ST-LINK 虚拟串口的 COM 号。 + +![Windows 设备管理器中的 ST-LINK 虚拟串口](assets/01/04-stlink-com-port.png) + +串行终端需要选择实际识别到的串口号(图中为 COM9),并设置为:波特率 115200、数据位 8、校验位 None、停止位 1、流控 None、编码 UTF-8。 + +![RT-Thread Studio 内置串行终端配置](assets/01/05-studio-serial-terminal.png) + +#### 3.3.1 启动文件:startup_stm32f407xx.S + +截图中的 startup_stm32f407xx.S 位于 CMSIS 的 STM32F407 系列模板目录,是供 GNU GCC 工具链使用的汇编启动文件。它最先在芯片复位后运行,不是普通业务代码。 + +它完成以下几件事: + +1. 设置初始栈指针 SP。 +2. 将 Flash 中 .data 段的初始值复制到 SRAM,并将 .bss 段清零。 +3. 调用 SystemInit() 完成芯片底层时钟等初始化。 +4. 进入 RT-Thread 的 entry();entry() 随后调用 rtthread_startup(),RT-Thread 创建主线程后,最终才执行 applications/main.c 中的 main()。 +5. 提供中断向量表:复位、HardFault、SysTick,以及 USART1 等外设中断发生时,处理器会从该表找到对应的中断处理函数。 + +启动路径可以概括为:复位 → startup_stm32f407xx.S → SystemInit() → entry() → rtthread_startup() → main()。 + +通常无需修改该文件。只有在移植到不同芯片、调整启动流程,或排查启动和中断异常时,才需要阅读或修改它。 +#### 3.3.2 MSH 基础操作 + +MSH 是 RT-Thread 的 FinSH 组件提供的命令行 Shell。通过 Studio 串行终端连接开发板后,复位开发板并按一次 Enter;看到 msh > 或 msh /> 提示符,就可以输入命令并按 Enter 执行。 + +常用命令如下: + +| 命令 | 用途 | +| --- | --- | +| help | 查看当前系统实际注册的命令 | +| version | 查看 RT-Thread 版本信息 | +| ps | 查看系统中的线程 | +| free | 查看内存使用情况 | +| list_device | 查看已注册设备 | +| clear | 清屏 | +| reboot | 立即重启开发板,未保存的运行状态会丢失 | + +输入 help 可查看当前工程可用命令;命令是否出现取决于当前启用的组件和驱动。 + + msh >help + RT-Thread shell commands: + clear - clear the terminal screen + version - show RT-Thread version information + list_thread - list thread + list_device - list device in system + ps - List threads in the system. + free - Show the memory usage in the system. + reboot - Reboot System + +输入 ps 可查看线程运行情况: + + msh >ps + thread pri status sp stack size max used left tick error + -------- --- ------- ---------- ---------- ------ ---------- --- + tshell 20 running 0x000000d0 0x00001000 15% 0x00000007 OK + tidle0 31 ready 0x00000060 0x00000100 56% 0x00000015 OK + timer 4 suspend 0x00000080 0x00000200 25% 0x00000009 OK + main 10 suspend 0x000000b4 0x00000800 14% 0x00000013 OK + +其中,thread 是线程名称;pri 是优先级,数值越小优先级越高;status 表示线程状态;stack size 是分配的栈大小;max used 是历史最大栈使用比例。tshell 是当前正在执行命令的 Shell 线程;suspend 表示线程正在等待,不一定是异常。 + +输入 free 可查看堆内存统计: + + msh >free + total : 125816 + used : 7076 + maximum : 7076 + available: 118740 + +total 是总堆内存,used 是当前已使用内存,maximum 是运行以来的最大使用量,available 是当前可用内存。上述数值会随工程配置和运行状态变化。 + +如果没有出现 MSH 提示符,优先检查串口号、115200 波特率、串口是否被其他软件占用,并确认 RT_USING_FINSH 和 FINSH_USING_MSH 已启用。 + +## 4. RT-Thread 启动流程 + +[RT-Thread 官方编程手册:基础知识与启动流程](https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/programming-manual/basic/basic?id=rt-thread-%e5%90%af%e5%8a%a8%e6%b5%81%e7%a8%8b) + +RT-Thread 启动并不是复位后直接执行用户的 main()。芯片先运行启动文件,完成最基础的硬件和内存准备,再进入 RT-Thread 内核;内核创建线程并启动调度器后,用户 main() 才在主线程中执行。 + +![RT-Thread 启动流程图](assets/01/06-rtthread-startup-flow.png) + +### 4.1 从启动文件进入 RT-Thread + +芯片复位后,启动文件首先运行。不同工具链的入口不同:MDK 使用 $Sub$$main(),IAR 使用 __low_level_init(),GNU GCC 使用 entry()。它们最终都会进入 rtthread_startup()。 + +对本次 STM32F407ZG 的 GCC 工程而言,startup_stm32f407xx.S 先设置栈和内存段、调用 SystemInit(),再调用 entry();entry() 继续调用 rtthread_startup()。 + +### 4.2 rtthread_startup() 完成内核初始化 + +rtthread_startup() 按顺序完成关键准备工作: + +1. 关闭中断,避免初始化尚未完成时发生中断。 +2. 调用 rt_hw_board_init(),完成板级初始化;其中可执行板级自动初始化函数。 +3. 显示 RT-Thread 版本,初始化系统定时器、调度器和信号机制。 +4. 创建主线程、定时器服务线程和空闲线程。 +5. 启动调度器 rt_system_scheduler_start(),由调度器根据优先级运行线程。 + +### 4.3 主线程执行组件初始化与用户 main() + +调度器启动后,主线程执行 main_thread_entry()。它先调用 rt_components_init(),依次完成预初始化、设备、组件、环境和应用等自动初始化函数;随后才调用 applications/main.c 中的 main()。 + +因此可以把完整路径记为:复位 → 启动文件 → rtthread_startup() → 创建系统线程 → 启动调度器 → main_thread_entry() → 组件初始化 → 用户 main()。 + +> 关键理解:用户 main() 不是裸机意义上的第一个 C 函数;在 RT-Thread 中,它运行在已经由内核创建并调度的主线程里。 + +## 5. Git 与 Gitee + +Git 是分布式版本控制工具,用于记录文件的历史修改;Gitee 是托管 Git 仓库的平台。把夏令营笔记放入 Git 仓库后,可以保存版本、同步到远程,并在需要时恢复到历史版本。 + +### 5.1 在 Gitee 创建笔记仓库 + +登录 Gitee 后,点击右上角“+”并选择“新建仓库”。仓库名称可使用 rt-thread-summer-camp-notes,填写简短介绍,并按自己的分享需求选择公开或私有。 + +当前笔记目录已经有 01.md 和图片文件。为了避免首次上传时与线上文件冲突,创建远程仓库时建议不要勾选 README、.gitignore 或许可证初始化选项,创建一个空仓库即可。创建完成后,在仓库页面复制 SSH 地址,格式类似: + + git@gitee.com:你的用户名/rt-thread-summer-camp-notes.git + +仓库名称、归属、路径、可见性和初始化选项的详细说明可参考 [Gitee:创建你的第一个仓库](https://gitee.com/help/articles/4120)。 + +### 5.2 配置账户 SSH 公钥 + +Gitee 可以通过 SSH 协议访问 Git 仓库。配置账户 SSH 公钥后,当前电脑可使用 SSH 地址拉取和推送你有权限的仓库。 + +Windows 建议使用 Windows PowerShell 或 Git Bash;命令提示符不自带 cat 和 ls 命令。 + +#### 5.2.1 生成 SSH 密钥 + +下面命令的目的是生成一对 ED25519 密钥: + + ssh-keygen -t ed25519 -C "Gitee SSH Key" + +命令会依次询问保存位置、密码短语和确认密码短语。直接连续按三次 Enter 可使用默认保存位置且不设置密码短语;若电脑可能被他人使用,建议设置密码短语。 + +生成后会得到两个文件: + +- id_ed25519:私钥,用于证明当前电脑身份。 +- id_ed25519.pub:公钥,用于上传到 Gitee。 + +#### 5.2.2 查看并复制公钥 + +先确认密钥文件已经生成。PowerShell 中执行: + + Get-ChildItem $HOME\.ssh + +读取公钥时,PowerShell 使用: + + Get-Content $HOME\.ssh\id_ed25519.pub + +Git Bash 使用: + + cat ~/.ssh/id_ed25519.pub + +复制输出的整行内容;它通常以 ssh-ed25519 开头。 + +#### 5.2.3 在 Gitee 添加并测试 + +在 Gitee 中依次进入“个人设置 → 安全设置 → SSH 公钥 → 添加公钥”,粘贴公钥内容、填写标题,并按页面要求验证账户密码。添加后,可在同一页面查看或删除已添加的公钥。 + +下面命令的目的是测试当前 SSH 密钥是否已经绑定到 Gitee 账户: + + ssh -T git@gitee.com + +若输出中包含已成功认证和你的用户名,说明 SSH 配置完成;Gitee 不提供 SSH Shell 登录是正常现象。完整官方步骤见 [Gitee:SSH 公钥设置](https://help.gitee.com/base/account/SSH%E5%85%AC%E9%92%A5%E8%AE%BE%E7%BD%AE)。 + +> 安全提示:只能复制和上传以 .pub 结尾的公钥文件;绝不能上传、发送或提交不带 .pub 后缀的私钥文件 id_ed25519。仓库部署公钥与账户 SSH 公钥用途不同,部署公钥请参考 [添加部署公钥](https://help.gitee.com/repository/ssh-key/generate-and-add-ssh-public-key)。 + +### 5.3 Git 的基本同步流程 + +Git 的常用流程可以理解为:本地工作区 → 暂存区 → 本地提交历史 → 远程仓库。 + +1. 初始化当前目录为 Git 仓库。一个目录只需执行一次;已经是 Git 仓库时不要重复初始化。 + + git init + +2. 拉取远程分支的最新提交到本地。下面以 master 为例;实际项目应使用仓库的默认分支名。 + + git pull origin master + +3. 将指定文件或目录加入暂存区。暂存区是“下一次提交的待提交清单”,便于决定本次要提交哪些修改。 + + git add 文件或目录路径 + +4. 把暂存区内容创建为一次本地提交,并使用 -m 写明本次修改说明。 + + git commit -m "本次修改说明" + +5. 将当前本地分支的提交推送到远程仓库。 + + git push origin master + +6. 修改名为 origin 的远程仓库地址。将示例地址替换为自己的 Gitee SSH 仓库地址。 + + git remote set-url origin git@gitee.com:你的用户名/你的仓库.git + +7. 获取远端 master 分支的最新提交和引用,但不自动合并到当前本地分支,也不修改工作区文件。 + + git fetch origin master + + 获取后,远端分支的状态会更新到 origin/master;需要将更新合并到本地时,再使用 git pull 或 git merge。 + +8. 查看所有远程仓库的名称,以及用于拉取和推送的地址。 + + git remote -v + + git remote -v 不显示分支列表;若要查看远端分支,可使用: + + git branch -r + +9. 基于当前分支的当前提交创建名为 test 的新分支,并立即切换到该分支。 + + git checkout -b test + + 创建后可使用下面命令确认,带 * 的分支就是当前所在分支: + + git branch + +同步方向需要记住:git pull 是“远端 → 本地”,git push 是“本地 → 远端”。 + +> 提交完成后,本地 Git 历史中才会保存这次可追溯的版本;仅执行 git add 不等于已经完成版本保存。 + +### 5.4 简单冲突处理 + +当本地与远端修改了同一文件的同一位置时,执行 git pull 或合并分支可能产生冲突。Git 会暂停合并,要求手动决定保留哪部分内容。 + +1. 先查看发生冲突的文件: + + git status + +2. 打开冲突文件,找到以下标记,保留正确内容后删除这三行标记及不需要的内容: + + <<<<<<< HEAD + 本地修改 + ======= + 远端修改 + >>>>>>> origin/master + +3. 将解决后的文件加入暂存区,并创建一次解决冲突的提交: + + git add 冲突文件路径 + git commit -m "resolve merge conflict" + +4. 再继续推送: + + git push + +如果暂时无法判断应该保留哪一边,可放弃本次合并并恢复到合并前状态: + + git merge --abort + +> 处理冲突的关键不是选“本地”或“远端”,而是确认最终文件同时保留所需修改,并且不留下 <<<<<<<、=======、>>>>>>> 标记。 + +### 5.5 Fork 与 Pull Request(PR)流程 + +当自己没有原始仓库的直接写入权限时,使用 Fork 和 PR 提交作业。整体流程是:原始仓库 → 自己的 Fork 仓库 → 本地分支 → Pull Request。 + +1. 在 Gitee 将原始仓库 Fork 到自己的账号。Fork 会创建一份属于自己的远程仓库副本,自己可以向该副本推送分支。 + +2. 克隆自己 Fork 后的仓库到本地。后续提交应推送到自己的 Fork,而不是直接推送到原始仓库。 + +3. 从主分支创建作业分支,例如 test,并在该分支完成修改: + + git checkout -b test + +4. 将修改加入暂存区、创建本地提交,并推送作业分支到自己的 Fork: + + git add 文件或目录路径 + git commit -m "完成作业内容" + git push -u origin test + +5. 在 Gitee 的自己 Fork 仓库中创建 Pull Request,并选择: + + - 来源:自己 Fork 仓库中的 test 作业分支。 + - 目标:原始仓库老师指定的分支,通常是 main 或 master。 + +6. 提交 PR 前,检查文件列表、提交记录、来源分支和目标分支;确认无误后再创建 PR。 + +> 关键理解:PR 不是直接向原始仓库提交代码,而是请求把“自己 Fork 仓库中已推送的作业分支”合并到原始仓库的指定分支。 diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\211\345\244\251\347\254\224\350\256\260.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\211\345\244\251\347\254\224\350\256\260.md" new file mode 100644 index 0000000000000000000000000000000000000000..4ddcf8bfd39d61a6951814dfa27dcca6d5415a27 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\270\211\345\244\251\347\254\224\350\256\260.md" @@ -0,0 +1,452 @@ +# RT-Thread 夏令营笔记03:线程同步与线程间通信(IPC) + +## 目录 + +- [1. 多线程协作的基础概念(Multithread Coordination)](#1-多线程协作的基础概念multithread-coordination) +- [2. 信号量(Semaphore):等待事件或管理多个资源](#2-信号量semaphore等待事件或管理多个资源) +- [3. 互斥量(Mutex):为共享数据加锁](#3-互斥量mutex为共享数据加锁) +- [4. 事件集(Event):等待一个或多个事件](#4-事件集event等待一个或多个事件) +- [5. 邮箱(Mailbox):在线程间传递一个机器字](#5-邮箱mailbox在线程间传递一个机器字) +- [6. 消息队列(Message Queue):传递指定大小的数据块](#6-消息队列message-queue传递指定大小的数据块) +## 1. 多线程协作的基础概念(Multithread Coordination) + +在 RTOS 中,多个线程会交替运行。只要它们会访问同一份资源,或者一个线程需要等另一个线程完成某件事,就需要使用 IPC(Inter-Process Communication,此处也包含线程间同步与通信机制)进行协调。 + +### 1.1 线程同步与线程间通信 + +![IPC 机制:线程同步与线程间通信](assets/03/01-ipc-overview.png) + +- **线程同步**解决“线程何时可以继续执行、谁先谁后”的问题。例如,线程 2 必须等线程 1 完成某项工作后才能继续,或多个线程不能同时修改同一份共享数据。信号量、互斥量和事件集主要用于协调这类执行顺序与资源访问关系。 +- **线程间通信**解决“线程之间传递什么数据”的问题。例如,按键线程把按键状态交给 LED 线程,传感器线程把采样值交给显示线程。邮箱和消息队列用于传递这些数据。 + +两者并非完全割裂:线程接收邮箱或消息队列时,若暂时没有数据也会等待,因此通信机制也能带来同步效果;但区分重点是,**同步侧重执行时机,通信侧重数据传递**。 + +### 1.2 临界区与共享资源 + +**临界区**可以理解为:访问某个共享资源的那一段代码或资源本身,在同一时刻只允许一个线程使用。课程中用“一个房间里有饮水机、健身器材、厕所,多个住户轮流使用”来类比。 + +例如,两个线程都要依次修改 `number1` 和 `number2`,那么“读取、修改并写回这两个变量”的代码必须作为一个整体执行;否则线程可能在中途切换,导致两个变量暂时或长期不一致。 + +### 1.3 阻塞、非阻塞与挂起 + +- **阻塞(blocking)**:线程所需资源暂不可用时,线程等待该资源,暂时不继续执行后续逻辑。 +- **非阻塞(non-blocking)**:线程尝试获取资源;若无法获得,立即返回,由线程自行决定执行其他逻辑还是稍后重试。 +- **挂起状态**:等待 IPC 对象的线程通常会从运行/就绪状态转入挂起状态;资源可用、事件到达或等待超时后,线程回到就绪状态,等待调度器再次运行它。 + +可以把阻塞获取理解为在饮水机前等待接满水;非阻塞获取则类似把杯子交给自动饮水机,随后先去做其他事情,等通知再处理结果。 + +> 注意:阻塞不是“CPU 什么都不做”。当前线程被挂起后,调度器仍可运行其他就绪线程。 + +## 2. 信号量(Semaphore):等待事件或管理多个资源 + +信号量(Semaphore)是内核对象,用一个计数值表示“可用许可”的数量。线程通过**获取**(take)消耗一个许可,通过**释放**(release)增加一个许可,从而实现同步或资源计数。 + +### 2.1 二值信号量与计数信号量 + +| 类型 | 取值范围 | 常见初值 | 适合解决的问题 | 类比 | +| --- | --- | --- | --- | --- | +| 二值信号量 | `0` 或 `1` | 通常为 `0` | 等待一次事件发生 | 红灯停、绿灯行;按键后允许 LED 操作 | +| 计数信号量 | `0` 到最大值 | 可用资源数量 `N` | 管理多个同类资源实例 | 有 `N` 个车位的停车场 | + +二值信号量初值为 `0` 时,表示“事件尚未发生”:等待它的线程会阻塞;另一个线程或中断服务程序释放该信号量后,值变为 `1`,等待线程便可以被唤醒并获取它。 + +计数信号量初值为 `N` 时,表示还有 `N` 个可用资源。每成功获取一次,计数减一;计数到 `0` 后,新的获取请求需要等待其他线程释放资源。 + +### 2.2 信号量的生命周期与等待策略 + +RT-Thread 为信号量提供动态创建和静态初始化两套接口,它们的销毁方式必须配对: + +| 方式 | 创建/初始化 | 结束使用 | 适用理解 | +| --- | --- | --- | --- | +| 动态方式 | `rt_sem_create()` | `rt_sem_delete()` | 内核从堆中创建对象,结束时释放对象 | +| 静态方式 | `rt_sem_init()` | `rt_sem_detach()` | 用户提供控制块,结束时从内核对象管理中脱离 | + +`flag` 用于选择等待队列的组织方式: + +- `RT_IPC_FLAG_FIFO`:先等待的线程优先得到信号量; +- `RT_IPC_FLAG_PRIO`:优先级更高的等待线程优先得到信号量。 + +课程建议:对实时性更敏感的场景,优先使用按优先级等待;只有业务确实强调先来后到时,再考虑 FIFO。 + +```c +/* 动态创建:初值为 0,表示同步事件尚未发生 */ +rt_sem_t sem = rt_sem_create("dsem", 0, RT_IPC_FLAG_PRIO); + +/* 静态初始化:sem_obj 由用户预先定义 */ +rt_sem_init(&sem_obj, "ssem", 0, RT_IPC_FLAG_PRIO); +``` + +### 2.3 获取与释放 + +常用操作如下: + +```c +rt_err_t result; + +/* 最多等待 timeout 个 tick;RT_WAITING_FOREVER 表示一直等待 */ +result = rt_sem_take(sem, timeout); + +/* 不等待:拿不到就立刻返回 */ +result = rt_sem_trytake(sem); +/* 与 rt_sem_take(sem, 0) 的“不等待”效果相同 */ + +/* 释放一个许可,可能唤醒等待该信号量的线程 */ +rt_sem_release(sem); +``` + +`rt_sem_take()` 成功时,信号量值减一;若当前值为 `0`,则根据 `timeout` 决定立即返回、等待有限时间,还是永久等待。`rt_sem_release()` 使计数增加,或直接使符合条件的等待线程恢复就绪。 + + + +### 2.4 信号量示例执行流程 + +```text +在 MSH 输入 semaphore_sample + ↓ +semaphore_sample() 开始执行 + ↓ +创建动态信号量 dsem,初始计数值 = 0 + ↓ +初始化并启动 thread1(优先级 25) + ↓ +初始化并启动 thread2(优先级 24,更高) + ↓ +thread2 等待信号量 + ↓ +thread1 每计数 10 次,释放一次信号量 + ↓ +thread2 被唤醒,number 加 1 + ↓ +thread2 再次等待信号量 + ↓ +重复 10 次 + ↓ +thread1 结束;thread2 最终停在“永久等待信号量” +``` + +线程 1 共释放 10 次信号量,因此线程 2 成功获取 10 次,`number` 最终为 `10`。这里的信号量只传递“可以执行一次”的通知,不传递 `count` 的具体数值。 + +## 3. 互斥量(Mutex):为共享数据加锁 + +互斥量(Mutex)可以看作专门用于保护临界区的“锁”。它在未被持有时为开锁状态;一个线程获取后,互斥量处于闭锁状态,其他线程不能再成功获取,直到持有者释放它。 + +### 3.1 互斥量与二值信号量的关键差异 + +| 特性 | 二值信号量 | 互斥量 | +| --- | --- | --- | +| 主要用途 | 同步通知,也可用于简单互斥 | 保护共享临界区 | +| 所有权 | 无;可由其他线程或中断释放 | 有;只能由持有线程释放 | +| 递归获取 | 不支持;同一线程重复等待可能把自己阻塞 | 支持;内核记录持有次数 | +| 优先级反转处理 | 不提供互斥量的优先级继承语义 | 通过优先级继承缓解优先级反转 | + +“互斥量是特殊的二值信号量”只说明它也具有锁定/解锁的两种状态;实际使用时应重点关注它的所有权、递归计数和优先级继承机制。 + +### 3.2 优先级反转与优先级继承 + +假设线程优先级为 `A > B > C`,共享资源 `M` 被低优先级线程 `C` 持有: + +```text +① C 持有 M 并开始执行 +② A 就绪,尝试获取 M,但 M 尚未释放,因此 A 被阻塞 +③ B 就绪;B 的优先级高于 C,于是抢占 C +④ C 无法继续运行并释放 M,A 只能继续等待 +``` + +此时高优先级线程 `A` 间接被中优先级线程 `B` 延迟,这就是**优先级反转**。 + +互斥量使用**优先级继承协议**缓解该问题:当高优先级线程 `A` 等待 `C` 持有的互斥量时,内核会临时把 `C` 的优先级提高到足以尽快运行并释放锁;待 `C` 释放互斥量后,再恢复其原有优先级。这样 `B` 就不会在这段时间内抢占 `C`,`A` 能更早取得资源。 + +> 注意:优先级继承不是让低优先级线程永久变高,而是持锁期间的临时调整。持锁时间应尽量短;持锁期间也不应随意修改线程优先级,以免破坏该机制的预期效果。 + +### 3.3 常用 API + +互斥量同样支持动态创建和静态初始化: + +```c +/* 动态创建与销毁 */ +rt_mutex_t mutex = rt_mutex_create("mutex", RT_IPC_FLAG_PRIO); +rt_mutex_delete(mutex); + +/* 静态初始化与脱离 */ +rt_mutex_init(&mutex_obj, "mutex", RT_IPC_FLAG_PRIO); +rt_mutex_detach(&mutex_obj); + +/* 获取、非阻塞尝试与释放 */ +rt_mutex_take(mutex, timeout); +rt_mutex_trytake(mutex); +rt_mutex_release(mutex); +``` + +课程强调,互斥量的等待最终按优先级处理,以支持实时性要求;无论如何设置 `flag`,设计上都应把它看作优先级相关的同步对象,而不是依赖严格 FIFO 顺序的队列。 + +### 3.4 临界区保护方式的边界 + +互斥量的做法是:获取失败的**线程**进入挂起,让其他线程和中断仍有机会运行;因此适合长度可控的线程级共享数据保护。 + +课程还对比了更底层的临界区保护方式:某些方式会限制调度或中断响应,适用于对时序要求极高且代码极短的场景。其具体影响取决于所调用的 API 和 RT-Thread 版本;无论使用哪种方式,都不应在受保护区域中加入长时间延时或耗时操作,否则会明显损害系统实时性。 + +### 3.5 互斥量示例:保证两个变量同步 + +示例创建一个互斥量,用它保护共享的 `number1` 与 `number2`。线程 1 和线程 2 都必须在操作这两个变量前先获取锁,结束后释放锁。 + +```text +线程 1:获取 mutex + number1++ + 延时 10 ms + number2++ + 释放 mutex + +线程 2:获取 mutex + 检查 number1 是否等于 number2 + number1++、number2++ + 释放 mutex +``` + +#### 3.5.1 没有完整互斥保护时为什么会不一致 + +如果把线程 1 的互斥量获取和释放注释掉,线程 1 执行完 `number1++` 后会延时 10 ms。此时,优先级更高的线程 2 可能抢占执行: + +```text +线程 1:number1 从 0 加到 1 +线程 1:延时,尚未来得及执行 number2++ +线程 2:读取到 number1 = 1、number2 = 0 +``` + +因此线程 2 会观察到不相等的结果;之后两线程继续交错执行时,`number2` 可能持续比 `number1` 小 1。 + +#### 3.5.2 加上互斥量后为什么能保持一致 + +在线程 1 先获取互斥量后,即使它在 `number1++` 与 `number2++` 之间延时,线程 2 也无法获得同一个互斥量,只能挂起等待。线程 1 完整更新两个变量并释放锁后,线程 2 才能继续检查,因此能够观察到一致的值。 + +```text +线程 1:获取 mutex → number1++ → number2++ → 释放 mutex +线程 2: 获取 mutex → 检查 +``` + +这里的临界区是“成对更新和读取 `number1`、`number2` 的逻辑”,而互斥量是保护该临界区的工具;两者不是同一个概念。 + +## 4. 事件集(Event):等待一个或多个事件 + +事件集(Event)用于表达“某些事件是否已经发生”。一个事件集可由多个线程发送、被多个线程等待,因此适合一对多或多对多的同步场景。 + +### 4.1 用 32 位标志位表示事件 + +事件集内部使用一个 32 位无符号整数保存事件状态:每一位对应一个独立事件;位为 `1` 表示该事件已发生,位为 `0` 表示尚未发生或已被清除。 + +```c +#define EVENT_0 (1U << 0) /* 0x01 */ +#define EVENT_1 (1U << 1) /* 0x02 */ +#define EVENT_3 (1U << 3) /* 0x08 */ +#define EVENT_5 (1U << 5) /* 0x20 */ +``` + +例如,`EVENT_3 | EVENT_5` 的值为 `0x28`,表示同时关心事件 3 与事件 5。事件之间相互独立,线程通过位运算把需要关注的一个或多个事件组合起来。 + +### 4.2 逻辑或、逻辑与与清除标记 + +线程接收事件时,可用不同选项描述“什么条件满足后才继续运行”: + +| 选项 | 含义 | 公交站类比 | +| --- | --- | --- | +| `RT_EVENT_FLAG_OR` | 等待集合中任意一个事件发生即可返回 | 有多条公交可到目的地,任来一辆即可出发 | +| `RT_EVENT_FLAG_AND` | 等待集合中所有事件都发生才返回 | 同伴到站且公交到站,两个条件都满足才出发 | +| `RT_EVENT_FLAG_CLEAR` | 成功接收后,清除本次接收对应的事件位 | 已处理的通知不再保留给下一次等待 | + +事件集只负责**同步**,不携带业务数据;需要传递数值、指针或数据块时,应使用邮箱或消息队列。事件也没有排队性:同一个事件位在尚未被处理前重复置位,结果仍然只是 `1`,不会累计为两次通知。 + +### 4.3 生命周期与常用 API + +事件集同样有动态与静态两种创建方式,销毁接口需要配对: + +```c +/* 动态创建与删除 */ +rt_event_t event = rt_event_create("event", RT_IPC_FLAG_PRIO); +rt_event_delete(event); + +/* 静态初始化与脱离 */ +rt_event_init(&event_obj, "event", RT_IPC_FLAG_PRIO); +rt_event_detach(&event_obj); + +/* 发送事件:置位 EVENT_3 */ +rt_event_send(event, EVENT_3); + +/* 接收事件:recved 保存实际收到的事件位 */ +rt_uint32_t recved; +rt_event_recv(event, EVENT_3 | EVENT_5, + RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, + RT_WAITING_FOREVER, &recved); +``` + +`rt_event_send()` 的第二个参数是要置位的事件掩码;多个事件可用按位或组合。`rt_event_recv()` 的关键参数依次是:等待掩码、接收选项、超时时间,以及保存实际接收事件的输出变量。 + +### 4.4 事件集示例:先等事件 3 或 5,再等两者同时发生 + +课程示例静态初始化一个事件对象,并创建两个线程:线程 1 接收事件,线程 2 依次发送事件 3、事件 5、事件 3。 + +```text +线程 1:以 OR | CLEAR 等待 EVENT_3 或 EVENT_5 +线程 2:发送 EVENT_3 +线程 1:被唤醒,收到 0x08;随后延时 1 秒 + +线程 2:发送 EVENT_5,再延时 200 ms,发送 EVENT_3 +线程 1:以 AND | CLEAR 等待 EVENT_3 和 EVENT_5 +线程 1:两位均置位后被唤醒,收到 0x28 +``` + +关键接收逻辑如下: + +```c +rt_uint32_t recved; + +/* 第一次:事件 3、事件 5 任意发生即可继续 */ +rt_event_recv(&event, EVENT_3 | EVENT_5, + RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, + RT_WAITING_FOREVER, &recved); + +rt_thread_mdelay(1000); + +/* 第二次:必须同时具备事件 3 和事件 5 */ +rt_event_recv(&event, EVENT_3 | EVENT_5, + RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR, + RT_WAITING_FOREVER, &recved); +``` + +第一次收到 `EVENT_3` 后,`CLEAR` 会清除该位;所以第二次以 `AND` 等待时,线程 2 先发送 `EVENT_5` 仍不足以唤醒线程 1,必须再发送一次 `EVENT_3`,两位同时为 `1` 后才满足条件。 + +> 调试建议:在两处 `rt_event_recv()`、三处 `rt_event_send()` 和 `recved` 的打印处断点。先观察线程 1 因事件尚未发生而挂起,再观察 `OR` 与 `AND` 两次返回条件的差异。 + +## 5. 邮箱(Mailbox):在线程间传递一个机器字 + +邮箱(Mailbox)用于在线程之间传递单个机器字宽度的值,开销较低。课程以“按键检测线程把按键状态发到邮箱,LED 线程从邮箱读出状态后控制亮灭”为例;也可以让多个传感器线程把数据或数据指针发送给 LCD 线程。 + +在常见的 32 位 MCU 上,一封邮件通常为 4 字节,即一个 `rt_ubase_t` 宽度,能够存放一个 32 位整数或一个指针。这个“4 字节”是 32 位平台的结果;跨平台理解时,应以 `rt_ubase_t` 的宽度为准。 + +### 5.1 发送、接收与紧急邮件 + +| 操作 | API | 行为 | +| --- | --- | --- | +| 立即发送 | `rt_mb_send()` | 邮箱未满时立即写入;满时立即返回错误 | +| 等待发送 | `rt_mb_send_wait()` | 邮箱满时可等待空位,等待时间由 `timeout` 指定 | +| 紧急发送 | `rt_mb_urgent()` | 把邮件插入队首,使其优先被接收 | +| 接收 | `rt_mb_recv()` | 邮箱为空时可按 `timeout` 阻塞等待 | + +普通发送常采用非阻塞方式,因此适合由中断服务程序、定时器或线程向接收线程投递一个简短通知或值;接收一般应放在普通线程中,因为邮箱为空时可能需要等待。 + +```c +rt_ubase_t value; + +/* 发送一个整数或指针值 */ +rt_mb_send(mb, (rt_ubase_t)value_to_send); + +/* 接收的第二个参数是保存邮件值的地址 */ +rt_mb_recv(mb, &value, RT_WAITING_FOREVER); +``` + +### 5.2 创建方式 + +| 方式 | 创建/初始化 | 结束使用 | 额外参数 | +| --- | --- | --- | --- | +| 动态方式 | `rt_mb_create(name, size, flag)` | `rt_mb_delete()` | `size` 为可容纳的邮件数量 | +| 静态方式 | `rt_mb_init(mb, name, msgpool, size, flag)` | `rt_mb_detach()` | 需要用户提供 `msgpool` 邮箱缓冲区 | + +静态邮箱的缓冲区应能容纳 `size` 个 `rt_ubase_t` 元素。例如,以 `sizeof(mb_pool) / sizeof(mb_pool[0])` 计算邮件数量,比直接把“除以 4”写死更稳妥。 + +### 5.3 邮箱示例:交替传递字符串指针 + +示例静态创建邮箱,定义两条普通字符串和结束字符串 `"over"`。线程 2 轮流向邮箱发送前两条字符串;线程 1 永久等待接收并打印。普通字符串各发送 5 次后,线程 2 再发送 `"over"`,线程 1 收到该结束标记后退出并脱离邮箱。 + +```text +线程 2:str1、str2、str1、str2 ……(各 5 次)→ "over" + ↓ +邮箱:保存每个字符串的首地址 + ↓ +线程 1:接收地址 → 打印字符串 → 收到 "over" 后结束 +``` + +简化后的接收逻辑如下: + +```c +char *str; + +while (1) +{ + rt_mb_recv(&mb, (rt_ubase_t *)&str, RT_WAITING_FOREVER); + rt_kprintf("%s\\n", str); + + if (rt_strcmp(str, "over") == 0) + break; +} +``` + +这个例子说明邮箱只传递指针值:字符串常量本身在程序整个运行期间有效,所以接收线程可安全打印;若换成临时缓冲区,则需要另行保证其生命周期。 + +> 调试建议:观察邮箱缓冲区中的值会看到地址而不是字符串内容。沿着该地址查看内存,才能看到实际的字符序列。 + +## 6. 消息队列(Message Queue):传递指定大小的数据块 + +消息队列(Message Queue)可看作邮箱的扩展:邮箱每封邮件只能传递一个机器字,而消息队列能传递用户在创建时指定大小的消息块,适合结构体、传感器采样帧或串口接收缓冲数据等内容。 + +> 一个消息队列的**单条消息大小在创建时确定**,并不是每条消息都可任意变长。创建时应根据最大需要传递的数据块设置 `msg_size`;不同队列可以设置不同的消息大小。 + +### 6.1 队列如何管理消息 + +消息队列使用消息块和链表管理缓存: + +```text +空闲消息链表 ──取出一个空闲块──→ 写入消息 ──→ 消息队列尾部 + ↓ +接收线程 ←──复制消息到接收缓冲区 ←── 取出队首消息 + ↓ + 消息块回到空闲链表 +``` + +当没有空闲消息块时,队列已满;普通发送会返回错误,等待发送则可等待接收方取走消息并释放空闲块。 + +### 6.2 创建、发送与接收 API + +```c +/* 动态创建:单条消息大小为 msg_size,最多 max_msgs 条 */ +rt_mq_t mq = rt_mq_create("mq", msg_size, max_msgs, RT_IPC_FLAG_PRIO); +rt_mq_delete(mq); + +/* 静态初始化:pool_size 是消息池总字节数 */ +rt_mq_init(&mq_obj, "mq", msgpool, msg_size, pool_size, RT_IPC_FLAG_PRIO); +rt_mq_detach(&mq_obj); + +/* 普通发送、等待发送、紧急发送与接收 */ +rt_mq_send(mq, buffer, size); +rt_mq_send_wait(mq, buffer, size, timeout); +rt_mq_urgent(mq, buffer, size); +rt_mq_recv(mq, recv_buffer, size, timeout); +``` + +普通发送将消息放到队尾;`rt_mq_urgent()` 把消息插到队首,因此下一次接收优先得到该紧急消息。接收时必须提供接收缓冲区,内核会把队首消息复制到该缓冲区中。 + +### 6.3 消息队列示例:紧急消息优先读取 + +课程示例静态创建消息队列,将单条消息大小设为 1 字节。线程 2 按顺序发送普通字符消息;线程 1 以永久等待方式接收并打印。在线程 1 的一次较长延时期间,线程 2 持续投递普通消息,并在指定计数处发送紧急消息 `i`。 + +```text +普通队列顺序:b → c → d → e → f → g → h +紧急发送 i 后:i → b → c → d → e → f → g → h + ↑ + 插入队首,下一次优先接收 +``` + +运行时,线程 1 先接收到 `a`;随后线程 2 发送了多个普通消息和紧急消息 `i`。因此线程 1 下一次接收的不是原本队首的 `b`,而是插队后的 `i`;再之后才按原有顺序接收 `b`、`c` 等普通消息。 + +```c +char msg; + +/* 线程 2:普通消息入队尾 */ +rt_mq_send(&mq, &msg, sizeof(msg)); + +/* 线程 2:紧急消息插入队首 */ +msg = 'i'; +rt_mq_urgent(&mq, &msg, sizeof(msg)); + +/* 线程 1:从队首取一条消息 */ +rt_mq_recv(&mq, &msg, sizeof(msg), RT_WAITING_FOREVER); +``` + +> 调试建议:在普通发送、`rt_mq_urgent()` 和 `rt_mq_recv()` 处断点,观察消息链表的队首/队尾变化。重点验证:紧急消息只改变当前队列的读取顺序,并不会丢失已经入队的普通消息。 diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\214\345\244\251\347\254\224\350\256\260.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\214\345\244\251\347\254\224\350\256\260.md" new file mode 100644 index 0000000000000000000000000000000000000000..df42dd7b1f6512468ede375bd82940370f74f81a --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\214\345\244\251\347\254\224\350\256\260.md" @@ -0,0 +1,573 @@ +# RT-Thread 夏令营笔记02:RTOS、线程机制与 SCons 构建系统 + + +## 目录 + +- [1. 从裸机前后台系统到 RTOS](#1-从裸机前后台系统到-rtos) +- [2. RT-Thread 从复位到用户 main 函数](#2-rt-thread-从复位到用户-main-函数) +- [3. 线程控制块与线程栈](#3-线程控制块与线程栈) +- [4. 线程生命周期](#4-线程生命周期) +- [5. 调度:优先级与时间片](#5-调度优先级与时间片) +- [6. 两个关键系统线程](#6-两个关键系统线程) +- [7. 线程创建、启动与让出 CPU](#7-线程创建启动与让出-cpu) +- [8. SCons 构建系统](#8-scons-构建系统) +- [9. Studio 工程创建、调试与线程示例](#9-studio-工程创建调试与线程示例) + +## 1. 从裸机前后台系统到 RTOS + +### 1.1 裸机前后台模型的阻塞问题 + +裸机程序常见为“前后台”模型: + +- **后台**:在 `while (1)` 循环中按顺序执行读取传感器、SPI 通信、LCD 刷新等任务。 +- **前台**:由串口接收、SPI 接收等硬件中断触发的中断服务程序(ISR)。中断可暂时打断 CPU 正在执行的后台代码。 + +这种模式适合任务少、逻辑简单的项目,但后台任务本质上仍是串行执行。例如,SPI 任务为了等待一个标志位而阻塞 1 秒时,排在后面的 LCD 刷新任务也会被延后;即使 LCD 本应高频刷新,实际画面也可能卡顿。 + +裸机模式的常见局限如下: + +| 局限 | 原因 | 结果 | +| --- | --- | --- | +| 并发效率低 | 后台任务依次运行,长任务会占住 CPU | 其他任务难以及时处理 | +| 实时性受影响 | 阻塞或长延时会传递给后续任务 | 高优先级业务可能错过响应时机 | +| 可维护性较差 | 业务逻辑常交织在一个主循环中 | 修改一项功能可能影响多处流程 | +| 可重用性较差 | 模块边界不清晰、硬件和业务耦合 | 换项目或换芯片时容易重复改写 | + +> 中断能处理紧急事件,但不能替代完整的任务组织方式。中断服务程序通常应尽量短小,不宜把耗时或可能阻塞的业务全部放入其中。 + +### 1.2 RTOS 的分而治之思路 + +RTOS(Real-Time Operating System,实时操作系统)的基本思路是**分而治之**:将一个复杂系统拆成多个职责单一的线程(也常称任务),再由内核调度器决定当前执行哪个线程。 + +![裸机前后台模型与 RTOS 线程调度的对比](assets/02/01-裸机和RTOS的区别.png) + +例如可以拆分为: + +- SPI 接收线程:接收 W5500 等模块的数据; +- 串口通信线程:处理 AT 指令或其他串口报文; +- LCD 刷新线程:按设定节奏刷新显示; +- 业务处理线程:完成协议解析和控制逻辑。 + +单核 MCU 在任一时刻仍然只执行一个线程;所谓“多个任务同时运行”,是调度器在不同线程间快速切换后带来的宏观效果。重要的是,RTOS 中的延时通常会让**当前线程**暂时挂起并让出 CPU,调度器可转而运行其他就绪线程;它不同于裸机中常见的忙等延时。 + +## 2. RT-Thread 从复位到用户 main 函数 + +RT-Thread 中的用户 `main()` 并不是芯片复位后直接运行的第一个 C 函数。内核必须先完成板级初始化、创建系统线程并启动调度器,随后用户代码才在 Main 线程中执行。 + +先通过下面的全景流程定位各小节的内容: + +```text +① 芯片复位 + → 汇编启动文件完成最小运行环境准备 + → 工具链对应的早期 C 入口接入 RT-Thread + → RT-Thread 系统启动 (见 2.1) + +② RT-Thread 完成内核初始化 + → 初始化板级硬件、系统时钟与调度器 + → 创建 Main、Timer、Idle 等系统线程 + → 启动调度器 (见 2.2) + +③ 调度器选择最高优先级的就绪线程 + → 先运行优先级更高的系统线程(如已启用的 Timer 线程) + → Main 线程获得运行机会 (见 2.3) +``` + +![RT-Thread 启动流程(函数级参考)](assets/02/02-rtthread-startup-flow.png) + +### 2.1 芯片启动与工具链入口 + +本节对应全景流程的 **①**,只解释“芯片复位后如何进入 `rtthread_startup()`”;系统线程创建、调度器启动和用户 `main()` 的执行分别在 2.2、2.3 节说明。 + +芯片复位后,通常先从汇编启动文件( STM32 工程中的 `startup_*.S`)开始。它负责最早期的准备,如设置初始栈、初始化 `.data`/`.bss` 段,并进行 `SystemInit()` 等芯片级初始化。准备完成后,程序才具备稳定运行 C 代码的基础。 + +MDK、IAR、GCC 是**编译阶段使用的工具链**,典型入口有:MDK 的 `$Sub$$main()`、IAR 的 `__low_level_init()`、GCC BSP 中常见的 `entry()`,它们会为程序提供或配合不同的早期 C 入口;RT-Thread 利用这个入口,在用户 `main()` 之前调用 `rtthread_startup()`。这些工具链的共同目标是**从汇编启动文件进入早期 C 代码,并在用户 main() 前接入 RT-Thread 初始化。** 对 GCC 工程排查启动问题时,可从 `startup_*.S`、`entry()` 和 `rtthread_startup()` 依次跟踪。 + +### 2.2 rtthread_startup 的主要工作 + +`rtthread_startup()` 负责把芯片从“能运行 C 代码”带到“能调度线程”。典型过程如下;每一步的函数名与图对应: + +1. **关闭全局中断**(`rt_hw_interrupt_disable()`):避免内核和硬件尚未准备完成时被中断打断。 +2. **板级初始化**(`rt_hw_board_init()`):完成时钟、堆、控制台串口等底层准备;其中可调用 `rt_components_board_init()`,执行已注册的 board init functions。 +3. **输出 Logo**(`rt_show_version()`):串口控制台准备好后,打印 RT-Thread 的版本和 Logo。 +4. **初始化系统时钟**(`rt_system_timer_init()`):系统 Tick 为延时、超时和时间片提供基础。 +5. **初始化调度器**(`rt_system_scheduler_init()`):建立就绪线程等调度管理结构,供后续选择可执行线程。 +6. **初始化信号机制(可选)**(`rt_system_signal_init()`):是否执行取决于是否启用信号功能。 +7. **创建 Main 线程**(`rt_application_init()`):其入口函数为 `main_thread_entry()`;实际使用静态还是动态方式创建取决于当前 BSP。 +8. **创建 Timer 服务线程(可选)**(`rt_system_timer_thread_init()`):其入口函数为 `rt_thread_timer_entry()`;未启用软件定时器功能时不会创建。 +9. **创建 Idle 空闲线程**(`rt_thread_idle_init()`):其入口函数为 `rt_thread_idle_entry()`。Idle 本质上也是系统线程;当系统中没有其他就绪线程可运行、CPU 处于空闲状态时,调度器就会运行它。 +10. **启动调度器**(`rt_system_scheduler_start()`):内核开始按调度规则运行线程。 + +> 初始化期间关闭中断不等于系统永远不响应中断。启动过程完成后,系统会恢复正常的中断与调度运行。 + +### 2.3 主线程、自动初始化与用户代码 + +调度器启动时会选择当前就绪线程中优先级最高的线程。默认配置下,Timer 线程若启用通常优先级较高;没有更高优先级就绪线程时,Main 线程会开始运行。 + +Main 线程的入口并非立刻执行用户 `main()`,而是先完成自动初始化机制: + +```text +Main 线程入口 + → 执行自动初始化 + → 依次完成预初始化、设备、组件、环境和应用初始化 + → 用户 main() +``` + +开发者可通过不同阶段的初始化宏注册驱动、组件或应用初始化函数。`rt_components_init()` 会按照配置和初始化阶段统一调用这些函数,因此不必在 `main()` 中手动逐个调用。完成这些准备后,才进入 `applications/main.c` 中的用户 `main()`。 + +这也说明:在 RT-Thread 中,`main()` 本质上运行在一个由内核创建和调度的线程中,而不是裸机意义上的唯一程序入口。 + +### 2.4 在 01_hello_stm32 工程中定位启动流程 + +以下位置基于本机 `D:\RT-ThreadStudio\workspace\01_hello_stm32` 工程。阅读源码时,建议先沿着“启动汇编 → `entry()` → `rtthread_startup()` → `main_thread_entry()` → 用户 `main()`”这条主线跟踪;其余文件用于展开每一步的具体实现。 + +| 流程阶段 | 当前工程中的文件 | 关键函数或内容 | +| --- | --- | --- | +| 芯片复位与最早期初始化 | `libraries/CMSIS/Device/ST/STM32F4xx/Source/Templates/gcc/startup_stm32f407xx.S` | `Reset_Handler`,设置初始栈、初始化内存段,并跳转到 C 入口。 | +| GCC 早期 C 入口 | `rt-thread/src/components.c` | `entry()` 调用 `rtthread_startup()`。同一文件也包含 MDK/IAR 对应入口的条件编译代码。 | +| RT-Thread 启动主流程 | `rt-thread/src/components.c` | `rtthread_startup()`:关闭中断、板级初始化、定时器与调度器初始化、创建系统线程、启动调度器。 | +| 板级硬件初始化 | `drivers/board.c` | `rt_hw_board_init()`:堆、时钟、串口控制台等板级初始化的具体实现。 | +| board 自动初始化 | `rt-thread/src/components.c` | `rt_components_board_init()`:执行已注册的 board init functions。 | +| 创建 Main 线程 | `rt-thread/src/components.c` | `rt_application_init()`:根据是否启用堆,调用 `rt_thread_create()` 或 `rt_thread_init()` 创建 Main 线程。 | +| Main 线程与自动初始化 | `rt-thread/src/components.c` | `main_thread_entry()` 调用 `rt_components_init()`,然后进入用户 `main()`。 | +| 系统线程实现 | `rt-thread/src/timer.c`、`rt-thread/src/idle.c` | `rt_system_timer_thread_init()` 创建 Timer 服务线程;`rt_thread_idle_init()` 创建 Idle 线程。 | +| 调度器实现 | `rt-thread/src/scheduler.c` | `rt_system_scheduler_start()` 启动调度器;该文件还包含就绪队列选择与调度逻辑。 | +| 线程创建实现 | `rt-thread/src/thread.c` | `rt_thread_init()`、`rt_thread_create()`、`rt_thread_startup()` 等线程 API 的具体实现。 | +| 用户应用入口 | `applications/main.c` | 用户编写的 `main()`,由 Main 线程在自动初始化完成后调用。 | + +> 不同 RT-Thread 版本或 BSP 的目录、代码行号可能不同;排查当前工程时,以函数名搜索和调用关系为准。 + +## 3. 线程控制块与线程栈 + +### 3.1 线程由哪些部分构成 + +线程控制块(Thread Control Block,TCB)是内核管理线程的数据结构。在 RT-Thread 中,线程控制块由 `struct rt_thread` 定义;每创建一个线程,就会有一个对应的 `struct rt_thread` 作为它的线程内核对象。 + +多个线程内核对象会链接到内核的**对象容器**中统一管理。图中的“线程对象链表”通常是**双向链表**:每个 `struct rt_thread` 都通过内嵌的 `parent`(`struct rt_object`)携带链表节点,节点包含前驱和后继链接,因此内核可方便地将线程插入、删除或遍历。这个对象容器链表用于管理已注册的线程对象;它与调度器使用的就绪线程队列不是同一个概念。 + +![线程内核对象与对象容器链表](assets/02/03-线程内核对象.png) + +从运行视角,可以将一个线程概括为: + +```text +线程控制块(记录名称、状态、优先级、入口函数和参数等管理信息) + + +线程栈(保存局部变量与线程切换现场的独立内存空间) +``` + +其中,入口函数 `entry` 和入口参数 `parameter` 是**记录在线程控制块中的信息**,并不是与线程控制块并列的独立内存对象。 + +下面是课堂对应版本中 `struct rt_thread` 的核心字段定义(不同 RT-Thread 版本或配置可能增减部分字段): + +```c +struct rt_thread +{ + struct rt_object parent; /* 内核对象父类:名称、类型、链表节点等 */ + + /* 栈与入口 */ + void *sp; /* 当前栈指针 */ + void *entry; /* 线程入口函数 */ + void *parameter; /* 传给入口函数的参数 */ + void *stack_addr; /* 线程栈起始地址 */ + rt_uint32_t stack_size; /* 线程栈大小 */ + + /* 状态与调度 */ + rt_err_t error; /* 线程错误代码 */ + rt_uint8_t stat; /* 线程状态 */ + rt_uint8_t current_priority; /* 当前优先级 */ + rt_uint8_t init_priority; /* 初始优先级 */ + rt_uint32_t number_mask; /* 调度器内部的优先级位图/掩码信息 */ + + ... + + rt_ubase_t init_tick; /* 初始时间片计数值 */ + rt_ubase_t remaining_tick; /* 剩余时间片计数值 */ + + /* 定时与退出清理 */ + struct rt_timer thread_timer; /* 内置线程定时器 */ + void (*cleanup)(struct rt_thread *tid); /* 线程退出清理函数 */ + rt_uint32_t user_data; /* 用户自定义数据 */ +}; +``` + +> 阅读源码时,优先关注 `sp`(切换现场)、`entry`(业务入口)、`stat`/优先级/时间片(调度条件),以及 `thread_timer` 和 `cleanup`(等待与退出)。 + +### 3.2 线程栈 + +每个线程都有自己的栈。除了函数局部变量外,线程被切走时的寄存器现场、程序执行位置等上下文也需要保存,才能在以后从正确位置继续执行。 + +以线程 A 切换到线程 B 为例: + +1. 内核把线程 A 的寄存器现场等上下文保存到 A 自己的栈中,常称为“压栈”或“保存上下文”。 +2. 内核加载线程 B 之前保存的上下文,包括 B 的栈指针。 +3. CPU 从线程 B 上次暂停的位置继续运行;如果 B 是第一次运行,则从它的入口函数开始。 +4. 以后切回线程 A 时,再从 A 的栈中恢复现场,A 能接着之前的位置继续执行。 + +线程 A 切换到线程 B 的完整过程可以简记为: + +```text +线程 A 正在运行 + ↓ +将 A 的寄存器现场压入 A 的线程栈,并保存 A 的 SP + ↓ +将更新后的 A 的 SP 记录到 A 的线程控制块 + ↓ +调度器选中线程 B + ↓ +CPU 的 SP → 指向线程 B 的栈 + ↓ +恢复 B 的寄存器和 PC + ↓ +CPU 继续执行线程 B 的代码 +``` + +如果没有这套保存与恢复机制,线程 A 在中途被切走后,就无法知道返回时该从哪条指令、哪个局部变量状态继续运行。 + +线程栈大小必须与线程业务相匹配。大量局部变量、深层函数调用和递归都会增加栈占用;栈太小可能产生栈溢出、内存踩踏或难以复现的异常。嵌入式开发中应尽量避免不受控的递归,并结合线程栈使用情况调整栈大小。 + +## 4. 线程生命周期 + +### 4.1 五种状态与转换 + +课堂将 RT-Thread 线程生命周期概括为五种状态:初始态、就绪态、运行态、挂起态和关闭态。 + +```text +创建线程 + → 初始态 + -- rt_thread_startup() --> 就绪态 + -- 被调度器选中 --> 运行态 + -- 延时或等待资源 --> 挂起态 + -- 等待条件满足 --> 就绪态 + -- 线程退出 --> 关闭态 +``` + +![RT-Thread 线程状态转换与常见 API](assets/02/04-线程状态转换.png) + + +各状态可这样理解: + +| 状态 | 含义 | 常见转移原因 | +| --- | --- | --- | +| 初始态 | 线程对象已创建,但尚未参与调度 | 调用 `rt_thread_startup()` 后变为就绪态 | +| 就绪态 | 线程具备运行条件,等待调度器选择 | 被选中后开始运行 | +| 运行态 | 当前正在占用 CPU 执行业务代码 | 被抢占、时间片用完、延时或等待资源时离开运行态 | +| 挂起态 | 暂时不能运行,正在等待时间或某个条件 | 延时到期、信号量释放、收到消息等后重新就绪 | +| 关闭态 | 线程已结束,不再参与调度 | 后续由系统完成资源清理 | + +使运行线程进入等待/挂起状态的常见情形包括:调用延时函数、等待信号量、等待互斥量、等待事件、接收邮箱或消息队列。对应资源就绪或超时后,线程会回到就绪态,等待下一次被调度。 + +> 创建线程不等于线程已经运行。只有将线程启动并放入就绪队列后,它才有机会被调度器执行。 + +### 4.2 线程关闭后的回收 + +线程的入口函数执行结束,或显式退出后,会进入关闭态。关闭态线程不再参与调度,但其线程控制块和动态申请的栈空间未必在同一时刻释放。 + +RT-Thread 中的 Idle 线程会检查已关闭的线程,并负责后续回收工作。对动态创建的线程,这一步通常包含释放其从堆中申请的内存;线程退出时注册的清理钩子也可用于执行用户自定义的清理操作。 + +因此要区分两个概念: + +- **线程退出**:业务代码已经结束,线程被标记为关闭,不再调度。 +- **资源回收**:由 Idle 线程在适当时机处理关闭线程及其动态资源。 + +## 5. 调度:优先级与时间片 + +### 5.1 抢占式优先级调度 + +线程优先级表示线程被调度的优先程度。RT-Thread 采用基于优先级的抢占式调度:**优先级数值越小,优先级越高**,因此优先级 1 高于优先级 5。更重要、响应更紧急的任务通常应配置更高优先级。 + +系统支持的优先级数量由 `RT_THREAD_PRIORITY_MAX` 配置决定,最大可配置为 256 级;若配置为 32 级,则有效优先级为 0~31。资源紧张的系统可选择 8 级或 32 级等更少的优先级,以减少内核调度数据的资源占用。最低优先级默认保留给 Idle 线程,用户业务线程一般不使用。 + +当一个比当前线程优先级更高的线程变为就绪态时,内核会换出当前线程,优先执行高优先级线程,这就是“抢占”。高优先级线程运行结束、挂起或等待资源后,调度器才会重新选择下一个最高优先级的就绪线程。具体可用的优先级数量请以工程的 `menuconfig` 或配置头文件为准。 + +### 5.2 相同优先级时的时间片轮转 + +时间片(time slice)只对**优先级相同且同时处于就绪态**的线程生效。不同优先级线程之间始终先比较优先级,不会因为时间片而让低优先级线程抢先运行。 + +当多个同优先级线程都就绪时,系统采用时间片轮转:每个线程一次连续运行的最长时长由其时间片决定;用完后,调度器会轮到同优先级的下一个就绪线程。时间片以系统 Tick(OS Tick)为单位;Tick 周期由系统配置决定,例如某工程可能设为 1 ms 或 10 ms,不能脱离具体配置固定理解。 + +例如线程 A、B 的优先级相同,且此时没有更高优先级的就绪线程: + +- A 的时间片为 10 Tick; +- B 的时间片为 5 Tick。 + +若图示时刻 `t1` 先轮到 B,则执行顺序为:B 运行 5 Tick → A 运行 10 Tick → B 再运行 5 Tick → A 再运行 10 Tick,循环轮转。时间片限制的是同优先级线程**单次连续运行时长**,不能突破更高优先级线程的抢占规则。 + +![同优先级线程的时间片轮转调度](assets/02/05-优先级与时间片调度.png) + +## 6. 两个关键系统线程 + +### 6.1 Idle 空闲线程 + +Idle 是 RT-Thread 创建的空闲线程,具有以下特点: + +- 它使用系统中最低优先级;例如 32 级优先级配置时通常为 31。 +- 它始终为就绪态,以保证所有其他线程都不能运行时,CPU 仍有线程可执行。 +- 它通常是无限循环,不能像普通业务线程一样被永久挂起或删除。 + +Idle 线程的主要职责有两类: + +1. **回收资源**:检查并回收处于关闭态的线程,尤其是动态线程申请的内存。 +2. **执行空闲钩子**:可用于低功耗休眠、喂狗等仅适合在系统空闲时做的工作。 + +Idle 钩子只会在系统没有其他就绪线程时运行,因此适合执行很短的空闲处理:例如让 CPU 进入低功耗休眠状态。 + +也可以把“喂狗”放入 Idle 钩子:若某个异常循环或高优先级线程长期占用 CPU,Idle 无法运行、看门狗得不到喂狗,硬件便可复位系统。此策略的前提是系统正常运行时 Idle 会周期性获得执行机会;若正常负载本来就会长期占满 CPU,则可能误触发看门狗复位。 + +> Idle 钩子必须短小且不能阻塞,否则会延迟资源回收,并影响系统进入真正的空闲状态。 + +### 6.2 Main 主线程 + +Main 线程也是系统启动阶段创建的线程。它负责执行组件自动初始化,并最终调用用户编写的 `main()`: + +```text +创建 Main 线程 + → 调度器选中 Main 线程 + → rt_components_init() + → 用户 main() +``` + +因此,即使源码形式上仍写作 `int main(void)`,运行时它已经处于 RT-Thread 的线程调度体系内。用户在 `main()` 中创建其他线程、初始化应用模块或启动业务逻辑,都是在这一基础上进行的。 + +## 7. 线程创建、启动与让出 CPU + +### 7.1 两种创建方式:静态与动态 + +RT-Thread 的线程和许多 IPC 对象都支持两种内存分配方式:**静态创建**和**动态创建**。两者都是合法选择,区别主要在于线程控制块和线程栈由谁提供、内存占用能否在编译阶段确定。 + +| 对比项 | 静态创建 | 动态创建 | +| --- | --- | --- | +| 创建 API | `rt_thread_init()` | `rt_thread_create()` | +| 线程控制块与栈 | 由应用预先定义并传入 | 内核从系统堆中申请 | +| 栈起始地址参数 | 需要调用者提供 | 不需要,内部自动分配 | +| 编译/链接期内存估算 | 较明确,RAM 占用具有确定性 | 取决于运行时申请情况 | +| 主要风险 | 栈大小固定,预留过大浪费 RAM | 堆不足时创建失败,长期反复申请/释放可能产生碎片 | +| 适用倾向 | 高可靠、资源可预测或禁止动态内存的场景 | 一般应用中按需创建、生命周期灵活的任务 | + +“静态”指内存由用户提前准备,并不表示线程只能在系统刚启动时创建;“动态”指创建时从堆中申请内存,也不表示线程一定比静态线程优先级高。 + +对于高安全或内存确定性要求高的场景,常要求全静态分配,以便在编译/链接阶段评估内存布局。普通应用可以使用动态、静态或两者混合的方式;关键是评估堆容量、线程栈和线程生命周期。 + +### 7.2 静态创建:rt_thread_init + +静态创建前需要由应用准备线程控制块和一块满足对齐要求的栈数组。`rt_thread_init()` 的函数原型如下: + +```c +/** + * @brief 初始化一个静态线程,使其进入初始态。 + * @return RT_EOK 初始化成功。 + * @return -RT_ERROR 初始化失败。 + */ +rt_err_t rt_thread_init( + struct rt_thread *thread, /* 用户提供的线程控制块指针 */ + const char *name, /* 线程名称;最长为 RT_NAME_MAX,超长自动截断 */ + void (*entry)(void *parameter), /* 线程入口函数 */ + void *parameter, /* 传给入口函数的参数 */ + void *stack_start, /* 用户提供的线程栈起始地址,需按要求对齐 */ + rt_uint32_t stack_size, /* 线程栈大小,单位:字节 */ + rt_uint8_t priority, /* 优先级;数值越小越高,范围由 RT_THREAD_PRIORITY_MAX 决定 */ + rt_uint32_t tick); /* 时间片大小,单位:系统 Tick;仅同优先级就绪线程间生效 */ +``` + +`rt_thread_init()` 成功后,线程仍处于初始态;还需要调用 `rt_thread_startup()`,线程才会进入就绪态并有机会被调度。 + +### 7.3 动态创建:rt_thread_create + +动态创建不需要传入线程控制块和栈地址,内核会从系统堆中为它们分配空间。函数原型如下: + +```c +/** + * @brief 从系统堆中创建一个动态线程,使其进入初始态。 + * @return 非 RT_NULL:创建成功,返回线程句柄。 + * @return RT_NULL:创建失败,通常表示堆空间不足。 + */ +rt_thread_t rt_thread_create( + const char *name, /* 线程名称;最长为 RT_NAME_MAX,超长自动截断 */ + void (*entry)(void *parameter), /* 线程入口函数 */ + void *parameter, /* 传给入口函数的参数 */ + rt_uint32_t stack_size, /* 由内核从堆中分配的线程栈大小,单位:字节 */ + rt_uint8_t priority, /* 优先级;数值越小越高,范围由 RT_THREAD_PRIORITY_MAX 决定 */ + rt_uint32_t tick); /* 时间片大小,单位:系统 Tick;仅同优先级就绪线程间生效 */ +``` + +动态创建失败通常意味着系统堆无法满足本次申请,例如剩余堆空间不足或出现严重碎片。应用可通过内存查看命令或接口观察堆使用情况,但更重要的是在设计阶段控制动态对象数量和生命周期,避免频繁创建、销毁大块内存。 + +> 静态线程的栈数组和控制块必须在其整个生命周期内保持有效。因此不要把它们定义为创建函数中的普通局部变量;应使用 `static` 存储期或其他长期有效的内存区域。 + +### 7.4 创建后启动:rt_thread_startup + +无论使用 `rt_thread_init()` 还是 `rt_thread_create()`,新线程创建后都先处于初始态。调用 `rt_thread_startup()` 后,线程才会被加入就绪队列,从初始态变为就绪态,并在合适的调度时机运行。 + +```text +rt_thread_init() / rt_thread_create() + → 初始态 +rt_thread_startup() + → 就绪态 +调度器选中 + → 运行态 +``` + +这是初学线程时最容易遗漏的一步:创建成功不代表线程已经开始执行。 + +### 7.5 用延时主动让出 CPU + +线程不能长期在无限循环中持续占用 CPU。若当前线程暂时没有工作,可调用 `rt_thread_delay()`(1个OS Tick为单位)或 `rt_thread_mdelay()` (ms为单位)等延时接口,使自己在指定时间内进入挂起态。调度器便能运行其他就绪线程。 + +延时时长应由业务周期、响应要求和 CPU 负载决定;对于等待数据或资源的场景,后续还应学习信号量、事件、邮箱和消息队列等同步通信机制,而不是单纯轮询加延时。 + +## 8. SCons 构建系统 + +SCons 是 RT-Thread 常用的构建系统。它使用 Python 风格脚本描述源码、头文件路径、宏依赖和编译参数,目标是降低手写 Makefile 与 IDE 路径配置的负担,让开发者更专注于功能开发。 + +> 本篇命令以课堂内容为主。不同 RT-Thread 版本、Env/Studio 安装方式及 BSP 支持的工程类型可能不同;执行生成工程或打包命令前,应先在 BSP 根目录查看当前环境支持的选项。 + +### 8.1 三类关键脚本文件 + +| 文件 | 位置与职责 | +| --- | --- | +| `SConstruct` | 构建总入口,组织整个 BSP 的构建流程,并调用各目录的构建脚本 | +| `SConscript` | 通常放在含源代码的目录中,描述该目录或子目录要参与构建的文件与规则 | +| `rtconfig.py` | 保存工具链、CPU 架构、浮点 ABI、优化级别、链接参数等编译配置 | + +常见组织方式是:每个源码子目录放一个 `SConscript`,各自只关心本目录的源码;父级脚本再通过 `SConscript()` 继续读取子目录脚本。这种分层结构使构建规则跟随源码目录组织,便于维护。 + +`rtconfig.py` 会根据 GCC、MDK/ARMCC、IAR、LLVM 等工具链分别设置参数。CPU 架构、Thumb 指令集、硬浮点/软浮点和优化等级都与目标芯片密切相关,通常应通过项目配置生成或谨慎修改,不能随意从其他芯片工程复制。 + +### 8.2 常用构建命令 + +在 **BSP 根目录**、并已进入正确 Env/终端环境后,常见命令如下: + +| 命令 | 用途 | +| --- | --- | +| `scons` | 按当前配置编译 BSP | +| `scons -jN` | 使用 N 个并行任务编译,例如 `-j8`;N 不宜超过电脑可承受的并发度 | +| `scons -c` | 清理本次构建产生的目标文件等中间产物,之后需重新编译 | +| `scons --target=<目标类型>` | 依据当前目录生成对应 IDE 或构建系统工程,例如 MDK、IAR、VS Code、CMake;具体目标名以当前 BSP 支持项为准 | +| `scons --dist` | 将 BSP 所需文件整理为更精简的独立开发包,便于复制到其他目录继续开发 | + +若当前环境无法找到交叉编译器,可在 Env 中设置工具链路径和工具链类型;课堂中提到的 `RTT_EXEC_PATH` 就是用于指定工具链路径的环境变量。此类设置与电脑上的实际安装路径相关,应优先使用 Studio/Env 已配置的工具链,避免写死他人电脑的路径。 + +`scons --dist` 的目的不是“备份所有文件”,而是移除无关 BSP 内容、保留独立开发所需的代码和配置。生成后应在新的目录中重新构建验证,确认所选功能和依赖均已被带入。 + +### 8.3 SConscript 的基本写法 + +一个典型 `SConscript` 通常做四件事:获取当前目录、添加头文件搜索路径、收集源文件、把它们定义为构建组。 + +```python +from building import * + +cwd = GetCurrentDir() +src = Glob('*.c') + +group = DefineGroup( + 'applications', + src, + CPPPATH=[cwd], +) + +Return('group') +``` + +常见元素的作用如下: + +| 元素 | 作用 | +| --- | --- | +| `GetCurrentDir()` | 获取当前 `SConscript` 所在目录,避免手工硬编码路径 | +| `CPPPATH` | 增加头文件搜索目录;找不到 `.h` 文件时应优先检查这里 | +| `Glob('*.c')` | 用通配符收集当前目录的 C 源文件 | +| `src` | 待参与编译的源文件列表,可逐项添加或由 `Glob()` 生成 | +| `DefineGroup()` | 将一组源文件、路径、依赖和编译参数定义为构建单元 | +| `Return('group')` | 将当前目录构建组返回给上一级脚本 | + +`Glob('*.c')` 使用方便,但会把当前目录下所有匹配的 C 文件都加入构建。若某个文件暂时不应编译,应显式移除它,或改为逐个列出所需源文件;否则容易引入重复符号或未完成代码。 + +### 8.4 依赖宏决定源码是否参与构建 + +`DefineGroup()` 可通过 `depend` 参数声明宏依赖。只有相关宏已在配置中启用时,对应源文件才会被加入构建。 + +```python +from building import * + +cwd = GetCurrentDir() +src = ['hello.c'] + +group = DefineGroup( + 'hello', + src, + depend=['RT_USING_HELLO'], + CPPPATH=[cwd], +) + +Return('group') +``` + +上例中,只有 `RT_USING_HELLO` 被定义,`hello.c` 才会参与编译。宏通常由 `Kconfig` 菜单配置生成,并写入 `rtconfig.h` 等配置文件。因此“菜单中勾选功能 → 生成配置宏 → SConscript 判断依赖 → 源文件参与编译”是一条完整链路。 + +构建参数也可按作用域配置:例如公共 C/C++ 参数、C 专用参数、C++ 专用参数、全局宏和局部宏。局部参数应只作用于确有特殊要求的文件或目录,避免无意影响整个工程。 + +### 8.5 SConscript 如何连接子目录 + +父目录不一定直接列出所有深层源文件,而是调用子目录中的脚本,让子目录自行返回构建组。可以把这种写法理解为“桥接”:父脚本负责继续向下发现构建规则,子脚本负责说明自己的源码。 + +```python +SConscript([ + 'applications/SConscript', + 'drivers/SConscript', +]) +``` + +这样增加一个模块时,通常只需在新模块目录编写 `SConscript`,并在适当的父级脚本中接入它,而无需把每个 `.c` 文件都堆放在顶层构建入口。 + +### 8.6 Env、Studio 与 menuconfig 配置 + +RT-Thread Env 的 `menuconfig` 和 Studio 的图形化 RT-Thread Settings,本质上都在修改 Kconfig 对应的功能选择。保存配置后,相关宏会写入 `rtconfig.h` 等生成文件,SCons 再据此决定哪些模块和源码参与构建。 + +使用 Studio 时,图形界面更直观;若参与 BSP 或主线贡献,需要更灵活地处理脚本和配置,可进一步熟悉 Env 与命令行。无论使用哪种界面,修改配置后都应重新构建,不能只勾选菜单却不编译。 + +## 9. Studio 工程创建、调试与线程示例 + +### 9.1 断点验证启动流程 + +调试启动流程时,可在 `entry()`、`rtthread_startup()`、板级初始化、线程创建与调度器启动附近设置断点,并按以下顺序观察: + +```text +启动汇编文件 + → SystemInit() 等芯片初始化 + → entry() + → 关闭中断、板级初始化、系统时钟与调度器初始化 + → 创建 Main / Timer / Idle 线程 + → 启动调度器并首次切换线程 +``` + +在调试器的变量视图中,可检查线程控制块的名称、栈地址、栈大小、优先级和状态。 + +若启用了 Timer 线程,且它的优先级高于 Main 线程,调度器启动后可能先运行 Timer 线程;待其挂起或等待后,才会轮到 Main 线程。这是优先级调度的正常表现,不是 `main()` 丢失或卡死。 + +### 9.2 使用软件包运行线程示例 + +Studio/Env 可从软件包仓库拉取功能模块。以 `kernel samples` 中的线程示例为例:在软件包配置界面启用线程 sample、保存配置并重新构建后,代码会下载到工程的 `packages/` 目录并参与构建。 + +![kernel samples 中的线程示例软件包](assets/02/thread软件包.png) + +示例中包含两个不同优先级的线程: + +- `thread2` 优先级更高,且入口函数中没有主动延时;因此它会先连续输出并很快结束。 +- `thread1` 优先级较低,周期性延时后打印,因此表现为持续、周期性的输出。 + +这个现象验证了两点:高优先级就绪线程会优先运行;动态创建的短生命周期线程退出后可由 Idle 线程自动回收。是否能直接编译某个软件包,还取决于它的 Kconfig 依赖、板级外设与已启用组件;例如图形库等软件包往往需要显示、输入或内存等额外支持,拉取后报依赖错误并不罕见。 + +### 9.3 用 MSH 观察运行结果 + +MSH 是 FinSH 提供的命令行 Shell。连接串口并进入 `msh />` 提示符后,可用以下命令观察线程: + +```text +msh /> list_thread +``` + +或: + +```text +msh /> ps +``` + +输出可查看线程名称、优先级、状态、栈大小与栈使用情况。线程 sample 运行后,已退出并完成回收的 `thread2` 不再出现在列表中;仍在周期性执行或延时等待的 `thread1` 会保留在列表中。按 Tab 键还可查看或补全当前系统已注册的命令。 + +> `ps`、`list_thread` 和软件包命令是否可用,取决于当前工程是否启用了 FinSH/MSH 以及相关组件。遇到“命令不存在”时,应先检查配置,而不是假定所有 BSP 都默认提供该命令。 diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\224\345\244\251\347\254\224\350\256\260.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\224\345\244\251\347\254\224\350\256\260.md" new file mode 100644 index 0000000000000000000000000000000000000000..422c027e83c96a419bdc0966daf540eba0ca1a32 --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\344\272\224\345\244\251\347\254\224\350\256\260.md" @@ -0,0 +1,498 @@ +# RT-Thread 夏令营笔记05:软件包、MQTT 与 DFS 文件系统实战 + +## 目录 + +- [0. 本次实战目标与最终链路](#0-本次实战目标与最终链路) +- [1. 软件包生态与索引机制](#1-软件包生态与索引机制) +- [2. 实验一:通过 AHT10 软件包读取温湿度](#2-实验一通过-aht10-软件包读取温湿度) +- [3. 实验二:RW007 WiFi 联网](#3-实验二rw007-wifi-联网) +- [4. 实验三:MQTT 发布与订阅](#4-实验三mqtt-发布与订阅) +- [5. 实验四:DFS 与外部 Flash 文件读写](#5-实验四dfs-与外部-flash-文件读写) +- [6. 本次工程:温湿度上云、Data.txt 与远程控灯](#6-本次工程温湿度上云datatxt-与远程控灯) +- [7. 常见问题速查](#7-常见问题速查) + +## 0. 本次实战目标与最终链路 + +第五天的重点不是孤立地调用某一个 API,而是把传感器、网络、云端控制和本地存储串成一条完整链路: + +```text +AHT21(I2C3) + ↓ 采集温度、湿度 +RT-Thread 应用线程 + ├── MQTT 发布到 EMQX → MQTTX 查看数据 + ├── DFS 追加写入 → /font/Data.txt + └── MQTT 订阅命令 ← MQTTX 发布 led_toggle + ↓ + PF12 小灯翻转 +``` + +本工程的板载传感器配置名是 **AHT21**,但 BSP 选择了 `AHT10 v2.1.0` 软件包;两者地址同为 `0x38`,但初始化和测量时序不能照搬。实践中以板载硬件和数据手册为准,比“软件包名称”更重要。 + +## 1. 软件包生态与索引机制 + +### 1.1 Kconfig 配置从哪里来 + +工程根目录的 `Kconfig` 会引入 RT-Thread 内核、BSP 和软件包的配置项。软件包的 Kconfig 路径不是直接写死在每个工程中,而是由 `ENV_ROOT` 等环境变量传入,最终指向 RT-Thread Studio 安装目录下对应 `env` 的 `packages` 目录。 + +```text +工程 Kconfig + ├── RT-Thread 内核 Kconfig + ├── BSP / 板级 Kconfig + └── env/packages 中的软件包索引 + ↓ + menuconfig 中显示的软件包分类与配置项 +``` + +因此,在 Studio 中打开 `menuconfig`(或“添加软件包”界面)所看到的软件包列表,本质上由 `env/packages` 下的索引信息提供。 + +### 1.2 内核与 env 的版本兼容 + +Studio 为不同 RT-Thread 内核版本维护了不同版本的 `env`,目的是让软件包索引、构建工具和内核保持兼容。课程中特别强调: + +| 内核情况 | env 使用提示 | +| --- | --- | +| RT-Thread 5.1.0 及以上 | 使用与新内核匹配的 env。 | +| RT-Thread 5.1.0 以下 | 需要使用 1.5.x 一类的旧 env。 | +| 课程示例的 4.1.1 内核 | 只能搭配兼容的 1.5.x env;使用较新的 env 可能报错。 | + +遇到 `env` 报错时,不要只重装工具。应先确认工程的 RT-Thread 内核版本,再选择兼容的 `env`。 + +### 1.3 软件包索引、仓库与拉取过程 + +RT-Thread 软件包仓库独立维护。索引中的 `package.json` 会记录软件包的来源、地址和版本等信息。开发者若要贡献软件包,需要向对应仓库提交 PR(Pull Request),并更新相关索引信息。 + +使用流程如下: + +```text +menuconfig 中使能软件包 + ↓ +保存配置(Ctrl+S) + ↓ +构建系统按索引拉取软件包源码 + ↓ +工程的 packages/ 目录出现对应软件包 + ↓ +编译时将软件包源文件纳入工程 +``` + +软件包被拉取后,优先阅读其 `README.md`、`Kconfig`、头文件和 `example` 目录。README 通常说明配置和初始化方式;如果 README 没有覆盖全部 API,应以头文件声明和示例源码为准。 + +![RT-Thread Studio 软件包配置入口](assets/05/day5-package-config.png) + +### 1.4 依赖项:软件包能拉下来不代表能编译 + +软件包可能依赖 RT-Thread 的其他组件。例如课程中的 AHT10 软件包依赖 I2C 设备框架和 Sensor 设备框架;仅使能 AHT10 而未使能 Sensor 框架,会在编译时出现类型或符号未定义的错误。 + +排查顺序: + +1. 阅读软件包 `Kconfig` 和 README,确认依赖项。 +2. 在 `menuconfig` 中使能依赖组件,例如 Sensor、ULog、文件系统等。 +3. 保存配置后重新生成/编译工程。 +4. 若仍失败,查看 `SConscript` 是否把依赖相关源文件编入工程。 + +## 2. 实验一:通过 AHT10 软件包读取温湿度 + +### 2.1 为什么要使用传感器软件包 + +若不使用软件包,开发 AHT10 通常要查数据手册,按照 I2C 时序读写寄存器,并自行处理初始化、测量触发和原始数据转换。软件包已经封装了这些重复工作,上层只需初始化设备并调用读取 API。 + +```text +不使用软件包:数据手册 → 寄存器配置 → I2C 收发 → 原始值换算 +使用软件包: AHT10 初始化 → 调用读温度/湿度 API → 获得结果 +``` + +这层封装底部仍然是 RT-Thread 的 I2C 设备框架。它没有取消 I2C 的存在,而是替应用屏蔽了 AHT10 专用寄存器的细节。 + +### 2.2 配置与确认项 + +1. 以课程指定的芯片或开发板模板创建工程,并先成功编译一次。 +2. 查看原理图和 BSP 配置,确认实际传感器型号及 I2C 总线名称。本工程为 **AHT21,连接 `i2c3`**;`list_device` 中出现 `i2c3` 才说明总线驱动已注册。 +3. 在软件包或 menuconfig 中使能 AHT10 软件包,保存后确认 `packages/` 下已拉取源码。 +4. 使能该软件包依赖的 Sensor 设备框架;编译出现 `sensor_ops` 未定义等问题时,首先检查此项。 +5. 若要打印调试日志,使能 ULog 并在源文件中包含 `ulog.h`。 +6. 若要使用 `%f` 打印温湿度,额外使能 `rt_vsnprintf_full` 软件包或工程中等价的浮点格式化支持。 + +### 2.3 初始化与读取 API + +课程展示的核心调用关系如下。具体函数原型、数据类型与返回单位应以当前下载的软件包头文件为准。 + +```c +#include +#include +#include + +#define LOG_TAG "aht10" + +static void aht10_entry(void *parameter) +{ + aht10_device_t dev; + + /* "i2c3" 必须替换为本板 AHT10 实际连接的 I2C 总线名。 */ + dev = aht10_init("i2c3"); + if (dev == RT_NULL) + { + LOG_E("AHT10 init failed"); + return; + } + + while (1) + { + float temperature = aht10_read_temperature(dev); + float humidity = aht10_read_humidity(dev); + + LOG_I("temperature: %.2f C, humidity: %.2f %%", temperature, humidity); + rt_thread_mdelay(500); + } +} +``` + +关键点: + +- `aht10_init()` 的返回值是设备句柄,必须判空;初始化失败通常与 I2C 总线名、硬件连线、地址或软件包依赖有关。 +- `aht10_read_temperature()` 与 `aht10_read_humidity()` 都需要传入该句柄。 +- 用手捂热传感器后,温度读数应有变化;这是比“有打印输出”更有效的验证。 + +### 2.4 线程与 MSH 命令封装 + +传感器周期读取不应阻塞 MSH 终端。课程做法是创建独立线程,并把启动函数导出为 MSH 命令: + +```c +static int aht10_sample(void) +{ + rt_thread_t tid = rt_thread_create("aht10", + aht10_entry, + RT_NULL, + 1024, + 10, + 10); + if (tid == RT_NULL) + { + LOG_E("create aht10 thread failed"); + return -RT_ERROR; + } + + rt_thread_startup(tid); + return RT_EOK; +} +MSH_CMD_EXPORT(aht10_sample, start AHT10 sampling); +``` + +这里的线程栈大小、优先级和时间片是课程示例取值,并非所有项目的固定答案。实际应用应根据调用深度、日志量和其他任务负载调整。 + +> 课程现象:未开启浮点数格式化支持时,`%f` 无法正常显示;使能相应软件包、重新配置并编译下载后,温湿度数值即可正常输出。 + +### 2.5 本板实测:为什么会出现 `-50.6 C, 0.0%` + +本工程直接调用旧版 AHT10 软件包 API 时,曾出现 `temperature: -50.6 C, humidity: 0.0%`。这不是实际环境温湿度,而是一次无效测量的典型表现:温度的 `-50` 是驱动失败时的默认值,额外的 `.6` 来自原先对负数小数部分的打印处理。 + +根因是板载 AHT21 的一次测量需要完整时序:发送 `0xAC 0x33 0x00`,等待约 80 ms,确认状态位,再读取数据和 CRC;旧驱动读取过早且命令参数不完全匹配。最终应用使用定点数保存“数值 × 10”,例如 `253` 表示 `25.3 ℃`,这样既避免 `%f` 配置,又能保证 MQTT 与文件记录使用同一次采样结果。 + +## 3. 实验二:RW007 WiFi 联网 + +### 3.1 配置 RW007 前必须看原理图 + +RW007 软件包会提供默认的 CS(片选)、Boot0、Boot1、IRQ(中断)和 Reset 等引脚配置,但默认值不一定匹配当前开发板。直接使用默认值可能导致扫描不到热点、初始化失败或网络无法连接。 + +正确流程是: + +```text +原理图确认 RW007 各信号连接的 MCU 引脚 + ↓ +在 RW007 软件包配置页填写对应引脚编号 + ↓ +保存、重新配置、编译并下载 + ↓ +扫描热点、连接热点、ping 验证 +``` + +课程中以 MSH 的引脚编号换算来检查 GPIO 配置。记录配置时,应填写“原理图信号名 → MCU 引脚 → 软件包配置值”的对应关系,不应照抄其他开发板数值。 + +| 信号 | 作用 | 配置原则 | +| --- | --- | --- | +| CS | 选择 RW007 的 SPI 从设备 | 与原理图一致;与其他 SPI 从设备不能混淆。 | +| Boot0 / Boot1 | 模块启动模式相关控制 | 按板级连接填写。 | +| IRQ | 模块中断通知 | 与实际 GPIO 和中断配置一致。 | +| Reset | 模块复位 | 与实际 GPIO 电平逻辑一致。 | + +### 3.2 WiFi 功能验证 + +下载固件后,先通过 MSH 查看命令帮助,再完成扫描、连接和网络连通性验证。不同固件的命令参数格式可能略有差异,以 `wifi help` 输出为准。 + +```sh +msh /> wifi help +msh /> wifi scan +msh /> wifi connect <热点名> <热点密码> +msh /> ping www.baidu.com +``` + +成功标准不只是“连接命令没有报错”,还包括: + +1. `wifi scan` 能看到附近热点或手机热点; +2. `wifi connect` 成功后模块获得 IP 地址; +3. `ping` 公网域名成功,证明 DNS、网络连接和基本收发链路正常。 + +补充:`ifconfig` 中默认网卡 `w0` 的 `LINK_UP INTERNET_UP`、非 `0.0.0.0` 的 IP、网关和 DNS,是 MQTT 前最重要的判断条件。部分公网服务器会丢弃 ICMP,因此 `ping broker.emqx.io` 超时不必然表示 TCP 1883 端口不可用。 + +### 3.3 关于线程停止的限制 + +课程中 AHT10 线程会持续打印,这会影响终端交互。MCU 上实现像桌面/Linux 中 `Ctrl+C` 一样通用、随时终止任意线程的机制,需要处理信号、资源释放和线程安全,成本较高,课程工程未提供该能力。 + +调试阶段可以: + +- 将采样线程设计为由标志位控制退出; +- 使用更短的日志或减少打印频率; +- 在简单实验中使用 `reboot` 重新启动系统。 + +## 4. 实验三:MQTT 发布与订阅 + +### 4.1 MQTT 的角色与优势 + +MQTT 是面向物联网的轻量级发布/订阅协议。设备不直接把数据发送给每个接收者,而是向服务器(Broker)发布消息;需要数据的客户端订阅同一主题(Topic)。 + +```text +开发板 ──发布 Topic──→ MQTT Broker ←──订阅 Topic── 电脑端 MQTTX +开发板 ──订阅 Topic──→ MQTT Broker ←──发布 Topic── 电脑端 MQTTX +``` + +这种模式适合资源受限的设备:协议开销小、发送方和接收方解耦,多个客户端可通过主题交换数据。 + +### 4.2 课程配置步骤 + +前提是 RW007 已能够正常联网。课程使用公开 EMQX 服务进行演示,TCP 端口为 `1883`;公共服务器的用户名和密码可能不校验,但这一点只适用于课堂指定服务器,不能套用到自己的服务器或生产环境。 + +1. 在软件包中使能本工程使用的 `kawaii-mqtt` 软件包。 +2. 使能 MQTT 示例(测试)代码,否则编译后不会出现对应示例命令。 +3. 填写服务器 Host、端口 `1883`、客户端 ID、发布主题和订阅主题。 +4. 每位同学使用不同的客户端 ID、主题名称,避免相互踢线或混入他人消息。 +5. 保存配置,确认软件包已拉取,编译并下载。 +6. 先连接 WiFi,再从 MSH 启动 MQTT;原示例命令是 `ka_mqtt`,最终应用命令是 `iot_start`。 +7. 在 MQTTX 中配置同一服务器,订阅开发板的发布主题,并向开发板订阅的主题发布测试消息。 + +![MQTT 发布/订阅关系](assets/05/day5-mqtt-flow.png) + +### 4.3 示例线程的关键结构 + +课程示例一般包含:初始化日志、创建 MQTT 客户端对象、设置 Host/端口/认证信息/客户端 ID、建立连接、订阅主题、注册接收回调和循环发布消息。其逻辑可概括为: + +```text +等待网络稳定 + ↓ +创建客户端并填写连接参数 + ↓ +连接 Broker + ↓ +订阅 Topic,并注册收到消息后的回调函数 + ↓ +按周期发布消息 / 等待服务器下发消息 +``` + +课程示例提到的 QoS 0 表示“至多一次”投递:资源消耗较少,但不保证消息一定送达。选择 QoS 时需要在可靠性和带宽、存储、重传开销之间权衡。 + +### 4.4 本板排错记录:只有客户端 ID 输出时 + +若串口只输出 `The ID of the Kawaii client is: ...`,随后没有订阅或发布日志,首先执行: + +```sh +msh > wifi status +msh > ifconfig +``` + +当 `w0` 仍是 `LINK_DOWN`、IP 为 `0.0.0.0` 时,MQTT 连接无法完成;先连 WiFi,确认 `LINK_UP INTERNET_UP`,再重启或重新启动 MQTT 线程。客户端 ID 必须唯一:本工程把应用客户端设为配置 ID 后追加 `_iot`,避免与 MQTTX 客户端互相踢线。 + +### 4.5 用 MSH 自定义发送命令 + +除了在示例线程中定时发布,也可以将发布动作封装为 MSH 命令,实现“命令行传参 → 发布 MQTT 消息”。`argc` 表示参数个数,`argv` 保存参数字符串;命令名本身也计入 `argv[0]`。 + +```c +/* 示意:client 必须在连接成功后才可用。 */ +static int mqtt_pub(int argc, char **argv) +{ + if (argc != 2) + { + rt_kprintf("Usage: mqtt_pub \n"); + return -RT_ERROR; + } + + /* 调用当前 MQTT 软件包提供的 publish API,发布 argv[1]。 */ + return RT_EOK; +} +MSH_CMD_EXPORT(mqtt_pub, publish one MQTT message); +``` + +需要注意:发布函数可能不可重入,且客户端尚未连接时不能直接调用。应先跑通官方示例,再改造成自定义命令;不要在示例未验证时同时修改主题、连接流程和命令封装,否则难以定位问题。 + +## 5. 实验四:DFS 与外部 Flash 文件读写 + +### 5.1 DFS、文件系统与 Flash 抽象层 + +DFS(Device File System)是 RT-Thread 提供的虚拟文件系统。它提供 Linux 风格的目录和路径:系统只有一个根目录 `/`,挂载后的文件系统以某个目录作为访问入口。本工程最终把 `font` 分区挂载到 `/font`。 + +应用无需直接面对 Flash 地址,而是使用标准 POSIX 风格接口: + +```text +应用:open / read / write / close / mkdir / opendir / readdir + ↓ +DFS 虚拟文件系统 + ↓ +elm/FatFs 等具体文件系统 + ↓ +FAL 分区与块设备 + ↓ +SFUD 与 SPI Flash(W25Q64 / W25Q128) +``` + +FAL(Flash Abstraction Layer)将片内和片外 Flash 统一为可管理的分区;SFUD(Serial Flash Universal Driver)将不同 SPI Nor Flash 芯片的读、写、擦除差异封装为统一接口。这样,上层应用不必针对每种 Flash 重写底层时序。 + +![DFS、FAL 与 FatFs 的关系](assets/05/day5-dfs-flow.png) + +### 5.2 文件系统配置与挂载 + +课程的基本配置步骤: + +1. 在 `menuconfig` 中使能 DFS。 +2. 使能 Flash 自动挂载。 +3. 使能旧版本兼容支持(以课程 SDK 的配置需求为准)。 +4. 使能 elm 文件系统;它是 FatFs 兼容实现。 +5. 根据所用 Flash 型号设置正确的扇区大小。课程以 `4096` 字节扇区为例,实际以芯片数据手册和 BSP 配置为准。 +6. 保存、重新编译并下载,观察启动日志是否显示挂载成功。 + +若首次挂载失败,可能是分区未格式化。可以使用 MSH 中与当前文件系统匹配的格式化命令(课程演示为 `mkfs` 相关操作);**格式化会清空该分区所有文件**,执行前必须确认目标路径和数据是否可丢失。 + +### 5.3 分区表与挂载点 + +本工程的 W25Q64 分区表将 Flash 空间按用途划分,例如: + +| 分区用途 | 作用 | +| --- | --- | +| `app` | 存放 MCU 应用固件。 | +| 下载/固件区 | 存放 WiFi 固件或待升级镜像。 | +| 字库区 | 存放字体等资源。 | +| 文件系统区 | 由 DFS 挂载,用于保存应用数据和日志。 | + +本工程把 `font` 分区作为数据分区并挂载为 `/font`,因此温湿度文件的完整路径是 `/font/Data.txt`。挂载点不是固定术语,必须以 `fal_cfg.h`、`drv_filesystem.c` 和启动日志为准,不能混用 `/fl`、`/flash`、`/fal` 等教程示例路径。 + +通过 MSH 查看: + +```sh +msh /> ls / +msh /> ls /font +``` + +若 `list_device` 中只有 `W25Q64` 而没有 `font`,或 `fal probe` 命令不存在,说明当前固件尚未启用 FAL/DFS;应先在 RT-Thread Settings 开启文件系统和基于 W25Q64 的 FAL 文件系统后重新编译下载。 + +### 5.4 使用 POSIX 接口写文件 + +下面是课程中“创建并写入测试文件”的等价骨架。路径必须与实际挂载点一致。 + +```c +#include +#include +#include + +static int flash_write_demo(void) +{ + const char text[] = "hello rt-thread\n"; + int fd = open("/font/test.txt", O_RDWR | O_CREAT | O_APPEND); + + if (fd < 0) + { + rt_kprintf("open file failed\n"); + return -RT_ERROR; + } + + if (write(fd, text, strlen(text)) < 0) + { + rt_kprintf("write file failed\n"); + close(fd); + return -RT_ERROR; + } + + close(fd); + return RT_EOK; +} +MSH_CMD_EXPORT(flash_write_demo, append one line to flash file); +``` + +常用打开标志: + +| 标志 | 含义 | +| --- | --- | +| `O_RDONLY` | 只读打开。 | +| `O_WRONLY` | 只写打开。 | +| `O_RDWR` | 可读可写。 | +| `O_CREAT` | 文件不存在时创建。 | +| `O_APPEND` | 每次写入追加到文件末尾,适合持续记录传感器日志。 | + +写入后可以用 `cat /font/test.txt` 查看文件内容。每次 `open()` 成功后都应 `close()`,避免文件描述符和缓存资源泄漏。 + +### 5.5 WiFi 与 Flash 共用 SPI 总线的问题 + +课程开发板上的 RW007 与外部 SPI Flash 共用同一条 SPI 总线。SPI 可共享 SCK/MOSI/MISO,但每个从设备必须有独立的片选 CS;未选中的设备需要保持 CS 无效电平(常见为高电平)。 + +若 RW007 的 CS 在启动时处于错误状态,可能干扰 Flash 通信,导致启动日志提示找不到 W25Q64、挂载失败或读写失败。课程的处理方法是在 Flash 初始化/访问前,将 WiFi 的 CS 引脚配置为输出并拉高: + +```c +/* GPIO 编号必须按当前开发板原理图和 BSP 配置替换。 */ +rt_pin_mode(RW007_CS_PIN, PIN_MODE_OUTPUT); +rt_pin_write(RW007_CS_PIN, PIN_HIGH); +``` + +该处理的本质是明确让 RW007 处于未选中状态,避免它误响应发给 Flash 的 SPI 数据。不能只根据现象随意改引脚号,应回到原理图确认 CS 对应关系。 + +## 6. 本次工程:温湿度上云、Data.txt 与远程控灯 + +### 6.1 启动流程与验证顺序 + +本次新增的 `iot_telemetry.c` 将采样、上云、文件记录和控灯合并为一个后台线程。推荐严格按下面顺序调试,避免把网络、文件系统和传感器问题混在一起: + +```sh +# 1. 网络正常 +msh > wifi status +msh > ifconfig + +# 2. 文件系统正常(启用 FAL/DFS 并重新下载后) +msh > list_device +msh > fal probe +msh > df +msh > ls /font + +# 3. 启动物联网应用 +msh > iot_start + +# 4. 验证本地记录 +msh > cat /font/Data.txt +``` + +`iot_start` 只允许启动一次。启动后每 5 秒采样一次:串口打印数据、向发布主题发送数据、并以追加模式写入 `Data.txt`。计数 `Count` 从本次上电后的首次采样开始递增。 + +### 6.2 MQTTX 的两条主题方向 + +| 方向 | MQTTX 操作 | 消息示例 | +| --- | --- | --- | +| 开发板 → 云端 | 订阅开发板的发布主题,例如 `rsoc/<唯一标识>/device/up` | `Temp: 25.3 ; Humi: 61.8 ; Count: 1` | +| 云端 → 开发板 | 向开发板订阅主题,例如 `rsoc/<唯一标识>/device/down` 发布 | 纯文本 `led_toggle` | + +主题、客户端 ID 都应带个人唯一标识。公共 Broker 上的 `rtt-pub`、`rtt-sub` 只能用于临时调试,容易收到其他人的消息。LED 命令必须选择 **Plaintext**,发送精确的 `led_toggle`,而不是 `{"msg":"1"}` 这类 JSON;应用回调按消息长度比较,避免把非 NUL 结尾的 MQTT Payload 当作 C 字符串处理。 + +### 6.3 首次格式化的边界 + +`mkfs elm font` 只在 `/font` 因未格式化而无法挂载、且确认分区没有需保留的字体或数据时执行。格式化会清空整个 `font` 分区;格式化成功后重启系统,再检查 `df` 和 `ls /font`。 + +## 7. 常见问题速查 + +| 现象 | 优先检查方向 | +| --- | --- | +| AHT10 编译提示 `sensor_ops` 等未定义 | 是否使能 Sensor 设备框架及软件包依赖。 | +| 温湿度日志无法显示小数 | 是否使能 `rt_vsnprintf_full` 或等价浮点格式化支持。 | +| 温湿度显示 `-50.x C, 0.0%` | 不要把该值当真实数据;检查 AHT21 测量命令、80 ms 等待、状态位和 CRC。 | +| AHT10 初始化失败 | I2C 总线名、原理图连线、设备地址、I2C 驱动是否正常注册。 | +| 扫描不到 WiFi 或无法连接 | RW007 的 CS、Boot、IRQ、Reset 是否与原理图匹配。 | +| MQTT 命令不存在 | 是否勾选 MQTT 示例并重新保存、编译、下载。 | +| MQTT 客户端反复掉线 | 客户端 ID 是否和其他人重复;WiFi 是否已经联网。 | +| 只打印 MQTT 客户端 ID,后续无输出 | 先检查 `ifconfig` 的 `w0` 是否为 `LINK_UP INTERNET_UP`;联网后再启动 MQTT。 | +| 电脑端收不到消息 | Host、端口、发布/订阅 Topic 是否完全一致;检查 MQTTX 的订阅状态。 | +| 找不到 W25Q64 或文件写入失败 | 共用 SPI 的 RW007 CS 是否已拉高;检查 Flash/FAL/DFS 挂载日志。 | +| 文件系统无法挂载 | 分区表、文件系统类型、扇区配置是否一致;确认是否需要格式化。 | +| `fal probe` 命令不存在 | 当前固件未启用 FAL;开启文件系统与 W25Q64 FAL 选项后重新编译下载。 | +| 按键中断响应慢 | 不要在中断相关流程中做长延时;记录本次与上次触发时间,用时间间隔做消抖,或参考按键软件包实现。 | +| env 报版本错误 | 核对 RT-Thread 内核版本与 env 版本是否兼容。 | diff --git "a/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\345\233\233\345\244\251\347\254\224\350\256\260.md" "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\345\233\233\345\244\251\347\254\224\350\256\260.md" new file mode 100644 index 0000000000000000000000000000000000000000..8b95a7872362ea2bf439e3f6446e12d0c08422fd --- /dev/null +++ "b/2026/\347\254\2543\347\273\204/\351\253\230\347\277\214\350\276\260/\347\254\224\350\256\260/\347\254\254\345\233\233\345\244\251\347\254\224\350\256\260.md" @@ -0,0 +1,711 @@ +# RT-Thread 夏令营笔记04:设备驱动框架与 IO 外设开发 + +## 目录 + +- [1. 为什么需要统一的设备驱动框架](#1-为什么需要统一的设备驱动框架) +- [2. 从专用总线 API 到通用 IO 设备模型](#2-从专用总线-api-到通用-io-设备模型) +- [3. rt_device:设备对象与分类](#3-rt_device设备对象与分类) +- [4. 设备的注册、查找与使用生命周期](#4-设备的注册查找与使用生命周期) +- [5. ops:上层 API 如何调用到底层驱动](#5-ops上层-api-如何调用到底层驱动) +- [6. 打开方式、回调与异步收发](#6-打开方式回调与异步收发) +- [7. 实验:注册 test_dev 并追踪调用链](#7-实验注册-test_dev-并追踪调用链) +- [8. GPIO 与 PIN 设备:从引脚配置到按键中断](#8-gpio-与-pin-设备从引脚配置到按键中断) +- [9. PIN 设备如何对接到 IO 框架](#9-pin-设备如何对接到-io-框架) +- [10. I2C 总线:消息传输与故障恢复](#10-i2c-总线消息传输与故障恢复) +- [11. SPI 总线:设备挂载、配置与传输](#11-spi-总线设备挂载配置与传输) +- [12. BSP、SDK 与 CubeMX 的配置差异](#12-bspsdk-与-cubemx-的配置差异) + +## 1. 为什么需要统一的设备驱动框架 + +### 1.1 不同芯片 API 带来的问题 + +SPI、I2C 等总线的**通信标准**相同,但不同芯片厂商的底层库(例如 STM32 HAL)提供的函数名、参数和初始化方式往往不同。假设甲同学在某款 MCU 上完成了 W25Q128 Flash 与文件系统功能,乙同学换用另一款 MCU 后,若直接依赖厂商 API,就需要重新学习接口并重写大量底层调用;之后接入 RW007、LwIP 等组件时也会反复遇到类似问题。 + +RT-Thread 的设备驱动框架通过统一抽象解决这一问题:上层应用只面对 RT-Thread 的设备 API;不同 MCU 的差异由 BSP(Board Support Package,板级支持包)和驱动层负责适配。 + +```text +同一份应用代码 + ↓ 调用统一 RT-Thread API +RT-Thread 设备框架 / SPI、I2C 等专用框架 + ↓ 调用已适配的底层驱动 +不同厂商、不同型号的 MCU 外设 +``` + +### 1.2 分层后的收益与代价 + +| 方面 | 影响 | +| --- | --- | +| 代码复用 | 上层应用不直接依赖厂商库;迁移 MCU 时通常只需适配底层驱动。 | +| 学习成本 | 应用开发者主要学习一套 RT-Thread API,而不必为每家芯片厂商反复切换接口。 | +| 协作效率 | 驱动/BSP 与应用可以分工;已适配的设备驱动可被多个工程复用。 | +| 工程一致性 | 减少各项目自行封装、接口碎片化和重复造轮子。 | +| 代价 | 多一层抽象会增加框架复杂度,并消耗一定 Flash 空间。 | + +课程的取舍是:对于需要跨芯片迁移、接入多种外设或长期维护的项目,统一框架带来的复用收益通常大于额外开销。 + +## 2. 从专用总线 API 到通用 IO 设备模型 + +### 2.1 通用接口与专用接口并存 + +RT-Thread 早期先为 SPI 提供统一 API,之后将能力扩展为更通用的 IO 设备管理层。对许多设备而言,上层可用统一的 `open`、`read`、`write`、`control`、`close` 等操作访问硬件。 + +这不表示 SPI 和 I2C 的特性被抹平。它们仍保留各自的专用框架与接口,例如 SPI 的传输和配置、I2C 的消息传输。这些专用接口与通用 IO 层是互补关系: + +- 通用 IO 接口负责设备的统一管理和基本操作; +- 专用总线接口负责总线独有的通信语义; +- 底层驱动把两类接口最终落实到具体硬件。 + +### 2.2 应用、框架、BSP 的职责边界 + +```text +应用开发者:rt_device_find/open/read/write/control/close,或总线专用 API + ↓ +RT-Thread 框架:设备管理、统一调用入口、回调转发 + ↓ +BSP / 驱动开发者:实现并登记 ops,调用 MCU 厂商 HAL/LL 等底层库 + ↓ +硬件:UART、GPIO、SPI、I2C、Flash、网卡等 +``` + +应用层通常不必关心 UART 是由中断还是 DMA 接收、SPI 控制器的寄存器如何配置;这些实现细节属于驱动层。反过来,驱动层要遵守框架约定,才能让应用以统一方式使用设备。 + +## 3. rt_device:设备对象与分类 + +### 3.1 rt_device 的关键信息 + +RT-Thread 以 `rt_device` 作为设备对象的基础。可以把它理解为 C 语言中实现“面向对象”风格的基类:具体的串口、PIN、SPI 总线等设备在此基础上扩展自己的私有数据,并提供自己的操作实现。 + +课程中需要重点认识的字段含义如下;具体字段布局会随 RT-Thread 版本和配置略有差异,应以工程头文件为准。 + +| 信息 | 作用 | +| --- | --- | +| 设备类型(class) | 标识设备属于字符、块、网络接口、PIN、SPI 总线等类别。 | +| 参数/用户数据 | 保存设备特有信息,例如工作模式或私有控制块。 | +| 打开标志与引用计数 | 记录设备是否已打开、以何种方式打开,以及被打开的次数。 | +| 接收/发送回调 | 异步接收数据或异步发送完成时通知上层。 | +| `ops` | 一组函数指针,定义 init、open、read、write、control、close 等操作如何落到具体驱动。 | + +### 3.2 字符设备与块设备 + +| 类型 | 数据访问特点 | 常见例子 | +| --- | --- | --- | +| 字符设备(character device) | 提供连续字节流,通常按到达顺序读写,不支持按任意位置随机读取。 | 串口、键盘、部分调制解调器。 | +| 块设备(block device) | 以固定大小的数据块为单位,可通过偏移位置随机访问。 | Flash、SD 卡、硬盘、RAM 模拟块设备。 | + +核心区别是是否支持随机访问。串口数据按时间顺序到来,无法要求“读取第 100 个字节后面的数据”;而 Flash 或 SD 卡可以指定地址/块号,读取某个位置的数据。 + +> 说明:块大小由设备和驱动决定,不应把某个固定字节数当作所有块设备的通用规则。 + +### 3.3 为什么分类有用 + +分类使组件能够按设备能力工作,而不依赖某个具体型号: + +- MSH 的终端输出可以重定向到任意合适的字符设备,例如串口或模拟字符设备; +- 文件系统通常依赖块设备,因此将 SD 卡或 Flash 实现为块设备后,可由文件系统统一管理; +- 应用关心的是“这是可顺序读写的串口”或“这是可随机访问的存储”,而不是底层控制器型号。 + +除了字符和块设备,RT-Thread 中还可见网络接口、内存、RTC、音频、图形、I2C、USB Device/Host、SPI 总线、SDIO 及杂项设备等类别。 + +## 4. 设备的注册、查找与使用生命周期 + +### 4.1 驱动侧:创建并注册设备 + +设备要先进入 IO 管理系统,应用才能按名称找到它。一个典型流程如下: + +```text +驱动创建或准备设备对象 + ↓ +实现并绑定该设备的 ops + ↓ +rt_device_register():用名称注册到设备管理链表 + ↓ +自动初始化或板级初始化期间完成注册 + ↓ +应用可通过 rt_device_find() 查找 +``` + +常见管理 API: + +| API | 用途 | +| --- | --- | +| `rt_device_create()` | 动态创建设备对象,返回设备句柄;失败时通常返回 `RT_NULL`。 | +| `rt_device_destroy()` | 销毁由 `rt_device_create()` 动态创建的设备对象。 | +| `rt_device_register()` | 为设备指定名称和能力标志,并注册到 IO 设备管理系统。 | +| `rt_device_unregister()` | 从 IO 设备管理系统注销设备。 | + +`rt_device_register()` 成功通常返回 `RT_EOK`(值为 0);错误码一般为负值。设备名称长度受配置宏限制,且同一系统内的设备名应保持唯一。 + +### 4.2 应用侧:查找、打开、操作与关闭 + +应用使用设备的标准顺序为: + +```text +rt_device_find("设备名") + ↓ +rt_device_open(设备句柄, 打开标志) + ↓ +rt_device_read() / rt_device_write() / rt_device_control() + ↓ +rt_device_close(设备句柄) +``` + +| API | 作用 | +| --- | --- | +| `rt_device_find()` | 按注册名称查找设备,返回设备句柄;找不到时返回 `RT_NULL`。 | +| `rt_device_init()` | 初始化设备。多数情况下 `rt_device_open()` 会在首次使用时确保初始化,但应以具体驱动行为为准。 | +| `rt_device_open()` / `rt_device_close()` | 以指定模式打开/关闭设备,维护打开状态与引用计数。 | +| `rt_device_read()` / `rt_device_write()` | 读/写设备;`pos` 对块设备等随机访问设备有意义,对普通字符设备通常无意义。 | +| `rt_device_control()` | 执行设备特有控制,例如设置串口波特率或总线参数。 | + +> 先 `open` 再 `read`/`write` 是重要前提。未正确打开 `test_dev` 时,读写操作不会进入预期的底层函数;补上 `rt_device_open()` 后调用链才完整。 + +### 4.3 用 MSH 验证设备是否已注册 + +通过自动初始化机制完成设备注册后,将程序下载到开发板,在 MSH 中执行: + +```text +msh /> list_device +``` + +该命令会遍历已注册设备并显示名称、类型和打开状态等信息。课程中的 `test_dev` 被注册后应能在列表中看到。某些系统设备(例如当前控制台串口)可能已经被多个组件打开,因此打开次数大于 1 属于正常现象。 + +## 5. ops:上层 API 如何调用到底层驱动 + +### 5.1 函数指针构成的操作集 + +`ops`(operations)是设备对象中保存的一组操作函数指针。驱动把自己的初始化、打开、关闭、读、写、控制函数填入其中;框架层调用 `rt_device_*()` API 时,再经由 `ops` 间接调用实际驱动函数。 + +```text +rt_device_read(dev, ...) + ↓ +设备框架检查设备状态与参数 + ↓ +dev->ops 中的 read 函数指针 + ↓ +具体驱动的 read 实现 + ↓ +厂商 HAL/LL 或寄存器操作 + ↓ +硬件外设 +``` + +这就是“上层统一、底层可替换”的关键:应用始终调用 `rt_device_read()`,但不同设备对象的 `ops->read` 指向不同实现。 + +有些 RT-Thread 工程启用了 `RT_USING_DEVICE_OPS`,使用独立的 `struct rt_device_ops`;另一些版本或配置把操作函数指针直接放在设备对象中。两者的设计目的相同:**由设备对象保存操作入口,再由框架统一分发。**编写代码时必须遵循当前工程 `rtdevice.h` 中实际定义。 + +### 5.2 一次读写调用的完整路径 + +以串口 DMA/中断接收为例,调用关系可以概括为: + +```text +上层:rt_device_open()、设置接收回调、等待数据 + ↓ +设备框架:检查打开模式,调用设备对应 ops + ↓ +串口驱动:配置 UART、DMA 或中断 + ↓ +STM32 HAL 回调 / 中断服务函数 + ↓ +RT-Thread 串口驱动回调 + ↓ +用户通过 rt_device_set_rx_indicate() 设置的接收回调 +``` + +RT-Thread 回调并不会绕过硬件库。以 STM32 为例,硬件中断或 DMA 事件通常先触发 HAL/底层驱动的回调,再由 RT-Thread 驱动转换为设备框架的回调,最后才通知应用层。 + +### 5.3 调试时应关注的断点 + +设备框架初学时容易觉得“函数绕了一层又一层”。最直接的理解方法是单步调试: + +1. 在应用调用 `rt_device_open()`、`rt_device_read()` 或 `rt_device_control()` 的位置打断点; +2. 进入 `rt_device_*` 框架函数,观察设备句柄、打开标志和初始化状态; +3. 查看设备对象中的 `ops` 是否非空、对应函数指针是否已赋值; +4. 继续单步进入 `ops` 指向的驱动函数,确认是否进入自己实现的 `init`、`open`、`read` 等函数; +5. 对异步收发,再沿中断或 DMA 回调向上追踪通知链。 + +课程中的 `test_dev` 实验正是用日志和断点验证:上层调用 `rt_device_init()`、`open()`、`control()` 等 API 后,是否进入自定义 `ops` 函数。 + +## 6. 打开方式、回调与异步收发 + +### 6.1 打开标志与引用计数 + +打开设备时需要传入模式标志。常见能力包括只读、只写、读写,以及面向异步收发的中断模式或 DMA 模式;具体可用标志以当前工程头文件为准。 + +- 设备可被重复打开时,框架会维护打开次数(引用计数); +- 某些设备或驱动不允许重复打开,可能返回“设备忙”等错误; +- `rt_device_close()` 并不一定立刻让所有硬件资源失效,而是按驱动规则减少打开引用并执行关闭逻辑; +- 对于需要回调通知的异步接收/发送,应按驱动要求使用中断或 DMA 等非阻塞方式打开。 + +### 6.2 接收与发送完成回调 + +当设备异步接收到数据时,可通过 `rt_device_set_rx_indicate()` 设置接收通知回调;回调参数通常包含设备句柄和本次可读取的数据大小。上层收到通知后,再按需要调用读接口取得数据。 + +当设备以 DMA 等异步方式发送数据时,可通过 `rt_device_set_tx_complete()` 设置发送完成回调。它解决的问题是:发送调用已经返回后,应用如何得知底层硬件何时真正完成传输。 + +```text +接收:硬件收到数据 → 中断/DMA → 驱动 → rx_indicate 回调 → 上层读取数据 +发送:上层发起发送 → DMA/硬件传输 → 传输完成中断 → tx_complete 回调 → 上层获知完成 +``` + +阻塞方式下,调用线程本身会等待结果,通常不需要再用同一套回调机制通知;异步方式下,回调是连接硬件事件和上层处理的重要手段。 + +## 7. 实验:注册 test_dev 并追踪调用链 + +### 7.1 注册 test_dev 并在设备列表中查看 + +它创建一个字符设备并注册到设备管理系统,目的是让 `list_device` 能看到 `test_dev`。 + +```c +#include +#include + +#define DBG_TAG "main" +#define DBG_LVL DBG_LOG +#include + +static int rt_dev_test_init(void) +{ + rt_device_t test_dev = rt_device_create(RT_Device_Class_Char, 0); + + if (!test_dev) + { + LOG_E("test_dev create failed"); + return -RT_ERROR; + } + + if (rt_device_register(test_dev, "test_dev", RT_DEVICE_FLAG_RDWR) != RT_EOK) + { + LOG_E("test_dev register failed!"); + return -RT_ERROR; + } + + return RT_EOK; +} +INIT_DEVICE_EXPORT(rt_dev_test_init); +``` + +当前工程在 `rtconfig.h` 中启用了 `RT_USING_COMPONENTS_INIT`,因此 `INIT_DEVICE_EXPORT(rt_dev_test_init)` 会把该函数放入**设备初始化阶段**,系统启动时自动调用,无需在 `main()` 内手动调用。整个流程如下: + +```text +系统启动 + ↓ +自动调用 rt_dev_test_init() + ↓ +申请一个字符设备对象 + ↓ +以 test_dev 名字注册到内核 + ↓ +list_device 能列出它 +``` + +当前代码在注册失败时直接返回 `-RT_ERROR`,并没有调用 `rt_device_destroy(test_dev)` 释放刚申请的内存。 + +### 7.2 绑定 ops 并通过日志验证调用链 + +仅完成注册时,`test_dev` 只是设备管理系统中的一个对象。要验证通用设备 API 是否会调用到测试驱动,还要把六个操作函数绑定到设备对象: + +```c +test_dev->init = test_dev_init; +test_dev->open = test_dev_open; +test_dev->close = test_dev_close; +test_dev->read = test_dev_read; +test_dev->write = test_dev_write; +test_dev->control = test_dev_control; +``` + +随后在 `main()` 中按如下顺序调用设备框架 API: + +```text +rt_device_find("test_dev") + ↓ +rt_device_init() + ↓ +rt_device_open(RT_DEVICE_FLAG_RDWR) + ↓ +rt_device_control() → rt_device_read() → rt_device_write() + ↓ +rt_device_close() +``` + +本次实验输出了以下日志: + +```text +test dev init +test drv open flag = 3 +test dev control cmd = 1 +test dev read pos = 0, size = 2 +test dev write pos = 0, size = 2 +test dev close +``` + +日志顺序与调用顺序一致,说明 `test_dev` 已注册成功,六个 `ops` 回调已绑定成功,且 `rt_device_*()` 通用 API 能通过设备框架分发到对应的 `test_dev_*()` 函数。`flag = 3` 表示以读写方式打开设备。该实验只验证设备框架的调用链;`read` 和 `write` 仅打印参数,并未实现真实硬件数据传输。 + +## 8. GPIO 与 PIN 设备:从引脚配置到按键中断 + +### 8.1 MCU 引脚的复用功能 + +MCU 芯片上的引脚不全是普通输入输出脚。常见类别包括: + +- **电源引脚**:为芯片不同电源域供电; +- **时钟引脚**:连接高速或低速外部晶振; +- **复位/调试引脚**:用于复位、SWD/JTAG 等功能; +- **GPIO(General Purpose Input/Output,通用输入输出)**:可配置为普通输入或输出; +- **复用功能引脚**:同一个物理引脚可在配置后承担 SPI、I2C、UART 等外设信号。 + +例如,SPI 通常需要 MOSI、MISO、SCK 与片选信号,I2C 使用 SCL、SDA,串口使用 TX、RX。一个引脚能够承担哪些复用功能由芯片内部连接决定,必须查看该芯片的数据手册、参考手册或 BSP 的引脚配置;不是所有引脚都支持所有功能。 + +RT-Thread 把 GPIO 抽象为 PIN 设备。上层应用调用 `rt_pin_*` API 时,不需要直接操作某款 MCU 的 GPIO 寄存器;只要目标 BSP 已实现 PIN 驱动,应用代码就能在不同 MCU 间复用。 + +### 8.2 常用 PIN API 与工作模式 + +使用一个引脚前,先根据外部电路设置合适模式。课程涉及的常用模式如下: + +| 模式 | 常量 | 典型用途 | +| --- | --- | --- | +| 普通输出 | `PIN_MODE_OUTPUT` | 控制 LED、普通数字输出。 | +| 普通输入 | `PIN_MODE_INPUT` | 外部电路已提供稳定高低电平的输入。 | +| 上拉输入 | `PIN_MODE_INPUT_PULLUP` | 按下接地、松开悬空的按键。 | +| 下拉输入 | `PIN_MODE_INPUT_PULLDOWN` | 按下接电源、松开悬空的按键。 | +| 开漏输出 | `PIN_MODE_OUTPUT_OD` | 需要“只主动拉低、由上拉电阻拉高”的场景。 | + +常用 API: + +| API | 作用 | +| --- | --- | +| `rt_pin_mode(pin, mode)` | 设置引脚模式。 | +| `rt_pin_write(pin, value)` | 向输出引脚写 `PIN_HIGH` 或 `PIN_LOW`。 | +| `rt_pin_read(pin)` | 读取输入引脚的当前电平,返回 `PIN_HIGH` 或 `PIN_LOW`。 | +| `rt_pin_attach_irq(pin, mode, hdr, args)` | 绑定引脚中断触发方式、回调函数和用户参数。 | +| `rt_pin_irq_enable(pin, enabled)` | 使能或禁用已绑定的引脚中断。 | +| `rt_pin_detach_irq(pin)` | 解除中断回调绑定,不再使用时可调用。 | + +课程提到的中断触发条件包括上升沿、下降沿、双边沿、高电平和低电平。实际 BSP 能支持哪些 `PIN_IRQ_MODE_*` 常量,取决于 MCU 的外部中断硬件能力和驱动实现,应查看当前工程头文件。 + +在 STM32 类 BSP 中,常用 `GET_PIN(端口字母, 引脚号)` 取得 PIN 编号,例如: + +```c +#define KEY_UP GET_PIN(C, 5) /* PC5 */ +#define KEY_DOWN GET_PIN(C, 1) /* PC1 */ +#define KEY_RIGHT GET_PIN(C, 0) /* PC0 */ +#define KEY_LEFT GET_PIN(C, 4) /* PC4 */ +``` + +`GET_PIN(C, 5)` 的数值是 BSP 为通用 PIN API 使用的编码,不是硬件寄存器地址。课程调试时看到 PC5 对应数值 `37`,可理解为 STM32 的每个 GPIO 端口有 16 个引脚:GPIOA 占 0~15、GPIOB 占 16~31、GPIOC 的第 5 脚因而编码为 `32 + 5`。此编码规则是该类 BSP 的实现细节,跨芯片时应使用 `GET_PIN()`,不要手写数字。 + +### 8.3 按键中断实验:上拉输入与下降沿 + +课程开发板上的四个方向按键连接到 PC5、PC1、PC0、PC4。按键按下时会把引脚拉到低电平,因此使用上拉输入: + +```text +松开按键:内部上拉使引脚保持高电平 +按下按键:按键接地,引脚变为低电平 + ↓ + 产生下降沿 +``` + +因此应设置 `PIN_MODE_INPUT_PULLUP`,并以 `PIN_IRQ_MODE_FALLING` 注册中断。以下为课程实验的整理版;引脚连接必须以自己的开发板原理图为准。 + +```c +#include +#include + +#define KEY_UP GET_PIN(C, 5) +#define KEY_DOWN GET_PIN(C, 1) +#define KEY_RIGHT GET_PIN(C, 0) +#define KEY_LEFT GET_PIN(C, 4) + +static void key_irq_handler(void *args) +{ + rt_base_t pin = (rt_base_t)args; + + /* 演示:实际项目中应仅记录事件,再由线程处理,见 8.4。 */ + rt_kprintf("key interrupt, pin = %d, level = %d\\n", + pin, rt_pin_read(pin)); +} + +static int key_irq_sample(void) +{ + const rt_base_t keys[] = {KEY_UP, KEY_DOWN, KEY_RIGHT, KEY_LEFT}; + rt_size_t i; + + for (i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) + { + rt_pin_mode(keys[i], PIN_MODE_INPUT_PULLUP); + rt_pin_attach_irq(keys[i], PIN_IRQ_MODE_FALLING, + key_irq_handler, (void *)keys[i]); + rt_pin_irq_enable(keys[i], PIN_IRQ_ENABLE); + } + + return RT_EOK; +} +INIT_APP_EXPORT(key_irq_sample); +``` + +实验步骤可以概括为:设置模式 → 注册回调 → 使能中断 → 按键触发 → 中断处理函数被调用。若希望解除某个按键的中断,应先禁用中断,再调用 `rt_pin_detach_irq()`。 + +> 提示:不同 RT-Thread 版本中 PIN 编号、回调参数类型和可用宏可能略有差异。应以当前工程的 `rtdevice.h`、PIN 驱动头文件及实际原理图为准;不要直接复制其他板卡的 PC 引脚定义。 + +### 8.4 按键抖动与中断上下文限制 + +机械按键的金属触点在按下或松开瞬间会短时间反复接通、断开,电平会产生多个边沿。这就是**按键抖动**。若只对每一个下降沿响应,一次按键可能进入两次或多次中断。 + +课程提出两类处理思路: + +- **软件消抖**:在检测到按键事件后,记录时间戳;在一个短暂的消抖窗口内忽略同一按键的重复事件。也可以在合适的线程/定时器中再次采样确认电平稳定。 +- **使用 Button 等按键软件包**:软件包通常封装了消抖、单击、双击、长按和释放等状态识别,适合需要丰富按键交互的项目。启用后应阅读软件包 README 和示例,并按工程配置其扫描周期。 + +不建议在中断回调中直接用普通延时进行消抖:中断服务程序应尽快返回,不能执行可能阻塞、耗时或依赖调度器的操作。更稳妥的模型是“中断只通知,线程再处理”: + +```text +按键中断 + ↓ +回调仅发送信号量/事件,或记录引脚和时间戳 + ↓ +按键处理线程等待到通知 + ↓ +延时/再次采样/判断消抖窗口 + ↓ +确认一次有效按键事件,执行业务逻辑 +``` + +课程中曾在中断处理过程使用日志观察现象。若需要在中断相关路径记录日志,必须确认 ULog 的异步输出及 ISR 安全配置已启用;更推荐只在中断中记录最少信息,再由线程输出完整日志。否则可能出现无输出、异常或实时性变差。 + +## 9. PIN 设备如何对接到 IO 框架 + +### 9.1 两层 ops 调用关系 + +PIN 设备展示了前一部分 `ops` 分层思想的实际应用。它至少涉及两级抽象: + +```text +方式一:调用 PIN 专用 API +rt_pin_read() / rt_pin_write() / rt_pin_mode() + ↓ +PIN 设备驱动框架的 ops + ↓ +MCU BSP 的 PIN/GPIO 驱动 ops + ↓ +厂商 HAL/LL 或寄存器操作 + +方式二:调用通用 IO API +rt_device_read() / rt_device_write() / rt_device_control() + ↓ +IO 设备管理层的 device ops + ↓ +PIN 设备驱动框架的 ops + ↓ +MCU BSP 的 PIN/GPIO 驱动 ops +``` + +因此,`rt_pin_read()` 等专用 API 通常少经过一层通用 IO 分发;而 `rt_device_*()` 提供统一设备入口。两条路径最终都会到达 BSP 提供的 GPIO 实现。应用只要使用 RT-Thread 的 PIN API,在已适配的 MCU 上通常可无缝迁移。 + +不同版本的 PIN 框架在文件位置、结构体名称和已对接的通用操作上可能不同。课程以主线版本与较旧版本对比,说明某些工程的 PIN 代码位于 `components/drivers/`,另一些工程可能放在杂项设备目录;阅读源码时应使用工程搜索,而不是依赖固定路径。 + +### 9.2 初始化位置与源码阅读路径 + +从系统启动看,PIN 驱动需要在应用调用前完成注册和 `ops` 绑定。常见方式有两种: + +1. **板级初始化**:如 PIN、串口等基础且常用的设备,可能在 `board.c` 的板级初始化过程中较早完成;控制台串口尤其需要早于 MSH 初始化。 +2. **自动初始化**:SPI、I2C 等可选组件常通过 `INIT_*_EXPORT()` 登记初始化函数。链接脚本为各初始化阶段保留函数指针区域,RT-Thread 启动时遍历并调用这些函数。 + +建议沿以下路径阅读源码: + +```text +applications/main.c 中的 rt_pin_* 调用 + ↓ +PIN 设备框架层(查找 rt_pin_mode、rt_pin_read 等实现) + ↓ +PIN 框架使用的 ops + ↓ +BSP 的 drv_gpio.c、drv_pin.c 或同类文件 + ↓ +厂商 HAL GPIO 初始化/读写/EXTI 函数 +``` + +STM32 的底层 GPIO 驱动通常在 BSP 的 `libraries` 或 `drivers` 相关目录中;其他厂商 BSP 的目录命名不同,但共同点是:最终都要实现框架需要的 mode、write、read、attach/detach IRQ、enable IRQ 等操作。 + +### 9.3 PIN 驱动的调试方法 + +当按键无反应、回调未执行或迁移后行为异常时,可按层定位: + +1. 查原理图,确认引脚、外部上拉/下拉与按下电平; +2. 确认 `GET_PIN()` 与实际端口、引脚号匹配; +3. 在 `rt_pin_mode()`、`rt_pin_attach_irq()`、`rt_pin_irq_enable()` 处检查返回值; +4. 进入 PIN 框架层,确认其 `ops` 已绑定且目标函数非空; +5. 单步进入 BSP 的 GPIO 驱动,查看模式、EXTI 线和触发边沿是否正确传给厂商 HAL; +6. 在中断服务函数与用户回调处打断点,判断问题发生在硬件中断前、框架转发中,还是用户处理逻辑中。 + +这种“从上层 API 沿 `ops` 单步进入底层”的方法与 `test_dev` 实验完全相同,是理解 RT-Thread 各类设备框架的通用方法。 + +## 10. I2C 总线:消息传输与故障恢复 + +### 10.1 I2C 信号、地址与总线设备 + +I2C(Inter-Integrated Circuit)由 Philips 提出,是一种同步串行总线。它用两根信号线通信: + +- `SCL`:时钟线; +- `SDA`:数据线。 + +两根线通常都需要上拉电阻,设备只主动拉低线路、由上拉电阻恢复高电平。这也是 I2C 能让多个设备共享同一组线的电气基础。 + +I2C 的常见关键信号如下: + +| 信号 | 条件 | +| --- | --- | +| 起始(START) | `SCL` 为高电平时,`SDA` 从高变低。 | +| 停止(STOP) | `SCL` 为高电平时,`SDA` 从低变高。 | +| 应答(ACK) | 在第 9 个时钟周期,接收方将 `SDA` 拉低。 | +| 非应答(NACK) | 在第 9 个时钟周期,`SDA` 未被拉低。 | + +一条 I2C 总线可挂接多个从设备;只要地址不冲突,主机就能通过地址选择目标设备。常见设备使用 **7 位地址**;在线路上传输地址字节时,7 位地址会左移一位,最低位作为读写位:`0` 表示写,`1` 表示读。也存在 10 位地址设备,但需按对应标志和协议处理。 + +在 RT-Thread 中,I2C 控制器被注册为总线设备。例如启用并完成驱动注册后,可在 MSH 中用 `list_device` 观察到 `i2c1`、`i2c2` 之类的总线名称。I2C-Tools 软件包可用于扫描总线地址或做基础读写验证,但扫描操作本身也会产生总线访问,应避免在关键业务运行时频繁执行。 + +### 10.2 rt_i2c_msg 与传输 API + +RT-Thread 用 `struct rt_i2c_msg` 描述一次 I2C 消息,核心信息包括: + +| 字段/概念 | 作用 | +| --- | --- | +| `addr` | 从设备地址。对常见 7 位设备,应填 7 位原始地址,不要自行左移或拼接读写位。 | +| `flags` | 指定读/写方向及可选的起始、停止、ACK、10 位地址等传输行为。 | +| `buf` | 发送数据或接收数据的缓冲区。 | +| `len` | 本条消息的数据字节数。 | + +基本流程是“查找总线 → 构造消息 → 调用传输函数 → 检查结果”。 + +```c +#include +#include + +static rt_err_t i2c_write_one_byte(void) +{ + struct rt_i2c_bus_device *bus; + struct rt_i2c_msg msg; + rt_uint8_t data = 0x6B; + rt_size_t transferred; + + bus = (struct rt_i2c_bus_device *)rt_device_find("i2c2"); + if (bus == RT_NULL) + return -RT_ERROR; + + msg.addr = 0x68; /* 7 位设备地址,例如课程示例 */ + msg.flags = RT_I2C_WR; + msg.buf = &data; + msg.len = 1; + + transferred = rt_i2c_transfer(bus, &msg, 1); + return (transferred == 1) ? RT_EOK : -RT_ERROR; +} +``` + +`rt_i2c_transfer(bus, msgs, num)` 的第三个参数 `num` 是**消息条数**;返回值表示成功传输的消息条数。因此上例的一条消息包含 1 个字节,成功时返回 `1`。如果构造两条消息(例如“先写寄存器地址,再读寄存器内容”),应传入消息数组并将 `num` 设为 `2`,成功时应检查返回值是否为 `2`,而不是把返回值误解为读写字节数。 + +读取数据只需把消息的方向改为 `RT_I2C_RD`,并让 `buf` 指向可写入的接收缓冲区。实际传感器常需要先写寄存器地址、再重复起始并读取数据;可用多个 `rt_i2c_msg` 一次提交,具体标志组合应参考当前 RT-Thread I2C 头文件和器件手册。 + +> I2C 地址最常见的错误是把数据手册显示的“8 位读/写地址”直接填入 `msg.addr`。应先确认数据手册给的是 7 位地址还是已经包含读写位的地址;RT-Thread 常规用法中,`msg.addr` 填 7 位地址,读写由 `flags` 指定。 + +### 10.3 I2C 锁死的排查与恢复 + +I2C 可能因通信中途复位、从设备异常或线路问题进入总线忙状态。例如从设备在传输中把 `SDA` 持续拉低,主机无法产生正常 STOP,后续传输就可能失败。 + +常见排查顺序: + +1. 确认 `SCL`、`SDA` 连接、共地和上拉电阻正确; +2. 确认设备地址是 7 位/10 位中的正确形式,读写方向和寄存器地址顺序符合器件手册; +3. 用示波器或逻辑分析仪观察 START、ACK、SCL/SDA 是否被持续拉低; +4. 检查总线传输返回值和底层错误码。某些 BSP 中看到 `-5` 一类错误时,可能与 NACK、地址错误或底层 I/O 异常有关,不能只凭数值断定唯一原因; +5. 必要时执行总线恢复:将 SCL 临时配置为 GPIO,输出最多 9 个时钟脉冲以让从设备移出未完成的数据位,再按总线状态产生 STOP,最后恢复 I2C 外设配置。 + +“9 个时钟脉冲”是常用恢复策略,而非对所有硬件故障都有效。若线路短路、上拉缺失或从设备持续故障,仍应先解决硬件问题;恢复代码也必须遵循当前 MCU/BSP 对 GPIO 和 I2C 外设重初始化的要求。 + +## 11. SPI 总线:设备挂载、配置与传输 + +### 11.1 SPI 的总线—设备模型 + +SPI 是同步串行通信方式,通常使用 SCK、MOSI、MISO 和 CS(片选)等信号。它通常比 I2C 使用更多 IO,但支持全双工通信且常用于较高速度的外设,例如 W25Q128 Flash、显示屏、无线模块等。 + +在 RT-Thread 中,SPI 遵循“**总线—设备**”模型:SPI 控制器先注册为一条 SPI 总线,具体芯片再作为设备挂载到该总线上。多个设备可以共用 SCK/MOSI/MISO,但每个设备通常需要独立 CS。 + +```text +SPI 控制器 1 + ↓ 注册为 spi1 总线 +├── spi10:spi1 上的第 0 个设备,使用自己的 CS +└── spi11:spi1 上的第 1 个设备,使用自己的 CS +``` + +`spixy` 是课程使用的常见命名约定:`x` 表示总线号,`y` 表示该总线上的设备序号。因此 `spi10` 可理解为 `spi1` 总线上的 0 号设备。命名是约定而非硬件限制;关键是设备名在系统中唯一且与代码一致。 + +在 STM32 等部分 BSP 中,可使用类似下列接口把设备挂到总线: + +```c +rt_hw_spi_device_attach("spi2", "spi20", GET_PIN(B, 12)); +``` + +三个参数分别表示总线名、注册后的设备名和 CS 引脚。该 API 是否可用、CS 参数类型及总线名称会随 BSP 不同而变化;注册后可用 `list_device` 区分 SPI 总线与 SPI 设备是否都已出现。 + +### 11.2 SPI 设备配置 + +应用通常先用 `rt_device_find()` 获取 SPI 设备,再通过 `rt_spi_configure()` 设置 `struct rt_spi_configuration`。常见配置项如下: + +| 配置 | 含义 | +| --- | --- | +| `max_hz` | 最大时钟频率,不能超过从设备与控制器共同支持的上限。 | +| `data_width` | 单个数据帧位宽,常见为 8 位或 16 位。 | +| `mode` | 主/从模式、时钟极性 CPOL、时钟相位 CPHA、位序等组合。 | +| 位序 | `MSB` 在前或 `LSB` 在前。 | +| CS 极性/三线模式等 | 少数设备需要特殊片选极性或三线配置,按器件和 BSP 能力设置。 | + +SPI 四种模式由 CPOL 和 CPHA 组合决定: + +| 模式 | CPOL | CPHA | +| --- | --- | --- | +| Mode 0 | 0 | 0 | +| Mode 1 | 0 | 1 | +| Mode 2 | 1 | 0 | +| Mode 3 | 1 | 1 | + +模式、最高频率、位宽和位序必须与从设备手册匹配。课程所用器件常见 Mode 0 或 Mode 2,但这不是 SPI 的通用默认值,配置前应先查具体器件时序图。 + +### 11.3 常用 SPI 传输操作 + +SPI 的物理层是全双工:每发送一个数据帧,通常也会同时接收一个数据帧。因此“只发送”或“只接收”是框架对全双工传输的便利封装;纯接收时,主机仍可能需要输出 dummy 数据来产生时钟。 + +常用 API 语义可概括为: + +| 操作 | 典型 API | 用途 | +| --- | --- | --- | +| 单次全双工传输 | `rt_spi_transfer()` | 同时指定发送与接收缓冲区。 | +| 只发送 | `rt_spi_send()` | 向设备发送命令或数据。 | +| 只接收 | `rt_spi_recv()` | 时钟出数据并接收。 | +| 连续发送 | `rt_spi_send_then_send()` | 在片选保持有效的情况下分两段发送。 | +| 先发后收 | `rt_spi_send_then_recv()` | 先发送命令/地址,再接收返回数据。 | + +例如读取 Flash 寄存器常是“发送读命令和地址 → 接收返回数据”,适合 `rt_spi_send_then_recv()`;但 CS 是否在两段之间保持有效、函数是否可用及返回值语义,要以当前 RT-Thread 版本和器件协议为准。 + +## 12. BSP、SDK 与 CubeMX 的配置差异 + +### 12.1 基于芯片与基于 SDK 的工程 + +课程将 RT-Thread Studio 中的两类工程配置方式作了对比: + +| 方式 | 特点 | 外设配置常见流程 | +| --- | --- | --- | +| 基于芯片(BSP) | 需兼容多个芯片型号,工程通常用 `board.h`、`board.c` 中的宏和用户实现组织差异。 | 使能对应外设宏;用 CubeMX 为目标芯片生成初始化代码;按 BSP 注释/模板将需要的初始化函数或配置拷入 `board.c`,并确认 HAL 配置启用对应外设。 | +| 基于 SDK / 固定板卡 | 面向特定芯片或板卡,SDK 中往往已包含可直接复用的配置与驱动。 | 在工程配置中启用组件/驱动;利用已有 CubeMX 生成文件或板级配置,通常不必为每次使用手工搬运全部初始化代码。 | + +CubeMX 的职责主要是根据图形化选择生成引脚复用、时钟、外设初始化和 HAL 配置代码。RT-Thread 设备驱动在初始化时会调用 HAL/底层初始化入口;因此 CubeMX 配置与 RT-Thread 驱动并不是二选一,而是“CubeMX 生成底层配置,RT-Thread 框架统一管理设备”。 + +不同 Studio 版本、BSP 和 SDK 的目录结构差异很大。课程中出现的“将生成函数复制到 `board.c`”“在 `board.h` 打开 `BSP_USING_*` 宏”等步骤适用于相应的基于芯片工程;在自己的工程操作前,应先阅读 `board.h` 注释、Kconfig 选项和 BSP 已有驱动,不要把另一类工程的步骤直接照搬。 + +### 12.2 硬件 I2C 支持的边界 + +软件 I2C(Soft I2C)通过普通 GPIO 模拟 SCL 和 SDA 时序,只要 BSP 已完成 PIN 设备框架对接,通常能较方便地使用;代价是 CPU 参与时序控制,速度和实时性受限。 + +硬件 I2C 由 MCU 外设控制器完成传输,通常效率更高,但需要同时满足: + +1. 芯片本身有对应 I2C 控制器; +2. CubeMX/板级配置正确启用了该外设及其引脚复用; +3. 当前 BSP 已实现并启用了硬件 I2C 驱动; +4. Kconfig 或 `board.h` 的相关开关已打开。 + +课堂所用的某些旧版芯片支持包只提供 Soft I2C,因而配置菜单中看不到硬件 I2C 并不代表 RT-Thread 主线不支持该 MCU 的硬件 I2C。可先查看主线/BSP 是否已有对应驱动;若确实缺失,可在理解现有驱动和充分测试后升级或移植驱动。不要仅通过启用一个宏就假定硬件 I2C 已能工作。