APP代码移植(1)

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

197.2.5 APP代码移植(1)

/*
 * 本课目标:把 04Can_DHT11_OLED 复制为 10Can_DHT11_OLED_IAP,并移入升级触发功能。
 * 参考工程:09Ymodem_APP。
 * 本课边界:完成 USART1 中断、串口协议、内部 Flash 模块及 main.c 业务代码的移植;
 *           APP 链接地址、向量表和 APP.bin 生成命令留到第 198 课处理。
 * 代码说明:以下均为本课画面和配套工程中实际出现的代码片段;没有补写后续课程代码。
 */

HAL_NVIC_SetPriority(USART1_IRQn, 0, 0);    /* CubeMX 生成:把 USART1 中断的抢占优先级和子优先级都设为 0。 */
HAL_NVIC_EnableIRQ(USART1_IRQn);             /* CubeMX 生成:在 NVIC 中使能 USART1 全局中断通道。 */

void USART1_IRQHandler(void)                 /* CubeMX 在 stm32f1xx_it.c 中生成 USART1 中断入口。 */
{                                            /* USART1 中断服务函数开始。 */
    HAL_UART_IRQHandler(&huart1);            /* 交给 HAL 分析中断状态并推进当前 USART1 收发过程。 */
}                                            /* USART1 中断服务函数结束。 */

typedef struct{                              /* 定义课程使用的串口接收状态结构体。 */
    uint8_t rx_buf[50];                       /* 保存一帧升级命令,容量为 50 字节。 */
    uint8_t rx_cnt;                           /* 记录当前已经收到多少个字节。 */
    int8_t rx_flag;                           /* 保留的接收状态字段,本课协议代码没有实际使用它。 */
}UART_RX_typeDef;                             /* 为该结构体定义课程源码中的类型名。 */

void uart_receive_enable();                  /* usart.h:声明启动 USART1 单字节中断接收的函数。 */
uint8_t uart_get_ota_flag();                  /* usart.h:声明读取 RAM 升级标志的函数。 */

static uint8_t ota_flag = 0;                 /* usart.c:RAM 升级标志,上电初始化为 0。 */
static uint8_t uart1_rec_byte = 0;           /* usart.c:保存 USART1 本次中断收到的一个字节。 */
static UART_RX_typeDef uart1_rx_data;        /* usart.c:保存升级命令缓冲区及接收进度。 */

void uart_receive_enable()                   /* 封装 USART1 中断接收的首次启动操作。 */
{                                            /* 串口接收启动函数开始。 */
    HAL_UART_Receive_IT(&huart1, (uint8_t*)&uart1_rec_byte, 1); /* 要求 HAL 用中断方式接收 1 字节。 */
}                                            /* 串口接收启动函数结束。 */

uint8_t uart_get_ota_flag()                  /* 向 main.c 提供读取 RAM 升级标志的接口。 */
{                                            /* 升级标志读取函数开始。 */
    return ota_flag;                         /* 返回回调函数设置的 RAM 标志。 */
}                                            /* 升级标志读取函数结束。 */

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) /* HAL 收满请求的 1 字节后调用此回调。 */
{                                            /* 串口接收完成回调开始。 */
    if(huart ->Instance == USART1)            /* 确认本次回调来自 USART1。 */
    {                                        /* USART1 接收分支开始。 */
        uart1_rx_data.rx_buf[uart1_rx_data.rx_cnt] = uart1_rec_byte; /* 把刚收到的字节追加到帧缓冲区。 */
        uart1_rx_data.rx_cnt ++;              /* 接收计数加 1,指向下一个待写位置。 */

        if(uart1_rx_data.rx_buf[0] == 0xAF)  /* 检查一帧的第一个字节是否为帧头 0xAF。 */
        {                                    /* 帧头正确分支开始。 */
            if(uart1_rx_data.rx_cnt >= 48)   /* 达到课程设定的接收上限时认为数据过长。 */
            {                                /* 数据过长分支开始。 */
                uart1_rx_data.rx_cnt = 0;    /* 丢弃当前帧并从缓冲区起点重新接收。 */
                printf("ota data len error\r\n"); /* 打印长度错误信息。 */
            }                                /* 数据过长分支结束。 */
            else                             /* 当前接收长度尚未达到上限。 */
            {                                /* 正常长度分支开始。 */
                if(uart1_rx_data.rx_cnt > 2)  /* 收到帧头、长度及至少一个后续字节后再解析。 */
                {                            /* 基础字段已收到分支开始。 */
                    uint8_t data_len = uart1_rx_data.rx_buf[1]; /* 读取第 2 字节声明的 DATA 长度。 */

                    if((data_len > 0) && (data_len < 50)) /* 检查课程代码规定的长度范围。 */
                    {                        /* 长度字段通过检查。 */
                        if(uart1_rx_data.rx_cnt >= (data_len + 3)) /* 完整帧长度等于 DATA 长度再加 3 字节。 */
                        {                    /* 当前缓冲区已包含一整帧。 */
                            if(uart1_rx_data.rx_buf[data_len + 2] == 0xFA) /* 检查 DATA 后是否为帧尾 0xFA。 */
                            {                /* 帧尾正确,课程代码认为升级命令有效。 */
                                ota_flag = 1; /* 把 RAM 升级标志置 1,通知主循环切换到 Bootloader。 */
                                uart1_rx_data.rx_cnt = 0; /* 本帧处理完成,清零接收计数。 */
                                printf("OTA FLAG SET, VER:%d.%d.%d\r\n",uart1_rx_data.rx_buf[2],uart1_rx_data.rx_buf[3],uart1_rx_data.rx_buf[4]); /* 打印 DATA 前三字节表示的版本号。 */
                            }                /* 正确帧尾分支结束。 */
                            else             /* 指定位置不是帧尾 0xFA。 */
                            {                /* 帧尾错误分支开始。 */
                                uart1_rx_data.rx_cnt = 0; /* 丢弃当前错误帧。 */
                                printf("ota data tail error\r\n"); /* 打印帧尾错误信息。 */
                            }                /* 帧尾错误分支结束。 */
                        }                    /* 完整帧判断结束。 */
                    }                        /* 合法长度字段分支结束。 */
                    else                     /* DATA 长度为 0 或不小于 50。 */
                    {                        /* 长度字段错误分支开始。 */
                        uart1_rx_data.rx_cnt = 0; /* 丢弃当前错误帧。 */
                        printf("ota data len error\r\n"); /* 打印长度错误信息。 */
                    }                        /* 长度字段错误分支结束。 */
                }                            /* 基础字段解析结束。 */
            }                                /* 正常长度分支结束。 */
        }                                    /* 帧头正确分支结束。 */
        else                                 /* 缓冲区第一个字节不是 0xAF。 */
        {                                    /* 错误帧头分支开始。 */
            uart1_rx_data.rx_cnt = 0;        /* 丢弃该字节,让下一字节重新作为帧头。 */
        }                                    /* 错误帧头分支结束。 */

        HAL_UART_Receive_IT(&huart1, &uart1_rec_byte, 1); /* 再次挂起 1 字节接收,形成连续接收链。 */
    }                                        /* USART1 接收分支结束。 */
}                                            /* 串口接收完成回调结束。 */

#define INTER_FLASH_PARAM_ADDR (0x8006000U) /* inter_flashif.h:参数区地址,等价于 0x08006000。 */
#define INTER_FLASH_APP_ADDR   (0x8008000U) /* inter_flashif.h:APP 地址,等价于 0x08008000。 */

#pragma pack(1)                              /* inter_flash_cfg.h:按 1 字节边界排列参数成员。 */
typedef struct {                             /* 定义 APP 和 Bootloader 共同使用的 Flash 参数格式。 */
    uint8_t magic[4];                        /* 4 字节魔数,课程约定为 AA BB CC DD。 */
    uint32_t ota_bin_version;                /* 保存待升级固件版本信息。 */
    uint8_t ota_flag;                        /* Flash 升级标志:0 不升级,1 请求升级。 */
    uint8_t checksum;                        /* 保存有效字段的 8 位累加和。 */
    uint8_t format[2];                       /* 2 字节占位,使结构体总长保持为 12 字节。 */
}inter_flash_cfg_param_typeDef;              /* 定义课程使用的 Flash 参数类型名。 */
#pragma pack()                               /* 恢复编译器默认的结构体对齐设置。 */

uint8_t inter_flash_cfg_init(void);          /* inter_flash_cfg.h:声明参数区初始化与校验函数。 */
int8_t inter_flash_cfg_get_app_update_flag(); /* inter_flash_cfg.h:声明读取 Flash 升级标志的函数。 */
uint8_t inter_flash_cfg_set_app_update_flag(uint8_t flag); /* inter_flash_cfg.h:声明写 Flash 升级标志的函数。 */

#include "inter_flashif.h"                  /* main.c:引入内部 Flash 底层读写接口。 */
#include "inter_flash_cfg.h"                /* main.c:引入参数结构和升级标志接口。 */
#include <stdio.h>                           /* main.c:引入标准格式化输出声明;课程源码保留此行。 */

__set_PRIMASK(0);                            /* main.c:清除全局中断屏蔽,使 APP 可以响应可屏蔽中断。 */

uart_receive_enable();                       /* main.c:外设初始化完成后启动 USART1 单字节中断接收。 */
Can_Filter_Init();                           /* main.c:保留原工程的 CAN 过滤器初始化。 */
printf("                    APP CAN Start\r\n"); /* main.c:打印新 APP 的启动标识。 */
printf("----------------------------------------------------------------\r\n"); /* main.c:打印分隔线。 */
printf("flash cfg size:%d\r\n", sizeof(inter_flash_cfg_param_typeDef)); /* main.c:打印参数结构体长度,当前应为 12。 */
printf("----------------------------------------------------------------\r\n"); /* main.c:再次打印分隔线。 */
inter_flash_cfg_init();                      /* main.c:读取并校验 0x08006000 的参数。 */

#if ZET6                                    /* main.c:接收端宏为真时启用 OLED 显示。 */
OLED_Init();                                 /* main.c:先初始化 OLED 控制器和通信接口。 */
OLED_Clear();                                /* main.c:初始化后再清除屏幕旧内容。 */
OLED_ShowString(22, 0, "APP Can Test",16);  /* main.c:最后显示新 APP 的辨识文字。 */
#endif                                       /* main.c:结束 ZET6 条件编译区域。 */

if(uart_get_ota_flag() == 1)                 /* main.c 的 while(1):轮询串口回调设置的 RAM 标志。 */
{                                            /* 收到升级命令后的处理分支开始。 */
    inter_flash_cfg_set_app_update_flag(1);  /* 把内部 Flash 中可跨复位保存的升级标志写为 1。 */
    printf("Restarting...\r\n");            /* 输出即将复位的提示。 */
    HAL_Delay(1000);                         /* 延时 1 秒,保留课程源码的复位前等待。 */

    HAL_NVIC_SystemReset();                  /* 请求系统复位,让位于 Flash 起始处的 Bootloader 重新运行。 */
}                                            /* 升级命令处理分支结束;其后仍执行原工程的 CAN/OLED 业务。 */

本课不是从空白工程重新编写 IAP,而是演示怎样把已经验证过的升级触发模块移入一个普通业务工程。普通工程继续负责 CAN 接收、DHT11 与 OLED 显示;新增代码只负责接收升级指令、保存升级请求和复位。

一、移植APP代码

1.1 任务资源

本课涉及三个工程,角色不能混淆:

工程角色本课用途
04Can_DHT11_OLED原始普通业务工程被复制的移植起点
09Ymodem_APP已能触发 IAP 的参考 APP提供 USART1 和内部 Flash 相关代码
10Can_DHT11_OLED_IAP新目标工程接收本课的移植结果

视频先复制第 4 个工程,再把新文件夹命名为 10Can_DHT11_OLED_IAP。原笔记中的“IP 后缀”是识别错误,应为 IAP。文件夹虽然改名,Keil 工程和 target 在画面中仍叫 01Can_Test,这不妨碍编译,但日后维护时最好让工程名也能反映实际用途。

思维导图给出的完整移植流程共有 7 个大步骤:

  1. 用 CubeMX 初始化带中断的 USART1;
  2. 移植 usart.c、usart.h 中的升级命令接收代码;
  3. 加入两个内部 Flash .c 文件;
  4. 加入两个内部 Flash .h 文件及头文件搜索路径;
  5. 修改 main.c;
  6. 修改 Keil 中的 APP 地址、向量表等设置;
  7. 生成可用于升级的 BIN 文件。

第 197 课只完成第 1~5 步。 第 6、7 步是第 198 课内容,因此本课结束时“代码能编译”还不等于它已经能被 Bootloader 正确加载和运行。

1.2 CubeMX初始化串口1

原工程已经配置 USART1,但没有打开 USART1 全局中断。视频中的正确操作是:

  1. 打开新复制出来的第 10 个工程中的 .ioc,不要误改第 4 或第 9 个工程;
  2. 进入 USART1 配置,确认原有串口基本参数;
  3. 打开 NVIC Settings;
  4. 勾选 USART1 global interrupt 的 Enabled;
  5. 生成代码,关闭 CubeMX;
  6. 回到 Keil 时同意重新加载被更新的工程文件。

配套工程的 .ioc 对比能够看到新增项:

NVIC.USART1_IRQn=true:0:0:false:false:true:true:true:true

CubeMX 同时生成三处关键连接:

HAL_NVIC_EnableIRQ(USART1_IRQn)
          ↓
USART1_IRQHandler()
          ↓
HAL_UART_IRQHandler(&huart1)

勾选 NVIC 只负责让处理器可以进入 USART1 中断入口;它不会自动开始接收数据。真正启动接收的是后面调用的 HAL_UART_Receive_IT()。ST 对 CubeMX 的定位是“外设配置和初始化 C 代码生成工具”,NVIC 页面可以使能中断并配置优先级,参见 STM32CubeMX 用户手册 UM1718。

1.3 添加串口代码

原笔记中的 UART.c、UTTHH、KB_MX 都是转录错误,正确名称分别是:

  • usart.c:USART1 初始化以及本课移入的接收实现;
  • usart.h:接收结构体、函数声明等公共接口;
  • STM32CubeMX:根据 .ioc 生成初始化代码的工具。

参考 APP 在 CubeMX 生成的串口代码之外,又增加了三部分用户代码:

  1. ota_flag、单字节变量和帧缓冲区等状态;
  2. uart_receive_enable()与uart_get_ota_flag();
  3. HAL_UART_RxCpltCallback()中的升级命令组帧逻辑。

这些新增内容位于 CubeMX 的 USER CODE 区域中,正常重新生成代码时应被保留。

1.4 打开APP工程

视频打开第 9 个工程 09Ymodem_APP作为参考。它不是最终目标工程,而是已经跑通过上一课流程的“代码来源”。查看它的 usart.c可以得到完整调用关系:

main.c 调用 uart_receive_enable()
  → HAL 准备接收 1 字节并打开所需接收中断
  → USART1 收到字节
  → USART1_IRQHandler()
  → HAL_UART_IRQHandler()
  → 收满 1 字节后调用 HAL_UART_RxCpltCallback()
  → 回调保存字节、解析帧并再次启动下一字节接收

“回调函数由中断触发”不等于硬件会直接调用用户回调。中间还经过芯片中断入口和 HAL 通用中断处理函数。

1.5 接收升级指令

本课沿用第 196 课的串口帧格式:

┌────────┬────────┬──────────────┬────────┐
│ 0xAF   │ LEN    │ DATA         │ 0xFA   │
│ 帧头   │ 数据长 │ LEN 个字节   │ 帧尾   │
└────────┴────────┴──────────────┴────────┘

当回调收到形式符合要求的帧时,它把 RAM 中的 ota_flag置为 1。main.c的主循环通过 uart_get_ota_flag()查询这个值,再把内部 Flash 参数区中的持久化升级标志写为 1并复位。

原转录中的“窗口一”“进入 IP 升级模式”分别应为“串口一”和“进入 IAP 升级模式”。这里收到的是升级控制命令,不是经 USART1 直接接收整个新固件;复位后,Bootloader 才经其固件传输通道执行真正升级。

1.6 添加USART1文件

除了复制函数,还必须复制 usart.c顶部使用的状态变量,以及 usart.h中的UART_RX_typeDef和函数声明。只复制回调正文会导致类型、变量或函数原型缺失。

需要迁移的用户层对象包括:

对象所在文件作用
UART_RX_typeDefusart.h定义 50 字节帧缓冲区和计数器
ota_flagusart.c在 RAM 中记录是否收到升级命令
uart1_rec_byteusart.c暂存当前收到的 1 字节
uart1_rx_datausart.c保存正在组装的完整帧
uart_receive_enable()usart.c/.h启动第一次中断接收
uart_get_ota_flag()usart.c/.h供主循环读取 RAM 标志
HAL_UART_RxCpltCallback()usart.c逐字节组帧并设置标志

1.7 复制并粘贴USART1文件

视频采用的快捷方法是:把第 9 个工程的 usart.c和usart.h分别全选复制,替换第 10 个工程的同名文件。这个做法在本课可行,是因为两个工程都只使用同一套 USART1 配置:115200波特率、8 数据位、1 停止位、无校验、收发模式,GPIO 为 PA9=TX、PA10=RX。

它不是通用移植方法。若目标工程还有 USART2/USART3、DMA、其他回调、自定义引脚或不同波特率,整文件覆盖会把目标工程原有配置和业务代码一起删除。更安全的做法是比较差异,只把 USER CODE区域中的变量、类型、函数和回调逻辑逐项移入。

视频完成替换后进行编译,画面显示的是 0 errors、17 warnings。因此可以说“当前能够链接”,不能说“代码没有任何问题”。警告至少反映了旧式空参数列表、无符号返回类型返回 -1以及部分非 void函数缺少返回值等问题,后续做产品代码时不能忽略。

1.8 添加两个flash头文件

第 9 个工程的 inter_flashif目录实际包含 4 个文件:

inter_flashif/
├─ inter_flashif.c       底层 Flash 读取、擦除、写入和累加和
├─ inter_flashif.h       参数区与 APP 地址、底层函数声明
├─ inter_flash_cfg.c     参数读取、校验、升级标志修改
└─ inter_flash_cfg.h     12 字节参数结构体及配置层接口

正确的 Keil 工程操作分为两个概念:

  • 新建 inter_flashif代码组,把两个 .c文件加入工程,使编译器真正编译它们;
  • 在 Options for Target → C/C++ → Include Paths中加入 ..\inter_flashif,使 #include "inter_flashif.h"和#include "inter_flash_cfg.h"能被找到。

两个 .h文件不一定要显示在 Keil 工程树里才能生效;决定 #include能否找到它们的是头文件搜索路径。原笔记把“加入工程组”和“加入头文件路径”混成了同一件事。

复制模块而不是重新手敲代码还有一个关键原因:APP 和 Bootloader 必须使用相同的参数地址、结构体字段顺序、对齐规则、magic 和 checksum 算法。只要任意一项不一致,两边就会用不同方式解释 0x08006000中的同一组字节。

1.9 更改main.c

视频按思维导图在 main.c完成 5 项修改。放置位置比画面中的绝对行号更重要,因为重新生成代码或增加内容后行号会改变。

1.9.1 添加头文件

在 /* USER CODE BEGIN Includes */与/* USER CODE END Includes */之间加入:

#include "inter_flashif.h"
#include "inter_flash_cfg.h"
#include <stdio.h>

前两个头文件分别提供 Flash 底层接口和参数结构接口。目标工程的 usart.h本身已经包含 <stdio.h>并定义 printf宏,所以当前归档工程在 main.c再次包含 <stdio.h>属于重复但无害;顶部代码仍按课程源码原样保留。

1.9.2 添加打开所有终端

原笔记中的“打开所有终端”“enable_irq()”都是识别错误。课程实际复制的是:

__set_PRIMASK(0);

它位于 SystemClock_Config()之后、各外设初始化之前的 USER CODE BEGIN SysInit区域。其准确含义是把 Cortex-M3 的 PRIMASK清为 0,解除对可配置优先级异常的全局屏蔽,而不是“初始化所有中断”。

本工程通常由 Bootloader 跳转进入,而配套 Bootloader 跳转前执行过全局关中断,所以 APP 必须恢复全局中断。USART1 真正能进入接收回调还需要另外两个条件:CubeMX 已使能 USART1_IRQn,并且 HAL_UART_Receive_IT()已经打开本次接收所需的外设中断。三者缺一不可。

1.9.3 串口一中断接收

在 MX_USART1_UART_Init()等外设初始化完成之后调用:

uart_receive_enable();

该函数只请求接收 1 字节。收到后,回调末尾再次请求下一个字节,所以它形成的是“1 字节完成 → 回调处理 → 再接收 1 字节”的连续链路。如果忘记回调末尾的再次调用,程序只会收到第一个字节,永远拼不出完整升级命令。

1.9.4 flash初始化

视频加入了启动日志、结构体大小打印和inter_flash_cfg_init()。参数区正确地址是 0x08006000,读取长度是 12 字节;转录中的“0x4000 630”是语音识别错误。

初始化函数完成两项检查:

  1. 参数结构体长度是否能按 32 位整字写回;
  2. 参数区的 AA BB CC DD魔数及累加和是否正确。

任一检查失败,课程源码都会停在错误循环中,因此首次运行前仍必须保证参数区已经烧入合法的 info.bin。打印 APP CAN Start和flash cfg size:12有助于判断当前运行的是新 APP,以及参数结构是否与 Bootloader 一致。

1.9.5 添加升级控制代码

升级控制 if被放在 while(1)开头,后面原有的 CAN 接收和 OLED 数据显示逻辑保持不变。执行顺序为:

查询 RAM ota_flag
  ├─ 不是 1:继续执行原 CAN/OLED 业务
  └─ 等于 1:写 Flash ota_flag=1
                 ↓
             打印 Restarting...
                 ↓
             请求系统复位

视频还把 OLED 固定标题改成 APP Can Test,用于直观看出当前固件是移植后的 APP。讲解中一度准备把显示调用放在初始化前,随后立即纠正;最终正确顺序是:

  1. OLED_Init():配置 OLED;
  2. OLED_Clear():清除显存和旧画面;
  3. OLED_ShowString():写入要显示的文字。

未初始化就发送显示命令,OLED 通信接口和控制器状态可能尚未就绪,不能因为函数在 C 语法上可以调用就认为硬件一定能正确响应。

二、中断接收为什么需要三层使能

对初学者来说,“已经勾选中断但回调不运行”很常见。本课的 USART1 接收链路有三道门:

层级本课代码缺失后的现象
Cortex-M 全局屏蔽__set_PRIMASK(0)可屏蔽中断整体不能被 CPU 响应
NVIC 通道HAL_NVIC_EnableIRQ(USART1_IRQn)USART1 请求到不了中断服务函数
UART 接收过程HAL_UART_Receive_IT(..., 1)没有接收缓冲和长度,也不会因收到 1 字节完成而回调

因此排查时不要只看一个“中断开关”,应依次确认全局状态、NVIC 通道、外设接收状态和中断入口是否都正确。

三、USART1硬件连接

配套源码把 USART1 配置为:

  • PA9:USART1_TX,单片机发送;
  • PA10:USART1_RX,单片机接收;
  • 115200波特率、8 数据位、1 停止位、无校验、无硬件流控。

若使用外接 USB 转串口模块,信号必须交叉连接:

STM32 PA9  (TX) ─────────► USB-TTL RX
STM32 PA10 (RX) ◄───────── USB-TTL TX
STM32 GND       ────────── USB-TTL GND

双方必须共地,并使用与 STM32 兼容的 3.3 V UART 逻辑电平。若开发板和串口模块各自供电,不要在不了解板上电源结构时再随意连接两个 VCC。串口只有 TX/RX 而不共地时,接收端缺少共同电压参考,数据可能乱码或完全无法接收。

四、本课结束时还不能做什么

本课结束时只是把升级触发逻辑移入业务工程。若 APP 仍按普通工程链接在 0x08000000,它会与 Bootloader 占用同一地址;若中断向量表仍指向原位置,即使代码跳到了 APP,USART、SysTick 等中断也可能进入错误入口。

因此此时只验证“工程结构和 C 代码能编译”,不要提前宣称 IAP 已经完成。第 198 课还必须处理:

  • APP ROM 起始地址和可用大小;
  • SCB->VTOR中断向量表地址;
  • 编译宏;
  • 编译后生成供 YMODEM 传输的纯 BIN 文件。

五、学习检查

完成本课后,应能回答:

  1. 第 4、第 9、第 10 个工程在移植中分别承担什么角色?
  2. 为什么只勾选 USART1 global interrupt还不能收到完整升级命令?
  3. 为什么本课可以整文件替换 usart.c/.h,其他工程却未必可以?
  4. 两个 Flash .c文件和两个 .h文件应分别怎样加入 Keil 工程?
  5. __set_PRIMASK(0)与HAL_NVIC_EnableIRQ()有什么区别?
  6. 为什么uart_receive_enable()必须放在 USART1 初始化之后?
  7. 为什么 OLED 显示必须遵循“初始化 → 清屏 → 显示”的顺序?
  8. 为什么本课编译成功仍不能证明 APP 已能与 Bootloader 正确配合?