囤了很久的笔记——rtt篇

RT-Thread 嵌入式开发学习日志

本笔记记录了从 STM32 裸机开发到 RT-Thread RTOS 的完整学习过程,涵盖外设驱动、内存管理、线程调度、IPC 通信等核心知识点。


一、内存管理——理解一切的基础

在正式学习 RTOS 之前,必须先搞清楚程序运行时内存是怎么分布的。这不是可选项,而是必修课。

很多人学 RTOS 时遇到奇怪的崩溃、数据错乱、野指针,追根溯源几乎都是内存问题。只有真正理解了内存的布局和生命周期,才能看懂线程栈是怎么分配的、邮箱为什么不能传局部变量、全局变量和堆变量有什么本质区别。


1.1 STM32 的存储器结构

STM32 有两类存储器,职责完全不同:

存储器 类型 用途 掉电
Flash 非易失性 存放程序代码、常量、初始值 保留
RAM 易失性 程序运行时的工作内存 丢失

Flash 是只读的运行载体,RAM 才是程序真正"活动"的地方。理解内存管理,本质上就是理解 RAM 是怎么被划分和使用的。


1.2 RAM 的完整划分

一个 C 程序运行时,RAM 被划分为以下几个区域:

RAM 地址空间(以 STM32F4 为例,RAM 起始地址 0x20000000)

┌──────────────────────┐  0x20000000(低地址)
│      .data 段        │  已初始化的全局/静态变量
│  int a = 10;         │  (初始值从 Flash 复制过来)
├──────────────────────┤
│      .bss 段         │  未初始化的全局/静态变量
│  int b;              │  (启动时由启动文件自动清零)
├──────────────────────┤
│                      │
│      堆 (Heap)       │  ↑ 向高地址增长
│  malloc/rt_malloc    │  动态分配,需手动释放
│                      │
│      ~~~~~       │  (空闲区域,堆和栈在此相向增长)
│                      │
│      栈 (Stack)      │  ↓ 向低地址增长
│  局部变量、返回地址   │  函数调用时自动压栈/出栈
│                      │
└──────────────────────┘  0x20000000 + RAM_SIZE(高地址)

各区域详解:

.data 段

  • 存放所有已赋初值的全局变量和静态变量
  • 初始值保存在 Flash 里,芯片上电后由启动文件(startup_stm32xxx.s)自动从 Flash 复制到 RAM
  • 例:int count = 100;(全局)→ 放在 .data 段

.bss 段

  • 存放所有未赋初值的全局变量和静态变量
  • 不占用 Flash 空间(只记录大小),启动时由启动文件自动清零
  • 例:int count;(全局,未赋值)→ 放在 .bss 段,启动后值为 0

堆(Heap)

  • 程序运行时通过 malloc / rt_malloc 动态申请的内存
  • 从低地址向高地址增长
  • 必须手动释放,否则造成内存泄漏
  • 裸机中堆的大小由链接脚本(.ld 文件)中的 _Min_Heap_Size 决定

栈(Stack)

  • 函数调用时自动分配,存放局部变量、函数参数、返回地址、寄存器现场
  • 从高地址向低地址增长
  • 函数返回时自动回收,无需手动管理
  • 裸机中栈的大小由链接脚本中的 _Min_Stack_Size 决定(通常 512B ~ 4KB)

1.3 裸机 vs RTOS:内存管理的本质区别

这是引入 RTOS 后最需要重新理解的部分。

裸机的内存模型

裸机程序只有一个执行流(main + 中断),内存结构非常简单:

裸机 RAM 布局
┌──────────────┐
│  .data/.bss  │  全局变量(整个程序生命周期)
├──────────────┤
│     堆       │  malloc(一般裸机很少用)
├──────────────┤
│     栈       │  整个程序共用一个栈
│              │  main → func_a → func_b → ...
└──────────────┘
  • 只有一个栈:所有函数调用共用这一个栈,深度嵌套调用会导致栈溢出
  • 堆几乎不用:裸机开发中 malloc 用得很少,担心碎片化和泄漏
  • 中断使用同一个栈:中断发生时直接在当前栈上压栈,需要预留足够空间

RTOS 的内存模型

引入 RTOS 后,内存结构发生了根本性变化:

RTOS RAM 布局(以 RT-Thread 为例)
┌──────────────────────────┐
│      .data / .bss        │  全局变量(同裸机)
├──────────────────────────┤
│                          │
│    RT-Thread 内存堆       │  由 RT-Thread 统一管理
│  ┌────────────────────┐  │
│  │  线程1 的栈空间      │  │  ← rt_thread_create 时从堆里划出
│  ├────────────────────┤  │
│  │  线程2 的栈空间      │  │  ← 每个线程有自己独立的栈
│  ├────────────────────┤  │
│  │  线程3 的栈空间      │  │
│  ├────────────────────┤  │
│  │  线程控制块(TCB)     │  │  ← 线程的"身份证"也在堆里
│  ├────────────────────┤  │
│  │  信号量/互斥锁对象    │  │  ← IPC 对象也从堆里分配
│  ├────────────────────┤  │
│  │  消息队列缓冲区      │  │
│  └────────────────────┘  │
│                          │
└──────────────────────────┘

核心区别对比:

维度 裸机 RTOS
栈的数量 1 个(全局共用) N 个(每个线程独立一个)
栈的分配方式 链接脚本静态分配 从 OS 堆动态分配
堆的管理者 C 库(malloc) RT-Thread(rt_malloc)
内存隔离 无隔离,全局共享 线程栈相互隔离
动态对象 几乎没有 线程、信号量、队列等都是动态对象
内存风险 栈溢出覆盖全局变量 线程栈溢出只影响本线程(相对隔离)

关键理解:RTOS 的"堆中之栈"

这是最容易混淆的概念。在 RTOS 里:

// 创建线程时,RT-Thread 在内部做了这些事:
rt_thread_t tid = rt_thread_create(
    "task1",      // 线程名
    entry,        // 入口函数
    RT_NULL,      // 参数
    1024,         // ← 这 1024 字节的"栈",是从 RT-Thread 的堆里 rt_malloc 出来的!
    10,           // 优先级
    10            // 时间片
);
  • 这 1024 字节在物理上是堆内存(rt_malloc 分配)
  • 但在逻辑上是这个线程的栈(线程运行时 SP 寄存器指向这里)
  • 线程被删除时,这块内存会被 rt_free 归还给堆

所以说:RTOS 的线程栈,本质上是堆里划出来的一块区域,被当作栈来使用。


1.4 变量的生命周期与存储位置

理解了内存布局,就能准确判断每种变量的生命周期:

变量类型 存储位置 生命周期 示例
全局变量(有初值) .data 段 整个程序运行期间 int g = 1;(文件作用域)
全局变量(无初值) .bss 段 整个程序运行期间 int g;(文件作用域)
静态局部变量 .data/.bss 段 整个程序运行期间 static int s = 0;(函数内)
局部变量 栈 函数执行期间 int x = 5;(函数内)
动态分配 堆 malloc 到 free 之间 int *p = malloc(4);

静态局部变量的特殊性:

void counter(void) {
    static int cnt = 0;  // 存在 .data 段,不在栈上!
    cnt++;               // 每次调用都保留上次的值
    rt_kprintf("%d\n", cnt);
}
// 调用3次输出:1, 2, 3
// 如果是普通局部变量,每次都是 0

1.5 裸机 vs RTOS 的堆管理差异

裸机的堆(C 标准库 malloc)

裸机使用 C 标准库的 malloc/free,底层由 newlib 或 microlib 实现:

// 裸机动态分配
uint8_t *buf = (uint8_t *)malloc(64);
if (buf == NULL) { /* 分配失败处理 */ }
// 使用...
free(buf);

裸机堆的问题:

  • 不是线程安全的(裸机没有线程,但中断里调用 malloc 会出问题)
  • 长时间运行后容易产生内存碎片
  • 堆大小固定,由链接脚本决定,超出直接返回 NULL

RTOS 的堆(RT-Thread 内存管理)

RT-Thread 接管了堆的管理,提供多种内存管理算法:

// RT-Thread 动态分配
void *ptr = rt_malloc(64);
if (ptr == RT_NULL) { /* 分配失败处理 */ }
// 使用...
rt_free(ptr);

// 分配并清零(类似 calloc)
void *ptr2 = rt_calloc(4, sizeof(int));  // 分配 4 个 int 并清零

// 重新调整大小(类似 realloc)
ptr = rt_realloc(ptr, 128);

RT-Thread 提供三种内存管理算法(可在 menuconfig 中选择):

算法 适用场景 特点
小内存管理(small mem) RAM < 2MB 的小型系统 简单高效,有碎片
slab 内存管理 频繁分配固定大小对象 碎片少,速度快
memheap 管理 多块不连续内存 可管理多个内存区域

RT-Thread 堆的优势:

  • 线程安全:内部有互斥保护,多线程同时 rt_malloc 不会出问题
  • 可调试:可通过 free 命令查看内存使用情况
  • 统一管理:线程栈、IPC 对象、用户数据都从同一个堆里分配
// 在 FinSH 控制台输入 free 命令,可以看到:
msh >free
total memory: 98304        ← 总堆大小
used memory : 12480        ← 已使用
maximum allocated memory: 13056

1.6 内存管理不当引发的典型 Bug

学习过程中亲身踩过的坑,直接说明了理解内存的重要性。

Bug 1:邮箱传递局部变量地址(悬空指针)

// ❌ 错误写法:发送局部变量的地址
void thread1_entry(void *param) {
    struct sensor_data data = {25, 60, 3};  // 局部变量,在线程1的栈上
    rt_mb_send(mb, (rt_uint32_t)&data);     // 发送的是栈地址
    // 函数继续执行,data 所在的栈帧随时可能被覆盖!
}

void thread2_entry(void *param) {
    struct sensor_data *p;
    rt_mb_recv(mb, (rt_uint32_t*)&p, RT_WAITING_FOREVER);
    // ❌ 此时 p 指向的内存已经是 thread1 栈上的垃圾数据
    rt_kprintf("温度:%d\n", p->temperature);  // 读到随机值,甚至崩溃
}

根本原因:局部变量在线程的栈上,线程函数继续执行后栈帧会被后续调用覆盖,地址虽然还在,但内容已经不可信。

// ✅ 正确写法1:使用全局变量(生命周期贯穿整个程序)
static struct sensor_data global_data;
global_data.temperature = 25;
rt_mb_send(mb, (rt_uint32_t)&global_data);

// ✅ 正确写法2:使用消息队列(内部 memcpy,数据安全)
rt_mq_send(mq, &data, sizeof(data));  // 数据被拷贝进队列,局部变量也安全

Bug 2:邮箱接收类型错误(栈破坏)

// ❌ 错误写法:用 char 接收邮箱数据
char received;  // char 只有 1 字节!
rt_mb_recv(mb, (rt_uint32_t*)&received, RT_WAITING_FOREVER);
// 邮箱固定传 4 字节,写入 char 变量会越界覆盖栈上相邻的数据!

// ✅ 正确写法:必须用 rt_uint32_t 接收
rt_uint32_t received;
rt_mb_recv(mb, &received, RT_WAITING_FOREVER);

根本原因:邮箱的每封"信"固定是 4 字节(rt_uint32_t),如果接收变量不够大,rt_mb_recv 写入时会越界,破坏栈上相邻的数据,导致难以排查的随机崩溃。

Bug 3:线程栈溢出

// ❌ 栈空间给太小,函数调用层次深时溢出
rt_thread_create("task", entry, RT_NULL, 256, 10, 10);
//                                        ^^^ 256 字节往往不够

// entry 函数里调用了 OLED_ShowStr → OLED_Write_data → rt_i2c_transfer → ...
// 每层调用都要压栈,256 字节很快就用完了,溢出后覆盖相邻线程的栈或堆数据

排查方法:RT-Thread 提供栈使用率检测:

// 在 FinSH 里输入 list_thread 可以看到每个线程的栈使用情况
msh >list_thread
thread   pri  status      sp     stack size max used left tick  error
-------- ---  ------- ---------- ----------  ------  ---------- ---
task1     10  suspend 0x000001a4 0x00000400    42%   0x00000009 000
//                                             ^^^
//                              栈使用率 42%,说明还有余量
//                              如果接近 100% 就要增大栈空间

1.7 内存管理总结:从裸机到 RTOS 的思维转变

理解了以上内容,就能建立起一个完整的内存视角:

裸机思维:
  "我有一块 RAM,全局变量放前面,栈在后面,偶尔 malloc 一下"

RTOS 思维:
  "我有一块 RAM,OS 把它统一管理成一个大堆
   每个线程从堆里借一块当自己的栈
   IPC 对象(信号量/队列)也从堆里分配
   我需要时刻清楚:这个变量在哪里?它的生命周期到什么时候?"

一句话记住核心原则:

在 RTOS 里,凡是需要跨线程共享的数据,要么放全局变量(.data/.bss),要么用消息队列(OS 帮你拷贝),绝对不能传局部变量的地址。


有了这个内存基础,后面学习线程调度、IPC 通信时遇到的很多"玄学问题"都能找到根因。接下来先从裸机阶段的踩坑开始。


二、STM32 裸机阶段——踩坑与修复

2.1 ADC 配置 Bug:未初始化结构体成员

现象:ADC 读出的值明显偏大,寄存器 CFGR1 = 0x0000202F,说明 ADC 处于 10 位左对齐模式,而不是预期的 12 位右对齐。

根因分析:

// ❌ 错误写法
ADC_InitTypeDef ADC_InitStructure;
// 没有调用 ADC_StructInit,结构体成员是栈上的垃圾值
ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
// ADC_ExternalTrigConv 没有赋值,是随机值,被 OR 进了寄存器!
ADC_Init(ADC1, &ADC_InitStructure);

修复方案:

// ✅ 正确写法:先调用 StructInit 清零,再按需赋值
ADC_InitTypeDef ADC_InitStructure;
ADC_StructInit(&ADC_InitStructure);  // 关键!将所有成员初始化为默认值
ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b;
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;
ADC_InitStructure.ADC_ExternalTrigConvEdge = ADC_ExternalTrigConvEdge_None;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ScanDirection = ADC_ScanDirection_Upward;
ADC_Init(ADC1, &ADC_InitStructure);

教训:C 语言中局部变量不会自动清零,结构体初始化必须显式处理。


三、I2C 协议与软件 I2C 驱动

3.1 I2C 协议基础

  • 两线制:SCL(时钟线)+ SDA(数据线),支持多主多从
  • 起始/停止条件:SCL 高电平时 SDA 下降沿为 START,上升沿为 STOP
  • 应答机制:每传输 8 位后,接收方拉低 SDA 表示 ACK

3.2 软件 I2C 驱动设计(低耦合宏定义方案)

通过宏定义隔离硬件,驱动代码与具体引脚解耦:

// my_i2c_soft.h
#define IIC_SCL_PORT    GPIOB
#define IIC_SCL_PIN     GPIO_Pin_6
#define IIC_SDA_PORT    GPIOB
#define IIC_SDA_PIN     GPIO_Pin_7

#define IIC_GPIO_RCC_ENABLE() RCC_AHBPeriphClockCmd(RCC_AHBPeriph_GPIOB, ENABLE)
#define IIC_SCL(x) do{ (x) ? GPIO_SetBits(IIC_SCL_PORT, IIC_SCL_PIN) \
                            : GPIO_ResetBits(IIC_SCL_PORT, IIC_SCL_PIN); }while(0)
#define IIC_SDA(x) do{ (x) ? GPIO_SetBits(IIC_SDA_PORT, IIC_SDA_PIN) \
                            : GPIO_ResetBits(IIC_SDA_PORT, IIC_SDA_PIN); }while(0)
#define IIC_READ_SDA()  GPIO_ReadInputDataBit(IIC_SDA_PORT, IIC_SDA_PIN)
#define IIC_DELAY()     do{ volatile int i = 10; while(i--); }while(0)

3.3 SSD1306 OLED 驱动要点

  • Page Addressing Mode:128×64 屏幕分为 8 页,每页 8 行,每列 1 字节(垂直方向 bit0 在上)
  • GDDRAM:显示数据存储在芯片内部的图形显示 RAM 中
  • I2C 地址:0x78(含读写位的 8 位地址)= 0x3C(7 位纯净地址)
// HAL 库写法
void OLED_Write_cmd(uint8_t cmd) {
    HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 0x100);
}

// OLED_Printf 封装
void OLED_Printf(uint8_t x, uint8_t y, const char *format, ...) {
    char buffer[64];
    va_list args;
    va_start(args, format);
    vsnprintf(buffer, sizeof(buffer), format, args);
    va_end(args);
    OLED_ShowStr(x, y, buffer, 16);
}

四、SPI 协议与 NRF24L01

4.1 SPI 四种模式

模式 CPOL CPHA 空闲时钟 采样边沿
Mode 0 0 0 低电平 上升沿
Mode 1 0 1 低电平 下降沿
Mode 2 1 0 高电平 下降沿
Mode 3 1 1 高电平 上升沿

4.2 软件 SPI 字节交换(Mode 0)

SPI 的本质是"交换"——主机发一位,同时收一位:

unsigned char SPI_Swapbyte(unsigned char bytesend) {
    unsigned char i;
    for (i = 0; i < 8; i++) {
        if (bytesend & 0x80) SPI_W_MOSI(1);
        else                 SPI_W_MOSI(0);
        bytesend <<= 1;
        SPI_W_SCK(1);                        // 上升沿:从机采样 MOSI
        if (SPI_R_MISO()) bytesend |= 0x01;  // 同时主机采样 MISO
        SPI_W_SCK(0);
    }
    return bytesend;
}

五、RT-Thread RTOS 核心概念

5.1 线程调度

优先级调度 + 时间片轮转:

  • RT-Thread 支持 0~31 共 32 个优先级,数字越小优先级越高
  • 相同优先级的线程按时间片轮转
  • 就绪列表(Ready List)本质是一个按优先级组织的链表数组
  • 位图调度算法:用一个 32 位整数的每一位代表对应优先级是否有就绪线程,通过 __CLZ(计前导零)指令 O(1) 找到最高优先级

5.2 链表结构

RT-Thread 内核大量使用侵入式双向循环链表:

// 节点嵌入在数据结构内部(侵入式)
struct rt_thread {
    // ...
    rt_list_t list;   // 链表节点就在线程控制块里
    // ...
};

优点:无需额外内存分配,通过 rt_container_of 宏从节点指针反推出整个结构体指针。

5.3 线程同步

互斥锁(Mutex)

  • 有所有权:只有加锁的线程才能解锁
  • 防优先级反转:持有锁的低优先级线程会被临时提升优先级
  • 适用场景:保护共享资源(全局变量、外设)
rt_mutex_t mutex = rt_mutex_create("mtx", RT_IPC_FLAG_PRIO);

// 线程 A
rt_mutex_take(mutex, RT_WAITING_FOREVER);
// ... 操作共享资源 ...
rt_mutex_release(mutex);

信号量(Semaphore)

  • 无所有权:任意线程都可以 release
  • 计数型:可以累积多次 release
  • 适用场景:事件通知、资源计数
rt_sem_t sem = rt_sem_create("sem", 0, RT_IPC_FLAG_PRIO);

// 生产者线程
rt_sem_release(sem);

// 消费者线程
rt_sem_take(sem, RT_WAITING_FOREVER);

5.4 线程通信

邮箱(Mailbox)

  • 每次只能传递 4 字节(通常是指针)
  • 不拷贝数据,传的是地址
  • 注意:绝对不能发送局部变量的地址(悬空指针问题,见第一章)
rt_mailbox_t mb = rt_mb_create("mb", 4, RT_IPC_FLAG_PRIO);

// 发送方(必须用全局变量)
static struct sensor_data global_data = {20, 30, 5};
rt_mb_send(mb, (rt_uint32_t)&global_data);

// 接收方
struct sensor_data *p;
rt_mb_recv(mb, (rt_uint32_t*)&p, RT_WAITING_FOREVER);
rt_kprintf("温度:%d\n", p->temperature);

消息队列(Message Queue)

  • 通过 memcpy 拷贝数据,发送方数据可以是局部变量
  • 更安全,但有内存拷贝开销
// 创建:消息大小 = sizeof(struct sensor_data),队列深度 = 4
rt_mq_t mq = rt_mq_create("mq", sizeof(struct sensor_data), 4, RT_IPC_FLAG_PRIO);

// 发送(数据被拷贝进队列,局部变量也安全)
struct sensor_data data = {25, 60, 3};
rt_mq_send(mq, &data, sizeof(data));

// 接收
struct sensor_data recv;
rt_mq_recv(mq, &recv, sizeof(recv), RT_WAITING_FOREVER);

邮箱 vs 消息队列对比:

特性 邮箱 消息队列
传输内容 4 字节(指针) 任意大小数据
数据拷贝 不拷贝 memcpy 拷贝
安全性 需注意悬空指针 更安全
性能 更快 有拷贝开销

5.5 信号(Signal)

RT-Thread 的信号类似 Linux 的软件中断,可以打断线程的正常执行流:

void thread1_signal_handler(int sig) {
    rt_kprintf("收到信号 %d\n", sig);
}

static void thread1_entry(void *parameter) {
    rt_signal_install(SIGUSR1, thread1_signal_handler);
    rt_signal_unmask(SIGUSR1);
    // 正常运行...
}

// 在其他地方发送信号
rt_thread_kill(tid1, SIGUSR1);

六、RT-Thread 设备框架

6.1 BSP 与自动初始化机制

RT-Thread 的 BSP(板级支持包)通过 INIT_DEVICE_EXPORT 宏实现自动初始化:

// drv_i2c.c 底层驱动(系统自动调用,无需手动 Init)
INIT_DEVICE_EXPORT(rt_hw_i2c_init);

进入 main() 之前,系统已经自动完成了所有硬件初始化,不需要也不应该再调用 MX_I2C1_Init() 等 CubeMX 生成的函数。

6.2 用 RTT I/O 框架重构 OLED 驱动

核心思想:应用层只调用 OS 提供的标准 API,与底层硬件完全解耦。

关键坑:I2C 地址位数差异

库 地址格式 示例
HAL 库 8 位(含读写位) 0x78
RT-Thread 7 位(纯净地址) 0x3C(= 0x78 >> 1)
#include <rtdevice.h>

#define OLED_I2C_BUS_NAME  "i2c1"
#define OLED_ADDR_7BIT     0x3C   // ⚠️ 不是 0x78!

static struct rt_i2c_bus_device *i2c_bus = RT_NULL;

void OLED_Write_cmd(uint8_t cmd) {
    uint8_t buf[2] = {0x00, cmd};
    struct rt_i2c_msg msg;
    msg.addr  = OLED_ADDR_7BIT;
    msg.flags = RT_I2C_WR;
    msg.buf   = buf;
    msg.len   = 2;
    rt_i2c_transfer(i2c_bus, &msg, 1);
}

int OLED_Init(void) {
    i2c_bus = (struct rt_i2c_bus_device *)rt_device_find(OLED_I2C_BUS_NAME);
    if (i2c_bus == RT_NULL) {
        rt_kprintf("Fatal: can't find %s!\n", OLED_I2C_BUS_NAME);
        return -1;
    }
    rt_thread_mdelay(100);
    // ... 发送初始化命令序列 ...
    return 0;
}

6.3 软件 I2C 配置(board.h)

// drivers/board.h 中,在官方预留的 #ifdef 块内填写引脚
#define BSP_USING_I2C1
#ifdef BSP_USING_I2C1
    #define BSP_I2C1_SCL_PIN    GET_PIN(B, 6)
    #define BSP_I2C1_SDA_PIN    GET_PIN(B, 7)
#endif

为什么用软件 I2C? STM32 早期硬件 I2C 存在硅缺陷,RT-Thread 官方 BSP 默认主推软件模拟 I2C,稳定性更高。对于 OLED 这类低速外设,软件 I2C 完全够用。

6.4 UART 设备框架

rt_device_t uart2_dev = rt_device_find("uart2");

struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;
config.baud_rate = BAUD_RATE_9600;
config.bufsz     = 128;

rt_device_control(uart2_dev, RT_DEVICE_CTRL_CONFIG, &config);
rt_device_open(uart2_dev, RT_DEVICE_OFLAG_RDWR | RT_DEVICE_FLAG_INT_RX);

七、学习路线回顾

裸机开发
  └─ ADC 踩坑(未初始化结构体)→ 养成 StructInit 习惯
  └─ 软件 I2C → 理解协议时序
  └─ OLED 驱动 → 理解显存映射
  └─ SPI + NRF24L01 → 理解全双工通信

引入 RTOS
  └─ 内存布局 → 理解栈/堆/全局变量的本质
  └─ 线程调度 → 优先级、时间片、位图算法
  └─ 链表结构 → 侵入式双向循环链表
  └─ 线程同步 → mutex(有所有权)vs semaphore(无所有权)
  └─ 线程通信 → mailbox(传指针)vs mq(拷贝数据)
  └─ 信号机制 → 软件中断

架构升级
  └─ 抛弃 HAL 库 → 拥抱 RTT 设备框架
  └─ 自动初始化机制 → 无需手动 Init
  └─ I2C 地址坑(0x78 vs 0x3C)
  └─ 编译孤岛问题 → 宏定义必须放在公共头文件
  └─ 防御性编程 → rt_device_find 返回值检查

最后更新:2026-02-26

2 个赞