RTOS:keysking队列实战

43 阅读17分钟

RTOS:keysking队列实战

前言

写在前面: 最近在跟着 keysking 大佬学习 FreeRTOS,他的教程基于 STM32F103。但我手头只有一块蓝桥杯的 STM32G431RBT6。 本文记录了我将代码移植到 G431 的过程,重点解决了消息队列(Queue)传输结构体指针串口不定长数据接收以及 CLion 开发环境下的 CMake 配置坑。希望对同样使用 Clion + STM32 的同学有所帮助。

keysking大神对应的视频地址

【【FreeRTOS速通】队列实战:复杂数据、中断发送、多对一通信】www.bilibili.com/video/BV1vo…

核心原理:为什么要用队列?

在裸机开发中,我们习惯用全局变量来传递标志位。但在 RTOS 多任务系统中,全局变量就像一个没有红绿灯的十字路口,容易发生“撞车”(数据竞争)。

消息队列(Queue) 就像是一个传送带:

  1. 生产者(按键/串口) 把数据打包放上去。
  2. 消费者(LED任务) 专心从传送带取数据处理。
  3. 解耦:发指令的人只管发,执行的人只管做,互不等待。

image.png

第一步:Cubemax配置

  • LED、按键、串口的引脚配置
  • 按键需配置为 上拉输入
  • 注意 PD2 锁存器引脚,不要遗漏

⚠️ 特别说明: 本实验需要 串口中断接收,因此必须在 NVIC 中开启 USART RX 中断。

image.png 关于按键、串口、LED的配置,具体的引脚请大家找到自己的开发板的原理图,然后查看

image.png 按键记得设置为上拉输入

image.png

上图还有个PD2锁存器忘记标注了,大家不要忘了

因为我们要用到串口接收数据,所以要开启串口的NVIC接收中断

image.png

接下来开启FreeRTOS,创建两个任务(Led_Task和Key_Task)以及一个队列(Led_Queue)

要注意任务的优先级:通常情况下,建议给处理数据输入的任务高一些的优先级,而不影响核心功能的显示类任务给低一些的优先级

image.png

在 CubeMX 中启用 FreeRTOS,并创建以下对象:

任务(Task)
任务名功能优先级
Key_Task处理按键输入
Led_Task控制 LED

🎯 优先级设计原则

  • 输入 / 数据采集任务:高优先级
  • 显示 / 执行类任务:低优先级
队列(Queue)
  • Led_Queue
  • 用于在任务之间传递 复杂数据结构

完成后生成代码,并用 CLion 打开工程。

image.png

image.png

image.png

image.png

image.png

image.png

第二步:打开CLion

选择好Cmake配置文件

image.png

目录结构管理

为了保持工程整洁,建议在 Core 目录下新建 APP 文件夹,并细分为 Task(存放任务逻辑)和 Type(存放公共结构体定义)。

image.png

再在Task文件夹下新建一个.c文件 KeyTask.c

image.png

然后我们再来新建LedTask的源文件

image.png

image.png

CMakeLists.txt 的坑

痛点:在 CLion 中右键新建 .c 文件,IDE 虽然会自动更新 CMakeLists.txt,但有时位置不对,或者你需要手动整理。

接下来注意一点,在CLion中创建的文件,CLion会自动帮我们写到CmakeLists文件中,因而可以正常通过编译,但是,我们最好将其放到target_sources中

image.png

image.png

在VSCode等编辑器中新建文件的话,大家记得自己编辑一下CMakeLists文件,添加对应文件到target_sources中,并且重新加载一下Cmake,否则相应的文件不会参与到编译中

image.png

然后现在我们来编写按键检测函数,因为我们这次要传递 操作哪个小灯,以及打开还是关闭 两种信息,为了在队列里传递一组而不是一个数据,我们需要定义一个信息结构体

对应结构体数据,一般往往比较大,所以我们往往不直接传递,而是传递结构体的指针

Queue_LED:

  • Item Size: sizeof(Led_message*) —— 注意!这里存的是地址(指针),而不是结构体本身。
  • Queue Size: 比如设置为 16。

image.png

image.png 在Type文件夹下创建LEDType.h文件

image.png

image.png LedType.h

 //
 // Created by WangYuPeng on 2026/1/4.
 //
 ​
 #ifndef P3_QUEUEDEMO_LEDTYPE_H
 #define P3_QUEUEDEMO_LEDTYPE_H
 typedef  enum
 {
     led_off = 0,
     led_on = 1,
 }Led_state;
 ​
 typedef  enum
 {
     led_1 = 0,
     led_2 = 1,
     led_3 = 2,
 }led_now_light;
 ​
 typedef struct{
     Led_state state;
     led_now_light led_now;
 }Led_message;
 ​
 #endif //P3_QUEUEDEMO_LEDTYPE_H

为了让其他文件能找到此头文件,我们还需要去修改一下Cmakelists

image.png

然后回到KeyTask.c进行包含。

因为因为include文件夹配置只到App层,所以这里我们写#include "Type/LedType.h"

image.png

image.png 这里直接展示我的KeyTask.c

 #include <stdlib.h>
 ​
 #include "FreeRTOS.h"
 #include "cmsis_os2.h"
 #include "main.h"
 #include "Type/LedType.h"
 //
 // Created by WangYuPeng on 2026/1/4.
 //
 #define KEY1   (1 << 0)
 #define KEY2   (1 << 1)
 #define KEY3   (1 << 2)
 #define KEY4   (1 << 3)
 ​
 void StartKeyTask(void *argument)
 {
 ​
     for (;;)
     {
         static uint8_t key_down=0,key_old =0,key_up = 0,key_val = 0;
         uint8_t temp = 0;
         static Led_state state = led_off;
 ​
         if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_0) == GPIO_PIN_RESET)
             temp |= KEY1;
         if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_1) == GPIO_PIN_RESET)
             temp |= KEY2;
         if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_2) == GPIO_PIN_RESET)
             temp |= KEY3;
         if (HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_RESET)
             temp |= KEY4;
 ​
         key_val = temp;
         key_down = key_val & (key_val ^ key_old);
         key_up   = ~key_val & (key_val ^ key_old);
         key_old = key_val;
 ​
 ​
         if (key_down & KEY1) //如果按键1按下
         {
             HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_10);
             GPIOD->BSRR |= 0x01 << 2;   // 设置 GPIOD 第2位
             GPIOD->BRR |= 0x01 << 2;    // 重置 GPIOD 第2位
             state = !state;
             // Led_message* message = malloc (sizeof(Led_message));
             Led_message* message = pvPortMalloc(sizeof(Led_message));
             message->state = state;
             message->led_now = led_1;
             osMessageQueuePut(Led_QueueHandle,(&message),0,osWaitForever);
         }
         osDelay(10);
     }
 }

技术难点: 为什么通过指针传递? 如果直接传递结构体,FreeRTOS 需要把整个结构体数据拷贝到队列里(Copy by Value)。当结构体很大时,这非常耗时。 优化方案: 我们只传递结构体的地址(指针) ,数据量恒定为 4 字节,效率极高。但要非常注意内存管理(谁申请,谁释放)。

mallocpvPortMalloc这两个函数的区别:

image.png

我们要将这个头文件换成 #include "FreeRTOS.h"

image.png

LedTask:队列接收 + LED 控制

设计亮点
  • 事件驱动任务
  • 没有数据时任务阻塞
  • 不需要 osDelay
LedTask.c(关键点)
 osMessageQueueGet(Led_QueueHandle, &message, 0, osWaitForever);
 ...
 vPortFree(message);

⚠️ 重要规则

  • pvPortMallocvPortFree
  • mallocfree

必须配对使用

然后让我们来到LedTask.c的编写

 #include "cmsis_os2.h"
 #include "FreeRTOS.h"
 #include "main.h"
 #include "Type/LedType.h"
 ​
 //
 // Created by WangYuPeng on 2026/1/4.
 //
 void StartLedTask(void *argument)
 {
     for (;;)
     {
         Led_message* message;
         osMessageQueueGet(Led_QueueHandle,(&message),0,osWaitForever);        //调整好鼠标光标,按下alt + 回车  可以快速补全一些东西缺失的头文件
         switch (message->led_now)
         {
             case led_1:
                 HAL_GPIO_WritePin(GPIOC,GPIO_PIN_8,  message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
                 GPIOD->BSRR |= 0x01 << 2;   // 设置 GPIOD 第2位
                 GPIOD->BRR |= 0x01 << 2;    // 重置 GPIOD 第2位
             break;
             case led_2:
             HAL_GPIO_WritePin(GPIOC,GPIO_PIN_9,  message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
             GPIOD->BSRR |= 0x01 << 2;   // 设置 GPIOD 第2位
             GPIOD->BRR |= 0x01 << 2;    // 重置 GPIOD 第2位
             break;
             case led_3:
             HAL_GPIO_WritePin(GPIOC,GPIO_PIN_10,  message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
             GPIOD->BSRR |= 0x01 << 2;   // 设置 GPIOD 第2位
             GPIOD->BRR |= 0x01 << 2;    // 重置 GPIOD 第2位
             break;
         }
         vPortFree(message);
     }
 ​
 }

注意: 这里必须使用 vPortFree 释放内存,做到“借一还一”,否则系统运行一段时间后会因内存耗尽而崩溃。

这里注意释放内存

malloc vs pvPortMalloc

函数适用场景
malloc裸机 / 标准 C
pvPortMallocFreeRTOS 环境(必须)

因为此任务是由LEDQueue接收到数据这一事件进行驱动的,没有数据时任务根本不会占用cpu运行,因而这个osDelay也没什么必要

然后配置一下下载工程就可以开始上板子看看现象了

image.png

--什么?你不会,你说上面的面板配置文件你不知道怎么弄,那还请先看看我之前的一篇博客,讲解了Clion的一些使用时的配置Clion安装以及配置_clion 安装编译器-CSDN博客

第三步:未完待续

一阶段完成,接下来我们进入二阶段,任务如下

image.png

为了防止数据错乱,我们设计一个简单的协议帧: 包头(0xAA) + 长度(N) + [数据...] + 校验和

包头 + 长度 + 数据区(2*N) + 校验和

image.png

回到cubemax

image.png

我们可以在usart.c的最后实现关于串口中断接收的相关逻辑

image.png 串口中断回调代码部分

usart.c最底部

 ​
 /* USER CODE BEGIN 1 */
 uint8_t rxData;
 void Usart_Start_Receive_Data(void)
 {
   HAL_UART_Receive_IT(&huart1,&rxData,1);
 }
 //在中断里,我们只做一件事:把收到的这一个字节扔进队列。千万不要在中断里做复杂的协议解析!
 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)   
 {
   if (huart->Instance == USART1)
   {
     osMessageQueuePut(CommandQueueHandle,&rxData,1,0);  //这里的第四个参数一定不要写osWaitForever,因为中断我们要求快进快出
     HAL_UART_Receive_IT(&huart1,&rxData,1);
   }
 }
 /* USER CODE END 1 */

中断接收原则

  • 中断中不可阻塞
  • osMessageQueuePut 的 timeout 必须为 0
 osMessageQueuePut(CommandQueueHandle, &rxData, 1, 0);

用到了串口接收记得在开始前指定一下数据放的位置

image.png

需要重点注意的是

image.png

只能写0,因为中断我们是不能阻塞的,必须尽快处理,尽快结束中断

image.png

然后新建CommandTask.c文件

image.png 在Cmakelists中修改一下

image.png

Commandtask.c

image.png

 //
 // Created by WangYuPeng on 2026/1/4.
 //
 #include <math.h>
 #include <stdio.h>
 #include <string.h>
 #include <sys/types.h>
 ​
 #include "main.h"
 #include "FreeRTOS.h"
 #include "usart.h"
 #include "Type/LedType.h"
 ​
 ​
 void StartCommandTask(void *argument)
 {
     uint8_t recv;
     uint8_t command[32];
     uint8_t commandLength = 0; //命令的长度
     uint8_t commandIndex = 0; //解析命令的索引
     for (;;)
     {
         osMessageQueueGet(CommandQueueHandle,&recv,0,osWaitForever);
         if (commandIndex == 0)
         {
             if (recv == 0xAA) //检测包头
             {
                 command[commandIndex++] = recv;
             }
         }else if (commandIndex == 1)
         {
             commandLength = recv; // 获取到命令的长度
             if (commandLength < 4 || commandLength > sizeof(command))   //说明当前的数据有问题,重置接收索引
             {
                 commandIndex = 0;
                 commandLength = 0;
                 memset(command,0,sizeof(command));  //清零数组
                 continue;
             }
             command[commandIndex++] = recv;   // 存储长度字节
             commandIndex = 2;       // 更新索引
 ​
         }else
         {
             command[commandIndex++] = recv;
             if (commandIndex == commandLength)  //当接收到所有数据后
             {
                 //开始计算校验和
                 uint16_t sum = 0;
                 for (int i = 0; i < commandLength - 1; i++)
                 {
                     sum += command[i];
                 }
                 uint8_t human_sum  = (uint8_t)sum&0xff; //取出手动求和的第八位
                 if (human_sum  == command[commandLength - 1]) //说明计算没有问题,现在我们的command数组已经接收完了这一次的数据
                 {
                     /*下面是解析操作*/
                     for (int i=2; i < commandLength - 2; i+=2)
                     {
                         Led_message *message = pvPortMalloc(sizeof(Led_message));
                         if (message != NULL)
                         {
                             message->led_now = command[i];
                             message->state = command[i + 1];
                             osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
                         }
                         else
                         {
                             // 内存分配失败时的处理
                             printf("Error: Memory allocation failed for Led_message.\n");
                         }
                     }
                 }
                 // 重置命令接收状态
                 commandIndex = 0;
                 commandLength = 0;
                 memset(command,0,sizeof(command));
             }
         }
     }
 }
 ​
 ​

接下来让我们看看ai对应Commandtask.c代码的点评

gemini

这是一个非常典型的嵌入式串口数据流解析代码。这段代码的设计思想非常成熟,属于在 RTOS 环境下处理不定长串口数据的“教科书式”写法。

我将其结构和设计思想拆解为以下几个层面来讲解:

一、 核心设计思想:有限状态机 (FSM)

这段代码虽然没有使用 explicit 的 switch(state) 语句,但它利用 commandIndex 变量实现了一个隐式的有限状态机。这是处理串口字节流最可靠的方法。

因为串口发来的是一个一个的“字节”,CPU 并不知道哪个字节是开头,哪个是结尾。代码通过判断 commandIndex 的值,将处理流程分成了三个阶段(状态):

  1. 状态一:寻找包头 (Sync/Header)

    • 代码逻辑if (commandIndex == 0)
    • 思想:这是同步阶段。只有收到了特定的 0xAA,才认为数据包开始了。如果收到垃圾数据(比如上电时的乱码),会因为不等于 0xAA 而被直接丢弃,不会影响后续逻辑。
  2. 状态二:获取长度 (Length Extraction)

    • 代码逻辑else if (commandIndex == 1)

    • 思想:这是动态内存/缓冲区保护阶段。

    • 安全机制:代码在这里做了非常重要的边界检查

      C

       if (commandLength < 4 || commandLength > sizeof(command))
      

      如果接收到的长度太短(连校验位都不够)或者太长(超过了数组容量),直接复位重来。这防止了数组越界溢出(Buffer Overflow),极大地提高了系统的稳定性。

  3. 状态三:接收载荷 (Payload Collection)

    • 代码逻辑else { ... }
    • 思想:这是数据缓存阶段。程序只管把数据存入 command 数组,直到接收的数量达到了预期的 commandLength
二、 数据完整性设计:校验和 (Checksum)
  • 代码位置:当 commandIndex == commandLength 时。

  • 设计思想:串口通信(特别是长距离或强干扰环境)很容易出错。也许你发的是 0x01(开灯),因为干扰变成了 0x00(关灯)。

  • 实现逻辑

    • 发送方把所有数据加起来,取最后8位算出 Sum
    • 接收方(你的代码)收到数据后,也自己算一遍 human_sum
    • 比对:只有 human_sum == 收到包的最后一位,才说明数据是可信的。否则直接丢弃,宁可不执行,也不能执行错误的指令。
三、 任务解耦与通信:生产者-消费者模型

这段代码展示了 FreeRTOS 任务间通信的精髓。

  1. 上游(生产者) :是串口中断(在 usart.c 中)。中断只负责把字节塞入 CommandQueueHandle中断里不进行任何解析,保证了中断的快进快出。

  2. 中游(消费者/解析者) :就是当前的 StartCommandTask

    • 它通过 osMessageQueueGet(..., osWaitForever) 阻塞等待。
    • 设计亮点osWaitForever 意味着如果没有数据,这个任务会进入“休眠”状态,完全不占用 CPU 资源。只有数据来了,操作系统才会唤醒它。
  3. 下游(执行者)Led_QueueHandle 的接收端(即 LedTask)。

    • 解析与执行分离CommandTask 只负责“翻译”指令,翻译好之后,打包成 Led_message 结构体,扔给 LedTask 去执行。
    • 这样做的好处是:如果你以后要增加蜂鸣器控制、电机控制,只需要修改解析逻辑,而不需要改动 LED 任务的代码。
四、 内存管理技巧
Led_message *message = pvPortMalloc(sizeof(Led_message));
// ... 赋值 ...
osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
  • 传递指针而非拷贝结构体

    • 这里申请了一块动态内存,然后把内存的地址(指针) 发送到队列中。
    • 优点:不管结构体 Led_message 有多大(比如 100 字节),队列只需要传输 4 字节(指针大小),大大减少了数据拷贝的时间。
    • 风险点(注意) :必须确保接收端(LedTask)在使用完数据后调用 vPortFree,否则会发生内存泄漏。
五、 代码结构图解

image.png

GPT5.2

这段代码非常典型、也非常“工程化” ,本质上它做的是一件事:

把“字节流世界(中断/串口)”转换成“结构化事件(FreeRTOS 消息)”

下面我不会逐行念代码,而是从“结构层次 + 设计思想”的角度,把它拆开来讲,这样你以后看到类似代码,一眼就能看出作者在干什么。


一、这段代码在系统中的「角色定位」

先站在系统架构高度看它:

USART 中断
   ↓(1 字节)
CommandQueue
   ↓(字节流)
CommandTask   ←←← 你这段代码
   ↓(结构化消息)
Led_Queue
   ↓
LedTask

👉 StartCommandTask 的本质职责只有一个:

协议解析任务(Protocol Parser Task)

它不直接控制硬件 它不关心串口中断 它只做三件事:

  1. 接收「已经脱离中断的字节」
  2. 按协议拼包 + 校验
  3. 把“命令”翻译成 系统内部可理解的消息

这是一个非常标准的 RTOS 分层设计


二、整体结构拆分(宏观)

从结构上,这个函数可以分成 5 个逻辑层

┌────────────────────────────┐
│ 1. 任务入口 + 无限循环     │
├────────────────────────────┤
│ 2. 阻塞式获取 1 字节数据   │
├────────────────────────────┤
│ 3. 协议状态机(3 个状态) │
├────────────────────────────┤
│ 4. 完整帧校验              │
├────────────────────────────┤
│ 5. 命令 → 系统消息映射     │
└────────────────────────────┘

下面我们一层一层讲。


三、第一层:这是一个「事件驱动任务」
for (;;)
{
    osMessageQueueGet(CommandQueueHandle, &recv, 0, osWaitForever);
    ...
}
设计思想
  • 没有 osDelay
  • 没有主动轮询
  • 完全靠事件驱动

这说明作者明确知道:

👉 CommandTask 不是周期任务,而是数据驱动任务

只有当:

  • 串口中断收到 1 个字节
  • ISR 把字节丢进 CommandQueue

这个任务才会被唤醒。

📌 这是 RTOS 中最省 CPU、最优雅的写法


四、第二层:为什么一次只处理 1 个字节?
uint8_t recv;

你可能会问: “为什么不一次收一帧?”

这是非常重要的设计点

原因只有一句话:

串口是字节流,不是数据帧

  • 中断一次 ≠ 一帧完整数据
  • 帧可能被拆成 N 次中断
  • 甚至可能丢字节、乱序

👉 所以正确做法是:

在任务中,用状态机“拼”出一帧

这也是这段代码的核心思想。


五、第三层:一个“隐式状态机”的设计

你这里没有写 enum STATE, 但状态机是存在的,而且设计得很巧。

状态变量是谁?
uint8_t commandIndex;

它同时扮演了两个角色:

含义说明
当前状态处于协议解析的哪一步
当前进度已接收多少字节

状态 1:等待包头(commandIndex == 0)
if (commandIndex == 0)
{
    if (recv == 0xAA)
    {
        command[commandIndex++] = recv;
    }
}
设计思想
  • 只认包头
  • 所有垃圾数据都会被自动丢弃
  • 系统能在“任意时刻”重新同步协议

📌 这叫: 协议自同步能力(Self-Synchronization)


状态 2:读取长度字段(commandIndex == 1)
commandLength = recv;
if (commandLength < 4 || commandLength > sizeof(command))
{
    // 重置
}
设计思想

这是一个防御式设计(Defensive Programming)

  • 最小长度 = 包头 + 长度 + 校验
  • 最大长度 = 接收缓冲区大小

👉 任何非法长度,立刻丢包

这一步的意义是:

❌ 不让“错误协议”拖垮系统 ❌ 防止数组越界 ❌ 防止恶意数据


状态 3:接收剩余数据(commandIndex >= 2)
command[commandIndex++] = recv;
if (commandIndex == commandLength)
{
    // 一帧接收完成
}
设计思想
  • 不关心中间字段含义
  • 只按“长度”收够字节
  • 协议解析与协议完整性解耦

这是成熟协议解析的典型做法。


六、第四层:校验设计思想(Checksum)
uint16_t sum = 0;
for (int i = 0; i < commandLength - 1; i++)
{
    sum += command[i];
}
为什么这样设计?
  • 简单
  • 低成本
  • 对 MCU 足够可靠

这不是 CRC,但在:

  • 短帧
  • 低速串口
  • 内部控制系统

场景下非常合理。

👉 重点不是校验算法 👉 而是 “校验失败,整个包直接丢弃”

这保证了:

❌ 错误数据不会进入系统逻辑


七、第五层:协议 → 系统消息(最核心)
for (int i = 2; i < commandLength - 2; i += 2)
{
    Led_message *message = pvPortMalloc(sizeof(Led_message));
    message->led_now = command[i] - 1;
    message->state = command[i + 1];
    osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
}
这是整段代码「设计层次最高」的一部分

你在这里做了三件非常工程化的事


1️⃣ 协议层 ≠ 业务层
command[i]        // 协议数据
message->led_now  // 系统内部语义

👉 CommandTask 不控制 LED

它只做:

“翻译官”


2️⃣ 一帧命令 → 多个系统事件
i += 2

说明协议支持:

  • 一条命令
  • 控制多个 LED

而你用的是:

拆分为多个独立系统消息

这让:

  • LedTask 完全不关心“是不是串口来的”
  • 系统可扩展性极高

3️⃣ 动态内存 + 消息队列 = 解耦
pvPortMalloc
osMessageQueuePut

这意味着:

  • CommandTask 不等 LedTask
  • LedTask 不知道 CommandTask
  • 二者只通过“数据契约”通信

📌 这是 RTOS 解耦设计的教科书写法


八、为什么最后要“彻底清空状态”?
commandIndex = 0;
commandLength = 0;
memset(command, 0, sizeof(command));

设计思想

这一步保证:

  • 下一个字节 永远从“等包头”开始
  • 上一帧的任何残留都不会污染下一帧

这是一个:

强一致性重置点

————————————————————————————————————————————————————————————————————

callback最终Boss

image.png

总结与心得

  1. 内存管理是核心:在使用 FreeRTOS 队列传递指针时,pvPortMallocvPortFree 必须成对出现。相比于直接传递结构体,这种方式极大地减少了 CPU 搬运数据的开销。
  2. 中断要“短平快” :串口中断只负责“收”,复杂的协议解析(拼包、校验)交给 Task 去做,这就是 RTOS 的优势所在。
  3. CLion 很好用,但要懂 CMake:如果不熟悉 CMakeLists.txt,添加文件时很容易遇到编译错误。