1. 项目概述

本项目是一个基于 STM32F103C8T6 微控制器的 四轴飞行器手持遥控器。遥控器通过两个十字摇杆(4 路电位器)采集飞手操控指令,经 ADC 采样、滑动窗口滤波、中位校准后,通过 NRF24L01 2.4GHz 无线模块 以双向通信方式将控制指令发送至飞行器,同时接收飞行器回传的姿态角、高度、电压等遥测数据,实现在 0.96 寸 OLED 显示屏 上分页展示。

核心特性

特性 说明
MCU STM32F103C8T6(Cortex-M3, 72MHz, 64KB Flash, 20KB SRAM)
实时操作系统 FreeRTOS(多任务调度)
无线通信 NRF24L01(2.4GHz, 2Mbps, ACK 双向回传)
摇杆采集 4 路电位器 + ADC1 扫描模式 + DMA 循环传输
显示 0.96 寸 OLED(SPI 接口, 128×64)— 4 页面切换
电源 电池电压检测(PB0 ADC + 分压电路 + VREFINT 校准)
按键 6 个按键(左/右翻页 + 4 个微调键)
反馈 红/蓝双色 LED + 无源蜂鸣器
校准 摇杆中位 / 底部自动校准,数据存入 Flash 持久化

2. 项目目录结构

项目根目录/
├── User/
│   └── main.c                    # 主函数入口,FreeRTOS 启动
├── Remoter/                      # 遥控器业务逻辑层
│   ├── board.h                   # 顶层头文件(统一包含所有模块 + 引脚宏定义)
│   ├── remotetask.c/h            # FreeRTOS 任务创建
│   ├── remoterData.c/h           # 数据结构定义 + 数据收发/解析
│   ├── show.c/h                  # OLED 多页面显示
│   ├── stick.c/h                 # 摇杆数据滤波、校准、限幅、电池电压计算
│   └── key_led_beep.c/h          # 按键扫描 + LED/蜂鸣器反馈
├── Hardware/                     # 硬件驱动层
│   ├── MyADC.c/h                 # ADC1 + DMA 驱动
│   ├── NRF24L01.c/h              # 2.4GHz 无线模块驱动
│   ├── MyHSPI.c/h                # 硬件 SPI 驱动(NRF24L01 通信)
│   ├── MySPI.c/h                 # 软件 SPI 驱动
│   ├── OLED.c/h                  # OLED 显示屏驱动
│   ├── key.c/h                   # 按键底层驱动
│   ├── led.c/h                   # LED 底层驱动
│   ├── buzzer.c/h                # 蜂鸣器驱动
│   ├── MyFLASH.c/h               # 片上 Flash 读写(校准数据存储)
│   ├── MyI2C.c/h                 # I2C 驱动
│   ├── Serial1.c/h               # 串口 1(调试日志)
│   ├── Serial2.c/h               # 串口 2
│   ├── MyTIM1/2/3.c/h            # 定时器驱动
│   ├── MyEncoder.c/h             # 编码器驱动
│   ├── MyRTC.c/h                 # RTC 驱动
│   ├── MyIWDG.c/h                # 独立看门狗
│   ├── MyPWR.c/h                 # 电源管理
│   └── ...                       # MPU6050, DHT11, W25Q64 等(飞行器端使用)
├── FreeRTOS/                     # FreeRTOS 源码
├── System/                       # STM32 标准外设库 + 启动文件
└── docs/                         # 文档目录
    └── MyADC模块说明文档.md

3. 系统架构

3.1 总体架构图

┌─────────────────────────────────────────────────────────┐
│                    FreeRTOS 任务调度                      │
├───────────────────┬──────────────────┬──────────────────┤
│  ShowTask_50ms   │  StickTask_10ms  │ NRF24L01Task_10ms│
│  (优先级 1)      │  (优先级 3)      │  (优先级 4)      │
│  OLED 显示刷新   │  ADC 摇杆 + 电池 │  无线收发        │
├───────────────────┴──────────────────┴──────────────────┤
│              TaskKEY_10ms (优先级 2)                     │
│              按键扫描 + LED/蜂鸣器                        │
├─────────────────────────────────────────────────────────┤
│          TaskSerial1Log (优先级 configMAX-1)             │
│              调试日志队列输出                            │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│                   硬件驱动层 (Hardware/)                  │
│  MyADC │ MyHSPI │ NRF24L01 │ OLED │ key │ led │ buzzer │
└─────────────────────────────────────────────────────────┘

3.2 任务说明

任务名称 函数入口 优先级 周期 栈大小 功能描述
TaskSerial1Log vTaskSerial1SendLogCode 最高 (configMAX-1) 事件驱动 256 字 从队列取日志字符串,通过串口 1 发送
ShowTask_50ms ShowTask_50ms 1 (最低) 50ms 256 字 根据当前窗口索引刷新 OLED 显示
TaskKEY_10ms TaskKEY_10ms 2 10ms 128 字 扫描按键,处理翻页/微调/校准触发
StickTask_10ms StickTask_10ms 3 10ms 256 字 读取 ADC 摇杆值,窗口滤波,校准,电池电压计算
NRF24L01Task_10ms NRF24L01Task_10ms 4 (最高业务) 10ms 128 字 通过 NRF24L01 发送控制指令,接收遥测数据并解析

3.3 优先级设计原则

  • 串口日志 优先级最高:确保调试信息不被业务任务阻塞
  • 无线收发 优先级高于摇杆和按键:保证遥控数据发送的实时性
  • 摇杆采集 优先级高于显示:确保控制数据及时更新
  • 显示刷新 优先级最低:人眼对 50ms 延迟不敏感

4. 数据流

4.1 上行数据流(遥控器 → 飞行器)

摇杆电位器
    ↓ (模拟电压)
ADC1 扫描 + DMA → STICK_ADC_DATAS[0~5]
    ↓
滑动窗口滤波 (50样本)
    ↓
ADC → 1000~2000 范围映射 (×0.2442 + 1000)
    ↓
中位/底部校准偏移补偿
    ↓
幅度限幅 (1000~2000, 中点死区 ±3)
    ↓
RemoterData.THR / YAW / PIT / ROL
    ↓
Remoter_Send_Contor_To_Fly()  构建数据包
    ↓
NRF24L01_TX() → 2.4GHz 无线发射
    ↓
飞行器接收端

4.2 下行数据流(飞行器 → 遥控器)

NRF24L01 ACK Payload 回传
    ↓
NRF24L01_TX() 中检测 RX_DR 中断标志
    ↓
读取 ACK 负载数据
    ↓
Remoter_Data_Analyse() 解析:
  ├─ 功能字 0x01 → 姿态角 (ROL/PIT/YAW) + 高度 + 模式 + 解锁状态
  └─ 功能字 0x05 → 飞行器电池电压
    ↓
RemoterData 结构体更新
    ↓
ShowTask_50ms → OLED 显示刷新

4.3 通信协议

上行数据包格式(遥控 → 飞控)

偏移 长度 字段 说明
0 1 0xAA 帧头 1
1 1 0xAF 帧头 2
2 1 0x03 功能字
3 1 length 数据长度
4-5 2 THR 油门 (高字节在前)
6-7 2 YAW 偏航
8-9 2 ROL 横滚
10-11 2 PIT 俯仰
12-23 12 AUX1~6 辅助通道
24 1 SUM 校验和 (低 8 位)

下行数据包格式(飞控 → 遥控)

  • 帧头:0xAA, 0xAA
  • 功能字 0x01:姿态数据(ROL/PIT/YAW 各 2 字节,高度 4 字节,飞行模式 1 字节,解锁状态 1 字节)
  • 功能字 0x05:电源信息(飞机电压 2 字节,单位 mV)
  • 校验:SUM 累加和

5. OLED 显示页面

OLED 分 4 页,通过按 右键 循环切换(RemoterData.windows 0→1→2→3→0):

页面 0:主界面

位置 内容
左上 NRF24L01 信道号
中上 状态文字(“四轴遥控” / "遥控电低"闪烁 / "飞机电低"闪烁)
右上 信号强度图标 (0-5 格)
第二行左 RV: 遥控器电压 (V)
第二行右 FV: 飞行器电压 (V)
第三行 THR: xxxx ROL: xxxx
第四行 YAW: xxxx PIT: xxxx

页面 1:传感器状态

显示飞行器传感器自检结果(fly_test_flag 位掩码):

  • MPU6050:正常 / 错误(闪烁)
  • 气压模块:正常 / 错误(闪烁)
  • 光流模块:正常 / 错误(闪烁)

页面 2:飞行器姿态

显示飞行器实时姿态数据:

  • 俯仰角度(度,带符号浮点)
  • 翻滚角度(度,带符号浮点)
  • 偏航角度(度,带符号浮点)
  • 高度(厘米)

页面 3:微调偏移

显示当前摇杆微调补偿值:

  • 微调俯仰
  • 微调翻滚
  • 微调偏航

6. 按键与操作

按键 GPIO 操作 功能
Key_Right PB7 短按 (5~100 次扫描) 窗口页面向下翻页 + 蓝灯闪 1 次 + 蜂鸣
Key_Right PB7 长按 (>150 次扫描 = 1.5s) 蓝灯闪 3 次(预留功能)
Key_Left PB8 短按 红灯闪 1 次 + 蜂鸣
Key_Left PB8 长按 (>1.5s) 红灯闪 3 次 + 进入摇杆校准模式
Key_OffSet_Font PB3 短按 微调 PIT +1(蜂鸣确认)
Key_OffSet_Back PB5 短按 微调 PIT -1(蜂鸣确认)
Key_OffSet_Left PB4 短按 微调 ROL -1(蜂鸣确认)
Key_OffSet_Right PB6 短按 微调 ROL +1(蜂鸣确认)

摇杆校准流程

  1. 将摇杆置于中位(油门杆置于最低位)
  2. 长按 Key_Left 约 1.5 秒,红灯闪 3 次进入校准模式
  3. 系统自动采集 50 个样本(约 0.5 秒)
  4. 计算中位偏移量(目标中值 1500,油门目标底值 1000)
  5. 偏移量自动补偿到后续摇杆数据中
  6. 校准数据保存到 Flash 最后一页 (0x0800F800),掉电不丢失

7. 电池电压测量

遥控器电池电压通过以下方式测量:

  • 硬件:PB0 外接分压电阻 R12=10KΩ + R13=10KΩ(分压比 1:2)
  • ADC 通道:ADC1 Channel 8 (PB0)
  • 校准参考:内部 VREFINT(1.2V 带隙参考,ADC1 Channel 17)
  • 滤波:50 次累加平均

计算公式

电池实际电压(mV) = (ADC_CH8 × 1200 / VREFINT) × 2
  • 低电阈值:< 3100mV(遥控器 / 飞行器均使用此阈值)
  • 低电告警:OLED 主界面状态文字闪烁提示

8. 硬件资源占用

8.1 GPIO 分配

GPIO 功能 引脚
PA0 摇杆 YAW 电位器 (ADC1 CH0) 模拟输入
PA1 摇杆 THR 电位器 (ADC1 CH1) 模拟输入
PA2 摇杆 ROLL 电位器 (ADC1 CH2) 模拟输入
PA3 摇杆 PITCH 电位器 (ADC1 CH3) 模拟输入
PA4 NRF24L01 CSN 推挽输出
PA5 NRF24L01 SCK (SPI1) 推挽输出
PA6 NRF24L01 MISO (SPI1) 浮空输入
PA7 NRF24L01 MOSI (SPI1) 推挽输出
PA8 NRF24L01 IRQ 上拉输入
PA9 串口 1 TX (调试) 复用推挽
PA10 串口 1 RX 浮空输入
PA15 NRF24L01 CE 推挽输出
PB0 电池电压检测 (ADC1 CH8) 模拟输入
PB1 LED 蓝灯 推挽输出
PB3 微调前键 上拉输入
PB4 微调左键 上拉输入
PB5 微调后键 上拉输入
PB6 微调右键 上拉输入
PB7 右键(翻页) 上拉输入
PB8 左键(校准) 上拉输入
PB9 LED 红灯 推挽输出
PB10 蜂鸣器 推挽输出
PB12 OLED DC 推挽输出
PB13 OLED SCLK 推挽输出
PB14 OLED RES 推挽输出
PB15 OLED MOSI 推挽输出

8.2 片上外设

外设 用途 配置
ADC1 摇杆 + 电池 + VREFINT 采集 6 通道扫描,连续转换,DMA1 CH1 循环传输
SPI1 (HSPI) NRF24L01 通信 硬件 SPI,全双工
USART1 调试日志输出 波特率见 Serial1 配置
DMA1 CH1 ADC 数据自动搬运 循环模式,半字宽度
TIM3 (推测) FreeRTOS 系统心跳 1ms 中断
Flash 校准数据持久化 最后一页 0x0800F800

9. 关键数据流与信号处理

9.1 摇杆信号链路

电位器 (0~3.3V)
  → ADC 12bit (0~4095)
  → 滑动窗口滤波 (50 样本 FIFO)
  → 线性映射: value = ADC × 0.2442 + 1000 → (1000~2000)
  → 中位/底部校准偏移补偿
  → 限幅: [1000, 2000], 中点死区 1497~1503 → 1500
  → RemoterData.THR/YAW/PIT/ROL

9.2 NRF24L01 通信参数

参数
速率 2 Mbps
发射功率 7 dBm (最大)
地址宽度 5 字节
地址 0xA1, 0xA2, 0xA3, 0x02, 0x03
自动重发 10 次, 间隔 500μs (共 5ms)
数据管道 仅管道 0 (P0)
ACK 负载 使能 (双向数据通信)
动态负载长度 使能
信道 配置为 110(可在初始化时指定)

9.3 NRF24L01 双向通信机制

本项目使用 NRF24L01 的 ACK Payload 特性实现双向通信:

  1. 遥控器 作为发射端,发送控制数据包
  2. 飞行器 作为接收端,收到数据后在 ACK 应答包 中携带遥测数据
  3. 遥控器在 NRF24L01_TX() 中等待 5ms,检查 RX_DR 状态位
  4. 若收到 ACK 负载,提取遥测数据并解析;同时置 NRF_Connect = 1
  5. 若达到最大重发次数仍未收到 ACK,清空 TX FIFO,置 NRF_Connect = 0

信号强度计算:统计每秒收到的有效数据包数量(NRF_RSSI_count),除以 50 得到 0~5 档信号强度。


10. 文件依赖关系

main.c
 └── board.h
      ├── stm32f10x.h          (STM32 标准外设库)
      ├── FreeRTOS.h / task.h / queue.h
      ├── Serial1.h            (调试串口)
      ├── remotetask.h         (任务创建)
      │    ├── remoterData.h   (数据结构 + 通信协议)
      │    ├── show.h          (OLED 显示)
      │    │    └── OLED.h
      │    ├── key_led_beep.h  (按键 + LED + 蜂鸣器)
      │    │    ├── key.h
      │    │    ├── led.h
      │    │    └── buzzer.h
      │    ├── stick.h         (摇杆处理)
      │    │    ├── MyADC.h
      │    │    └── MyFLASH.h
      │    └── NRF24L01.h      (无线模块)
      │         └── MyHSPI.h
      └── ...

11. 启动流程

系统上电
  ↓
main() 入口
  ├─ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4)   设置中断优先级分组
  ├─ xQueueCreate()                                    创建串口日志队列
  ├─ xTaskCreate(RemoterCreateTask)                    创建任务创建器任务
  └─ vTaskStartScheduler()                             启动 FreeRTOS 调度
       ↓
RemoterCreateTask()  [临界区保护]
  ├─ xTaskCreate(TaskSerial1Log, 最高优先级)
  ├─ xTaskCreate(ShowTask_50ms, 优先级 1)
  ├─ xTaskCreate(TaskKEY_10ms, 优先级 2)
  ├─ xTaskCreate(StickTask_10ms, 优先级 3)
  ├─ xTaskCreate(NRF24L01Task_10ms, 优先级 4)
  └─ vTaskDelete(NULL)                                 删除自身
       ↓
各任务自动开始周期性执行

12. NRF24L01 通信配置

  • 工作模式:发射模式 (mode=0, 遥控器端)
  • 信道:110(NRF24L01_Init(0, 110)
  • 速率:2 Mbps (RF_SETUP = 0x0F)
  • 发射功率:7 dBm
  • 自动应答:仅管道 0 使能
  • 自动重发:10 次,间隔 500μs(SETUP_RETR = 0x1A),总计 5ms 内完成
  • 地址宽度:5 字节 (SETUP_AW = 0x03)
  • 动态负载长度:使能 (DYNPD = 0x01)
  • ACK 负载:使能 (FEATURE = 0x06)
  • CRC:2 字节 (CONFIG = 0x0E | mode)

遥控器发射后等待 5ms(vTaskDelay(5)),此时间窗口内 NRF24L01 硬件会自动完成 10 次重发尝试;若飞行器在范围内,ACK 应答包将携带遥测数据返回。


13. OLED 显示驱动简述

  • 接口:软件 SPI(4 线:DC、SCLK、MOSI、RES,CS 直接接地)
  • 分辨率:128×64
  • 控制器:SSD1306 兼容
  • 字体OLED_Font.h 提供 ASCII 字体 + 中文点阵字库
  • 刷新任务ShowTask_50ms 每 50ms 调用一次 Show()
  • 页面切换Show() 内部检测 RemoterData.windows 变化,切换时先清屏
  • 低电闪烁:通过 flash_cnt 计数器 + flash_text_mode 标志位实现约 300ms 周期的文字闪烁

14. 数据滤波与校准

14.1 滑动窗口滤波

  • 每通道维护一个 _WinFilter 结构体(50 样本环形缓冲 + 运行总和)
  • 每次新样本替换最旧样本,更新运行总和
  • 输出 = 运行总和 / 50(均值滤波)
  • 前 100 次采样丢弃(上电稳定期)

14.2 摇杆校准

  • 触发:长按左键(Key_Left)约 1.5 秒
  • 采集 50 组数据,计算平均值
  • 中位校准偏移 = 目标值(1500) - 实际中位平均值
  • 油门底位校准偏移 = 目标值(1000) - 实际油门最低位平均值
  • 持久化存储:写入 Flash 最后一页(0x0800F800),带版本号 0x55AA 校验
  • 每次上电自动从 Flash 加载校准数据

14.3 电池电压滤波

  • 50 次累加平均(BATTERY_FILTER_NUM = 50
  • 约每 500ms(10ms 周期 × 50 次)更新一次电压值

15. 相关文档


16. 注意事项

  1. NRF24L01 供电:NRF24L01 对电源纹波敏感,建议使用独立 3.3V LDO,并在 VCC 和 GND 间并联 10μF + 0.1μF 去耦电容。

  2. 摇杆中位漂移:电位器摇杆存在个体差异和温漂,建议首次使用或温度变化较大时重新执行校准。

  3. Flash 寿命:校准数据使用 Flash 最后一页存储,Flash 典型擦写寿命约 1 万次,校准操作频率很低(仅在需要时手动触发),不会影响寿命。

  4. FreeRTOS 堆栈:各任务堆栈大小已在代码中指定,若增加功能(如新增显示页面),需注意栈溢出风险,建议通过 uxTaskGetStackHighWaterMark() 监控。

  5. DMA 缓冲一致性:DMA 在后台持续更新 STICK_ADC_DATAS[6] 数组,CPU 读取时不存在竞态条件(DMA 写入半字是单周期原子操作),但要注意数组长度必须 ≥ 6。

  6. NRF24L01 通信延迟:每 10ms 发送一帧数据,包含 5ms ACK 等待时间。若通信环境恶劣(距离远、障碍物多),可能出现丢包,表现为 NRF_Connect = 0 和 RSSI 下降。


项目版本:STM32F103C8 四轴飞行器遥控器
文档生成时间:2026年7月
IDE:Keil MDK (Project.uvprojx)

MyADC 模块说明文档

1. 概述

MyADC.c / MyADC.h 是 STM32F103C8 四轴飞行器遥控器项目中 ADC 模数转换与 DMA 数据采集 的驱动模块。该模块负责采集遥控器摇杆的 4 路电位器信号、电池电压检测信号以及内部参考电压(VREFINT),并通过 DMA 循环传输方式自动将转换结果存入用户指定的 RAM 数组,无需 CPU 频繁干预。

  • MCU:STM32F103C8
  • 外设:ADC1 + DMA1 Channel1
  • 采集通道数:6 个(规则组扫描模式)
  • 分辨率:12 位(右对齐)
  • 传输方式:DMA 循环模式(Circular)

2. 硬件引脚连接

通道 ADC 通道 GPIO 引脚 功能说明
1 ADC_Channel_0 PA0 摇杆 YAW(偏航)电位器
2 ADC_Channel_1 PA1 摇杆 THR(油门)电位器
3 ADC_Channel_2 PA2 摇杆 ROLL(横滚)电位器
4 ADC_Channel_3 PA3 摇杆 PITCH(俯仰)电位器
5 ADC_Channel_8 PB0 遥控器电池电压检测
6 ADC_Channel_17 内部 VREFINT(1.2V 参考电压)

宏定义来源Remoter/board.h):

// 摇杆 ADC 及 GPIO
#define STICK_ADC_RCC   RCC_APB2Periph_ADC1
#define STICK_DMA_RCC   RCC_AHBPeriph_DMA1
#define STICK_RCC       RCC_APB2Periph_GPIOA
#define STICK_GPIO      GPIOA
#define STICK_THR_PIN   GPIO_Pin_1    // PA1
#define STICK_YAW_PIN   GPIO_Pin_0    // PA0
#define STICK_PITH_PIN  GPIO_Pin_3    // PA3
#define STICK_ROLL_PIN  GPIO_Pin_2    // PA2

// 电池电压检测
#define BATTERY_RC_RCC  RCC_APB2Periph_GPIOB
#define BATTERY_RC_GPIO GPIOB
#define BATTERY_RC_PIN  GPIO_Pin_0    // PB0

电池分压电路说明(来自注释代码):
PB0 外部接有分压电阻 R12=10KΩ + R13=10KΩ,分压比为 1:2。实际电池电压 = ADC 测量电压 × 2。


3. API 接口说明

3.1 对外接口

void     MyADC_Init(uint32_t arrayAddr);  // 初始化 ADC+DMA
uint16_t MyADC_GetDataValue(void);         // 获取单次转换数字值

3.2 void MyADC_Init(uint32_t arrayAddr)

功能:初始化 ADC1 及 DMA1 Channel1,启动连续扫描转换。

参数

  • arrayAddr:DMA 目标存储地址,即用户定义的 uint16_t 数组首地址。DMA 会将 6 个通道的转换结果自动循环写入该数组。

内部流程

  1. 时钟使能

    • STICK_RCC(GPIOA)
    • BATTERY_RC_RCC(GPIOB)
    • STICK_ADC_RCC(ADC1)
    • STICK_DMA_RCC(DMA1)
    • ADC 时钟分频:RCC_PCLK2_Div6(PCLK2=72MHz ÷ 6 = 12MHz,满足 ADC 输入时钟 ≤14MHz 要求)
  2. GPIO 配置

    • PA0、PA1、PA2、PA3 配置为模拟输入(GPIO_Mode_AIN
    • PB0 配置为模拟输入
  3. ADC 配置

    • 独立模式(ADC_Mode_Independent
    • 连续转换使能(ADC_ContinuousConvMode = ENABLE
    • 扫描模式使能(ADC_ScanConvMode = ENABLE
    • 通道数:6(ADC_NbrOfChannel = 6
    • 数据右对齐(ADC_DataAlign_Right
    • 无外部触发 / 软件触发(ADC_ExternalTrigConv_None
    • 使能内部温度传感器与 VREFINT 通道(ADC_TempSensorVrefintCmd(ENABLE)
  4. ADC 校准

    • 先关闭 ADC,等待稳定后再开启
    • 复位校准寄存器,等待完成
    • 启动校准,等待完成
    • 共执行两轮校准(软件注释说明了 STM32 手册建议:每次上电后至少执行一次校准)
  5. 通道扫描顺序配置

    顺序 ADC 通道 采样周期 对应周期时间
    1 CH0 (PA0) 55.5 Cycles 约 4.6 μs
    2 CH1 (PA1) 55.5 Cycles 约 4.6 μs
    3 CH2 (PA2) 55.5 Cycles 约 4.6 μs
    4 CH3 (PA3) 55.5 Cycles 约 4.6 μs
    5 CH8 (PB0) 55.5 Cycles 约 4.6 μs
    6 CH17 (VREFINT) 239.5 Cycles 约 20 μs

    注意:VREFINT(内部参考电压)使用 239.5 Cycles 的采样时间,这是 STM32 手册推荐的 17.1μs(17.1μs ÷ 1/14MHz ≈ 239.5)。

  6. DMA 配置

    • 通道:DMA1 Channel1
    • 缓冲大小:6(与通道数一致)
    • 方向:外设到内存(DMA_DIR_PeripheralSRC
    • 外设地址:&ADC1->DR(固定,不自增)
    • 内存地址:arrayAddr(自增)
    • 数据宽度:半字(16 位,匹配 ADC 12 位结果)
    • 模式:循环模式(DMA_Mode_Circular
    • 优先级:高(DMA_Priority_High
    • 无内存到内存传输
  7. 启动转换

    • 使能 ADC1
    • 使能 ADC 的 DMA 请求
    • 使能 DMA1 Channel1
    • 软件触发 ADC 转换(ADC_SoftwareStartConvCmd

3.3 uint16_t MyADC_GetDataValue(void)

功能:触发一次软件转换,等待完成,返回 ADC_DR 寄存器的值。

注意:该函数为单次读取模式。在实际项目中,由于已通过 DMA 循环自动采集,数据从 DMA 目标数组中读取即可,此函数主要供测试或特定场景使用。

返回值:12 位 ADC 转换结果(0 ~ 4095)。


4. DMA 数据存储布局

用户需在调用 MyADC_Init() 之前定义一个 uint16_t 类型的数组作为 DMA 目标缓冲区,长度为 6。DMA 会自动将转换结果按通道顺序循环写入:

uint16_t ADC_Values[6];  // 长度为 6 的缓冲区
MyADC_Init((uint32_t)ADC_Values);

数组索引与通道对应关系

数组索引 ADC 通道 采集信号
0 CH0 (PA0) 摇杆 YAW
1 CH1 (PA1) 摇杆 THR
2 CH2 (PA2) 摇杆 ROLL
3 CH3 (PA3) 摇杆 PITCH
4 CH8 (PB0) 电池电压
5 CH17 内部 VREFINT

5. 电池电压计算方法

基于 VREFINT 内部参考电压(典型值 1.2V)进行校准计算:

电池分压后电压(mV) = ADC_Values[4] × 1200 / ADC_Values[5]
电池实际电压(mV)   = 分压后电压 × 2       (因为分压比 1:2)
电池实际电压(V)    = 电池实际电压(mV) / 1000

即:

V_bat = (ADC_CH8 × 1200 / VREFINT) × 2 / 1000   [单位:V]

此计算方法可消除 MCU 供电电压波动对 ADC 转换结果的影响。


6. 关键配置参数汇总

参数 说明
ADC 外设 ADC1
ADC 时钟 12 MHz PCLK2(72MHz) / 6
分辨率 12 位
数据对齐 右对齐
转换模式 连续 + 扫描
触发方式 软件触发
通道数 6
DMA 通道 DMA1 Channel1
DMA 模式 循环
DMA 数据宽度 HalfWord (16 bit)
采样时间(摇杆/电池) 55.5 Cycles
采样时间(VREFINT) 239.5 Cycles

7. 使用示例

#include "MyADC.h"

// 定义 DMA 接收缓冲区
uint16_t ADC_Buffer[6];

int main(void)
{
    // 初始化 ADC,DMA 自动开始循环采集
    MyADC_Init((uint32_t)ADC_Buffer);

    while (1)
    {
        // ADC_Buffer 中的数据由 DMA 自动刷新,直接读取即可
        uint16_t yaw   = ADC_Buffer[0];  // YAW 摇杆值
        uint16_t thr   = ADC_Buffer[1];  // THR 油门值
        uint16_t roll  = ADC_Buffer[2];  // ROLL 横滚值
        uint16_t pitch = ADC_Buffer[3];  // PITCH 俯仰值
        uint16_t batt  = ADC_Buffer[4];  // 电池分压值
        uint16_t vref  = ADC_Buffer[5];  // 内部 VREFINT

        // 计算电池电压(单位:mV)
        uint16_t bat_voltage_mv = (uint32_t)batt * 1200 * 2 / vref;

        // 延时或执行其他任务
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

8. 设计分析:多通道采集为何互不影响

8.1 摇杆数据与电池电压采集互不干扰的原因

本模块使用 单个 ADC1 + 扫描模式 + DMA 循环传输 的架构,同时采集摇杆的 4 路电位器和电池电压。看似"共用" ADC1 会导致数据混乱,实际不会,原因如下:

1) 扫描模式保证通道独立、顺序采样

ADC1 配置为扫描模式(ADC_ScanConvMode = ENABLE),硬件会严格按 ADC_RegularChannelConfig 设定的顺序,依次对 CH0 → CH1 → CH2 → CH3 → CH8 → CH17 各采样一次,完成一轮 6 次转换。每个通道的转换结果在 ADC_DR 寄存器中只停留一瞬间,但——

2) DMA 将每次转换结果自动分流到数组不同位置

DMA1 Channel1 配置为:

  • 外设地址:&ADC1->DR(固定不变)
  • 内存地址:用户数组首地址,自增模式DMA_MemoryInc_Enable
  • 缓冲大小:6(DMA_BufferSize = 6

DMA 的工作流程:

ADC 完成 CH0 转换 → DMA 将 ADC_DR 值写入 数组[0],内存地址+2
ADC 完成 CH1 转换 → DMA 将 ADC_DR 值写入 数组[1],内存地址+2
ADC 完成 CH2 转换 → DMA 将 ADC_DR 值写入 数组[2],内存地址+2
ADC 完成 CH3 转换 → DMA 将 ADC_DR 值写入 数组[3],内存地址+2
ADC 完成 CH8 转换 → DMA 将 ADC_DR 值写入 数组[4],内存地址+2
ADC 完成 CH17 转换→ DMA 将 ADC_DR 值写入 数组[5],内存地址+2
→ DMA 缓冲区计数归零,地址回绕到数组首地址,开始新一轮

因此,摇杆数据(数组[0]~[3])和电池数据(数组[4])存储在完全独立的内存位置,彼此互不覆盖。

3) 连续转换 + 循环 DMA = 全自动运行

一旦 ADC_SoftwareStartConvCmd(ADC1, ENABLE) 启动后,ADC1 会不断重复扫描转换,DMA 循环刷新数组。整个过程完全由硬件自动完成,CPU 零干预。用户在任何时刻读取数组[0]~[5],拿到的都是对应通道的最新转换值,不会出现"读电池时摇杆数据被污染"的问题。

4) 对比:如果不用 DMA 会怎样

假设没有 DMA,每完成一次 EOC(转换结束)就需要 CPU 进入中断服务函数,读取 ADC_DR,判断当前是哪个通道,再手动存入对应变量。这不仅消耗大量 CPU 时间,还可能因为中断延迟导致数据错位或丢失。DMA 从根本上消除了这些问题。

8.2 如果改用 ADC2 采集电池电压,会有什么问题?

在 STM32F103C8 上,ADC2 不能独立使用 DMA。这是由芯片硬件设计决定的:

特性 ADC1 ADC2
独立 DMA 通道 ✅ DMA1 Channel1 ❌ 不支持
独立扫描模式 + DMA ✅ 可实现 ❌ 无法实现
双 ADC 模式下的 DMA ✅ 主 ADC ⚠️ 仅作为从 ADC,数据通过 ADC1 的 DMA 传输

如果强行用 ADC2 独立采集电池电压,将面临以下问题:

问题 1:必须使用轮询或中断方式读取数据

没有 DMA,每完成一次转换就需要软件介入:

// 伪代码:ADC2 轮询方式读取
ADC_SoftwareStartConvCmd(ADC2, ENABLE);       // 触发 ADC2 转换
while(ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC) == RESET); // 死等转换完成
uint16_t batt = ADC_GetConversionValue(ADC2);  // 读取结果

这段等待时间(约几个微秒)内 CPU 被完全占用,无法处理摇杆数据更新、NRF24L01 无线通信、OLED 刷新等任务。在高实时性要求的无人机遥控器场景中,这种阻塞是不可接受的。

问题 2:中断模式会引入不确定性延迟

改为 ADC2 中断方式:

void ADC2_IRQHandler(void)
{
    // 每次 ADC2 转换完成触发中断
    battery_value = ADC_GetConversionValue(ADC2);
}

虽然避免了死等,但每次 ADC2 转换完成都会触发中断。ADC2 以数十 kHz 的速率持续采样时,中断会频繁打断主循环和 FreeRTOS 任务调度,导致:

  • 摇杆数据读取产生时间抖动(jitter)
  • 无线发射数据包的间隔不稳定
  • 系统总体响应延迟增加

问题 3:双 ADC 同步模式的额外限制

即使使用 ADC1+ADC2 双 ADC 同步模式(ADC_Mode_RegSimult),也存在约束:

  • 两个 ADC 必须同步触发,采样通道数必须相同
  • ADC2 的转换结果通过 ADC1 的数据寄存器(高 16 位)合并传输
  • 这会破坏现有的 6 通道摇杆采集架构,需要重新设计 DMA 布局
  • 双 ADC 模式的配置远比单 ADC 扫描模式复杂,调试难度大

结论:使用 单个 ADC1 + 扫描模式 + DMA 是 STM32F103 上多通道模拟信号采集的最佳方案。它将不同功能的多个模拟通道统一管理,由硬件保证数据独立性和实时性,CPU 只需从数组读取即可,简单、高效、零开销。如果用 ADC2 单独采集,反而会引入不必要的 CPU 负担和实时性问题,得不偿失。


9. 注意事项

  1. 上电校准:ADC 校准时必须先关闭 ADC(ADON=0),等待至少 2 个 ADC 时钟周期后再开启校准。本模块在两处都执行了校准流程,共进行两轮校准,确保精度。

  2. VREFINT 激活:使用内部参考电压(CH17)和温度传感器(CH16)前,必须调用 ADC_TempSensorVrefintCmd(ENABLE) 激活内部通道。

  3. 采样时间:VREFINT 通道推荐最小采样时间为 17.1μs,因此配置为 239.5 Cycles(约 20μs)。摇杆和电池通道使用 55.5 Cycles 即可满足要求。

  4. 连续转换:由于开启了连续扫描模式,ADC 会不断按顺序扫描 6 个通道并通过 DMA 更新缓冲区,无需软件反复触发。

  5. 已废弃的函数MyADC_GetAnalogValue() 原本通过软件轮询方式依次读取 CH17 和 CH8 来计算电压,现已废弃,改为通过 DMA 自动采集 VREFINT 和电池值后再计算电压。该函数在头文件中已被注释。

  6. DMA 缓冲区长度:务必确保传入 MyADC_Init() 的数组长度 ≥ 6,否则 DMA 写入会越界导致内存错误。

STM32F103C8 Flash 存储模块 — 实现详解与优势分析

1. 概述

本项目使用 STM32F103C8T6 片上 Flash 存储 摇杆校准数据,实现掉电不丢失的持久化配置保存。校准数据存储于 Flash 地址 0x0800F800(第 62 页,即用户代码区末尾的安全页),每次系统上电自动加载,用户手动触发校准后立即写入。

为什么需要 Flash 存储?

电位器摇杆存在个体差异、机械偏差和温漂,出厂时中位电压并不严格等于 VCC/2。每次上电若使用固定默认值,四轴飞行器可能发生自旋或偏航。因此必须存储校准值并在每次上电时恢复,Flash 是唯一无需外设即可持久化的方案。


2. STM32F103C8 Flash 硬件背景

参数
Flash 总容量 64 KB
起始地址 0x0800 0000
页数 64 页(编号 0 ~ 63)
页大小 1 KB(1024 字节),小/中容量设备均为 1KB 页
编程位宽 仅支持 16 位半字写入
擦除粒度 整页擦除(不支持字节/半字独立擦除)
擦除后默认值 0xFFFF
擦写寿命 典型 10,000 次
写入前要求 目标地址必须先擦除为 0xFFFF,否则 PGERR 置位

Flash 地址映射

0x0800 0000 ┌──────────────┐
            │   启动代码    │
            │   ...        │
            │   用户代码    │
            │   ...        │
0x0800 E000 │   用户数据    │ ← 校准数据可用的安全区域
            │   ...        │
0x0800 F800 ├──────────────┤ ← 本项目校准数据存储页 (Page 62)
            │ 校准数据 1KB │
0x0800 FC00 ├──────────────┤
            │  Page 63     │ ← 最后一页 (Page 63), 1KB
0x0800 FFFF └──────────────┘

注意:STM32F103C8 共 64KB Flash(64 页 × 1KB)。选择第 62 页(0x0800F800)而非第 63 页,为尾页保留一定安全余量,避免与编译器生成的边界数据冲突。实际用户代码通常在 30~40KB 以内,此地址远在代码区之上,完全安全。


3. MyFLASH 驱动层实现

3.1 接口函数一览

函数 功能 操作粒度
MyFLASH_Init() 空函数(ST 库无需额外初始化) -
MyFLASH_ReadByte(addr) 读 1 字节 8 bit
MyFLASH_ReadHalfWord(addr) 读 2 字节(半字) 16 bit
MyFLASH_ReadWord(addr) 读 4 字节(字) 32 bit
MyFLASH_ReadByteArray(addr, buf, len) 读字节数组 8 bit × N
MyFLASH_ErasePage(addr) 擦除一整页(1KB) 整页
MyFLASH_PageProgram(addr, data) 烧写一个半字(16 bit) 16 bit
MyFLASH_ByteArrayProgram(addr, buf, len) 烧写字节数组 8 bit × N

3.2 读取操作

Flash 读取极为简单——STM32 的片上 Flash 直接映射到地址空间,CPU 可通过指针直接访问,无需任何外设配置:

// 读半字:直接解引用指针
uint16_t MyFLASH_ReadHalfWord(uint32_t address)
{
    return *(__IO uint16_t *)address;  // __IO = volatile, 防止编译器优化为寄存器缓存
}

// 读字
uint32_t MyFLASH_ReadWord(uint32_t address)
{
    return *(__IO uint32_t *)address;
}

// 读字节数组(循环逐字节读取)
void MyFLASH_ReadByteArray(uint32_t address, uint8_t *array, uint16_t length)
{
    for (uint16_t i = 0; i < length; i++)
        array[i] = MyFLASH_ReadByte(address + i);
}

优势:零延迟、零外设配置。读取速度与 SRAM 几乎一致,仅受 Flash 访问等待周期(72MHz 时通常 2 个等待周期)影响。校准数据仅在上电时读取一次(18 字节),对系统性能无任何影响。

3.3 页擦除操作

STM32 Flash 物理特性要求:写入前必须先擦除,擦除以整页为单位。擦除后所有位变为 1(即字节值为 0xFF)。

FLASH_Status MyFLASH_ErasePage(uint32_t Page_Address)
{
    FLASH_Unlock();                               // 步骤1: 解锁 FPEC(写入 KEY1/KEY2 序列)
    FLASH_Status status = FLASH_ErasePage(Page_Address); // 步骤2: 等待 BSY→0 → 置 PER=1 → 设 AR → 置 STRT=1 → 等待 BSY→0
    FLASH_Lock();                                 // 步骤3: 重新锁定,防止误操作
    return status;
}

标准库 FLASH_ErasePage() 内部流程

┌──────────────────────────────────────┐
│ 1. while(FLASH_SR & BSY) 等待空闲    │
│ 2. FLASH_CR |= PER (页擦除使能)      │
│ 3. FLASH_AR  = 页地址                │
│ 4. FLASH_CR |= STRT (启动擦除)       │
│ 5. while(FLASH_SR & BSY) 等待完成    │
│ 6. 返回 FLASH_COMPLETE / TIMEOUT     │
└──────────────────────────────────────┘

关键安全设计

  • Flash 解锁序列:必须按顺序向 FLASH_KEYR 写入 0x456701230xCDEF89AB,任一错误则 FPEC 永久锁定,需复位才能恢复。
  • 写后立即上锁FLASH_Lock() 再次保护 FPEC 模块,防止程序跑飞意外擦写代码区造成灾难性后果。这是 防御性编程 的最佳实践。

3.4 半字编程操作

STM32F103 Flash 仅支持 16 位半字写入,不能按字节或字写入:

FLASH_Status MyFLASH_PageProgram(uint32_t Address, uint16_t Data)
{
    FLASH_Unlock();
    FLASH_Status status = FLASH_ProgramHalfWord(Address, Data);
    // FLASH_ProgramHalfWord 内部流程:
    // 1. while(BSY) 等待空闲
    // 2. 检查地址是否已擦除(全1),若未擦除 → PGERR 错误
    // 3. FLASH_CR |= PG (编程使能)
    // 4. *(volatile uint16_t*)Address = Data (写入半字)
    // 5. while(BSY) 等待编程完成
    // 6. 可选:回读验证
    FLASH_Lock();
    return status;
}

重要限制:如果写入的目标地址未提前擦除(即当前值 ≠ 0xFFFF),硬件会置位 PGERR 且写入无效。唯一的例外是写入值 0x0000,但此场景极少使用。

3.5 字节数组编程(自动对齐)

由于硬件仅支持半字写入,MyFLASH_ByteArrayProgram() 实现了字节到半字的自动拼接

FLASH_Status MyFLASH_ByteArrayProgram(uint32_t Address, uint8_t *array, uint16_t length)
{
    FLASH_Status status = FLASH_COMPLETE;
    FLASH_Unlock();

    for (uint16_t i = 0; i < length; i += 2)
    {
        if (i == length - 1)  // 奇数长度:最后一个字节单独处理
        {
            status = FLASH_ProgramHalfWord(Address + i, array[i] | 0xFF00);
            // 高位补 0xFF(与擦除后默认值一致,不会破坏已擦除位)
        }
        else  // 正常情况:两个字节拼成一个半字
        {
            status = FLASH_ProgramHalfWord(Address + i, array[i] | (array[i+1] << 8));
            // 低字节在前,高字节在后(小端序)
        }
    }

    FLASH_Lock();
    return status;
}

设计要点

  • 按小端序拼接(低地址 = 低字节),与 STM32 内存模型一致
  • 奇数长度末尾自动补 0xFF,避免将已擦除位误写为 0
  • 循环内直接调用 FLASH_ProgramHalfWord 而非二次封装,避免重复解锁/上锁

4. 校准数据存储协议

4.1 数据布局

校准数据占据 Flash 页面 0x0800F800 起始的 20 字节,布局如下:

地址偏移   大小    内容
─────────────────────────────────────────
+0x00     2字节   版本号: 0x55AA (CALIBRATION_VERSION)
+0x02     2字节   OffSet_En         (uint8_t, 对齐占用2字节)
+0x04     2字节   OffSet_Rol        (int16_t)
+0x06     2字节   OffSet_Pit        (int16_t)
+0x08     2字节   OffSet_Thr        (int16_t)
+0x0A     2字节   OffSet_Yaw        (int16_t)
+0x0C     2字节   Middle_OffSet_Pit (int16_t)
+0x0E     2字节   Middle_OffSet_Rol (int16_t)
+0x10     2字节   Middle_OffSet_Yaw (int16_t)
+0x12     2字节   Bottom_OffSet_Thr (int16_t)
─────────────────────────────────────────
总计: 20 字节 (10 个半字)

剩余 1004 字节 (0x0800F814 ~ 0x0800FBFF): 留待扩展

4.2 数据结构

typedef struct
{
    uint8_t  OffSet_En;          // 校准使能标志(长按左键时置1,校准完成后清零)
    int16_t  OffSet_Rol;         // 翻滚角微调偏移(用户手动微调)
    int16_t  OffSet_Pit;         // 俯仰角微调偏移(用户手动微调)
    int16_t  OffSet_Thr;         // 油门微调偏移
    int16_t  OffSet_Yaw;         // 偏航角微调偏移
    int16_t  Middle_OffSet_Pit;  // 俯仰中位自动校准值
    int16_t  Middle_OffSet_Rol;  // 翻滚中位自动校准值
    int16_t  Middle_OffSet_Yaw;  // 偏航中位自动校准值
    int16_t  Bottom_OffSet_Thr;  // 油门底部自动校准值
} _stick_offset;

// 总大小: 1 + 2×8 = 17 字节
// Flash 存储时按半字对齐: 9 个半字 = 18 字节

4.3 版本号校验机制

版本号 0x55AA 是校准数据有效性的核心保证:

读取 Version = 0x55AA ?
  ├─ 是 → 数据有效,加载校准值
  └─ 否 → 数据无效(首次上电/擦除后默认0xFFFF),全部清零

版本号选择 0x55AA 的原因

  • 0x55 = 01010101b0xAA = 10101010b — 互为按位取反
  • 擦除后的默认值 0xFFFF 不会误判为有效
  • 与常见通信协议帧头(如 Modbus)风格一致,易于在调试器中识别
  • 预留扩展空间:未来可递增版本号(如 0x55AB)区分不同格式的校准数据

5. 应用层调用流程

5.1 写入流程(Stick_SaveCalibration)

void Stick_SaveCalibration(void)
{
    uint16_t version = CALIBRATION_VERSION;        // 0x55AA
    uint16_t *data = (uint16_t *)&stick_offset_data; // 将结构体视为半字数组

    // 步骤1: 擦除整页
    MyFLASH_ErasePage(CALIBRATION_FLASH_ADDR);     // 擦除 0x0800F800

    // 步骤2: 写入版本号 (半字0)
    MyFLASH_PageProgram(CALIBRATION_FLASH_ADDR, version); // 偏移 +0

    // 步骤3: 循环写入校准数据 (半字1~9)
    for (int i = 0; i < sizeof(_stick_offset) / 2; i++)   // 共 9 次写入
    {
        MyFLASH_PageProgram(CALIBRATION_FLASH_ADDR + 2 + i * 2, data[i]);
    }
}

流程图

用户长按 Key_Left (1.5s)
    ↓
系统采集 50 组摇杆中位值 → 计算校准偏移
    ↓
stick_offset_data.OffSet_En = 0          ← 校准标记复位
    ↓
Stick_SaveCalibration()
    ├─ MyFLASH_ErasePage(0x0800F800)      ← 整页擦除为 0xFFFF (~20ms, 最大值40ms)
    ├─ MyFLASH_PageProgram(addr+0, 0x55AA)  ← 写版本号
    ├─ MyFLASH_PageProgram(addr+2, data[0])  ← 写 OffSet_En (0x00) + OffSet_Pit 低字节
    ├─ MyFLASH_PageProgram(addr+4, data[1])  ← ...
    ├─ ... (共 10 次编程调用)
    └─ MyFLASH_PageProgram(addr+18, data[8])
    ↓
校准完成,数据永久保存

时序:单次擦除约 20~40ms,单次半字编程约 20~40μs。总计约 40ms + 10 × 40μs ≈ 40.4ms,在用户完全无感的范围内。

5.2 读取流程(Stick_LoadCalibration)

void Stick_LoadCalibration(void)
{
    // 步骤1: 读取版本号
    uint16_t version = MyFLASH_ReadHalfWord(CALIBRATION_FLASH_ADDR);

    if (version == CALIBRATION_VERSION)  // 版本匹配:数据有效
    {
        uint16_t *data = (uint16_t *)&stick_offset_data;

        // 步骤2: 循环读取校准数据
        for (int i = 0; i < sizeof(_stick_offset) / 2; i++)
        {
            data[i] = MyFLASH_ReadHalfWord(CALIBRATION_FLASH_ADDR + 2 + i * 2);
        }
    }
    else  // 版本不匹配:首次上电或数据损坏
    {
        // 全部初始化为零(默认无偏移)
        stick_offset_data.OffSet_En = 0;
        stick_offset_data.OffSet_Rol = 0;
        stick_offset_data.OffSet_Pit = 0;
        stick_offset_data.OffSet_Thr = 0;
        stick_offset_data.OffSet_Yaw = 0;
        stick_offset_data.Middle_OffSet_Pit = 0;
        stick_offset_data.Middle_OffSet_Rol = 0;
        stick_offset_data.Middle_OffSet_Yaw = 0;
        stick_offset_data.Bottom_OffSet_Thr = 0;
    }
}

执行时机Stick_Init() 中调用,即系统上电后在所有 FreeRTOS 任务启动前完成。读取仅需数微秒,不影响启动速度。

5.3 读取流程时序图

系统上电
    ↓
main() → RemoterCreateTask()
    ↓
StickTask_10ms() 初始化阶段
    ↓
Stick_Init()
    ├─ MyADC_Init(...)                    ← 配置 ADC + DMA
    └─ Stick_LoadCalibration()            ← 从 Flash 读取校准数据
         ├─ version = MyFLASH_ReadHalfWord(0x0800F800)
         │    ├─ == 0x55AA → 循环读取 9 个半字 → 校准值恢复
         │    └─ != 0x55AA → 全部清零(首次上电)
         └─ 完成(<10μs)
    ↓
进入周期性运行: Stick_Function_10ms() 每 10ms 应用校准值

6. 方案优势分析

6.1 零硬件成本

对比方案 额外硬件 BOM 成本 PCB 面积
片上 Flash 存储 ¥0 0 mm²
外部 EEPROM (AT24C02) I²C EEPROM + 2 上拉电阻 ~¥0.5 ~6 mm²
外部 SPI Flash (W25Q64) SPI Flash + 去耦电容 ~¥1.5 ~30 mm²
SD 卡 卡座 + 电平转换 ~¥2.0 ~150 mm²

对于仅需存储 18 字节校准数据的场景,片上 Flash 是唯一经济且合理的方案。

6.2 无需额外外设驱动

  • EEPROM 方案:需要 I²C 总线 + 上拉电阻 + 设备地址配置 + 时序控制(5ms 写入等待)
  • SD 卡方案:需要 SPI + FATFS 文件系统(代码体积增加 15KB+)+ 卡座可靠性问题
  • 片上 Flash:直接指针解引用读取,标准库函数写入,代码增量 < 200 字节

6.3 高可靠性

风险场景 片上 Flash 外部 EEPROM
接触不良 无(硅片内部) I²C 总线故障 → 数据丢失
强电磁干扰 高抗扰(芯片内部) I²C 信号线易受干扰
振动环境 无影响 焊点/插座可能松动
极端温度 -40°C ~ +85°C 全温域 部分 EEPROM 仅 -20°C ~ +70°C

四轴飞行器遥控器可能在户外、高温、振动环境下使用,片上 Flash 的集成可靠性远超任何外挂方案。

6.4 版本号校验 + 默认值回退

Flash 状态             → 行为
─────────────────────────────────────────
全新芯片 (0xFFFF...)   → 版本不匹配 → 全部清零 → 中位偏差小,不影响飞行
正常数据 (0x55AA...)   → 版本匹配 → 加载校准值 → 摇杆精确归中
被意外擦除             → 版本不匹配 → 恢复默认 → 安全飞行,下次校准即可
Flash 硬件损坏          → 读取异常 → 看门狗复位 → 进入安全状态

Fail-safe 设计:任何异常都回退到安全的默认状态(全部偏移清零),绝不会加载随机数据导致飞行器失控。

6.5 擦写寿命充足

最坏情况分析:
- 用户每天校准 1 次(已极频繁)
- 每天擦除 + 编程 1 次
- Flash 寿命: 10,000 次
- 理论寿命: 10,000 / 1 = 10,000 天 ≈ 27 年

实际使用中用户可能每周校准 2~3 次,Flash 寿命完全不会成为瓶颈。且 Flash 磨损是擦除引起的,编程不消耗寿命——本项目每次校准仅擦除 1 次,编程 10 次,擦写比极低。

6.6 写入安全:解锁-操作-上锁 模式

// 每次操作都必须解锁,完成后立即上锁
FLASH_Unlock();             // 打开 FPEC 权限
// ... 执行擦除或编程 ...
FLASH_Lock();               // 关闭 FPEC 权限(关键!)

为什么必须上锁?

  1. 防止程序跑飞误擦除:若系统因电磁干扰或软件 bug 导致 PC 跳转异常,未上锁的 FPEC 可能被随机指令触发擦除,破坏用户代码区 → 设备变砖
  2. 防止误写破坏配置FLASH_CR 的 PG/PER 位被意外置位后,任何 Flash 地址的写操作都会触发编程
  3. FMEDA 安全分析:在功能安全(如无人机)应用中,锁机制是安全关键措施

本项目每次操作独立解锁、立即上锁,窗口期 < 100μs,将风险降至最低。

6.7 地址选择的安全性

STM32F103C8 64KB Flash 布局:
┌──────────────────────┐ 0x0800 0000
│  启动代码 + 中断向量  │  ~4KB
├──────────────────────┤
│  用户代码 (.text)     │  ~30-40KB    ← 本项目代码量
├──────────────────────┤
│  只读数据 (.rodata)   │  ~2-5KB
├──────────────────────┤
│  ┌自由空间──────────┐│  约 16-28KB   ← 安全区域
│  │                  ││
│  ├──────────────────┤│ 0x0800 F800
│  │ 校准数据 (Page62) ││  ← 本项目使用
│  └──────────────────┘│
├──────────────────────┤ 0x0800 FC00
│  Page 63 (1KB)       │  ← 保留页,不占用
└──────────────────────┘ 0x0800 FFFF

选择 Page 62 而非 Page 63 的理由:

  • 为编译器可能生成的边界对齐填充留出余量
  • 距离代码区末端有足够的安全距离(通常 20KB+ 间隙)
  • 即使未来代码增长 10~15KB 也不会发生冲突

可通过在 Keil 编译后查看 .map 文件确认代码实际占用范围,确保校准数据地址远在代码区之上。


7. 与项目中其他存储方案的对比

本项目同时拥有 片上 FlashW25Q64 外部 SPI Flash 两套存储,分工明确:

维度 片上 Flash(校准数据) W25Q64(飞行器端)
数据量 18 字节 8 MB(SPI Flash)
写入频率 极低(仅校准触发) 可能高频(日志/配置)
寿命 10,000 次擦除 100,000 次擦除
读取速度 零延迟(总线直接访问) SPI 传输,约 20MHz 时钟
用途 遥控器校准参数持久化 飞行器端大容量数据存储
擦除粒度 1KB 整页 4KB 扇区 / 64KB 块

本项目遥控器端仅需存储少量配置参数,片上 Flash 是完美的匹配方案;飞行器端的 W25Q64 用于存储飞行日志、黑匣子数据等大容量需求,各司其职。


8. 完整代码路径追踪

8.1 初始化链路

main.c: main()
  → xTaskCreate(RemoterCreateTask)
    → remotetask.c: RemoterCreateTask()
      → xTaskCreate(StickTask_10ms)
        → stick.c: Stick_Init()           // 摇杆初始化
          → MyADC_Init(...)               // ADC 配置
          → Stick_LoadCalibration()        // ★ 从 Flash 加载校准数据
            → MyFLASH_ReadHalfWord(0x0800F800)
            → 比较版本号 → 决策是否加载

8.2 校准触发链路

key_led_beep.c: key_led_beep_10ms()       // 每 10ms 扫描按键
  → Key_Left 检测长按 >1.5s
    → stick_offset_data.OffSet_En = 1     // 置校准标志

stick.c: Stick_Function_10ms()             // 每 10ms 处理摇杆
  → Stick_middle()
    → 检测 OffSet_En == 1
      → 开始采集 50 组中位数据
      → 计算校准偏移
      → Stick_SaveCalibration()           // ★ 写入 Flash
        → MyFLASH_ErasePage(0x0800F800)
        → MyFLASH_PageProgram(...) × 10
      → OffSet_En = 0

9. 注意事项与最佳实践

9.1 编程前务必擦除

Flash 物理特性决定写入只能将 1 翻转为 0,不能反向。若目标地址未擦除(含 0 位),写入会触发 PGERR 错误。

// 错误示例:连续写入同一地址
MyFLASH_PageProgram(addr, 0x1234);  // addr 变为 0x1234
MyFLASH_PageProgram(addr, 0x5678);  // PGERR! 无法将 0x1234 改为 0x5678
                                     // 需先 ErasePage 再写入

9.2 擦除期间禁用中断

页擦除操作(FLASH_ErasePage)期间 不能响应任何中断,否则 FLASH 控制器可能进入未定义状态。ST 官方建议在擦除/编程前关闭全局中断,完成后恢复。本项目校准操作在 vTaskDelay(100) 的间隙中执行,不存在中断冲突风险,但若要增强安全性:

__disable_irq();
FLASH_Status status = MyFLASH_ErasePage(addr);
__enable_irq();

9.3 Flash 写入期间不能从 Flash 取指

STM32 Flash 在擦除/编程期间会暂停读取。若当前执行的代码恰好与被操作的 Flash 在同一 bank,CPU 会 stall 等待 直到操作完成。本项目校准数据在 Page 62,用户代码在低地址区,属于同一 bank,因此写入时会有短暂的 CPU 停顿(~40μs/半字),这在非实时关键的校准时刻完全可接受。

9.4 寿命监控

虽然 Flash 擦写寿命充足,但若需监控可增加擦写计数器:

// 在 Flash 中额外存储一个 32 位擦写计数器
// 每次 Stick_SaveCalibration() 时递增
// 当计数器接近 10,000 时通过 OLED 告警

本项目未实现此功能,因校准频率极低(推测全生命周期 < 500 次),无需此开销。

9.5 Keil 中确认存储地址安全

在 Keil IDE 中编译后,打开 Listings/Project.map 文件,搜索 “Execution Region ER_IROM1”,确认其结束地址远小于 0x0800F800。若接近,需前移校准数据地址或后移到其他页。


10. 扩展建议

当前仅存储 18 字节校准数据,剩余约 1004 字节可用。未来可扩展存储:

扩展项 大小 说明
NRF24L01 信道号 1 字节 用户可配信道,避免同频干扰
OLED 亮度 1 字节 用户偏好亮度设置
蜂鸣器音量 1 字节 静音/低/高
电池低电阈值 2 字节 不同电池的个性化阈值
摇杆曲线 32 字节 自定义摇杆响应曲线(非线性的 expo 设置)
总计 ~37 字节 仅占页面 4%,余量极其充足

扩展时只需在 _stick_offset 结构体中增加字段,并更新版本号(如 0x55AB)以兼容旧数据格式。


11. 总结

维度 评价
硬件成本 ★★★★★ 零额外元件
可靠性 ★★★★★ 无接触、无电磁干扰风险
写入安全 ★★★★★ 解锁-操作-上锁模式,故障自动回退默认值
代码复杂度 ★★★★★ < 150 行,依赖 ST 标准库
寿命 ★★★★☆ 10,000 次擦除,对校准场景绰绰有余
扩展性 ★★★★★ 仅用 18/1024 字节,余量 98%

片上 Flash 作为校准数据存储方案,在本项目中是 唯一零成本、最高可靠性、最简实现 的选择。其 Fail-safe 设计(版本号校验 → 默认值回退)、防御性编程(每次上锁)、以及合理的地址规划(Page 62 独立使用),构成了一个完善且可靠的嵌入式配置存储子系统。


相关文档

参考手册:STM32F10x Flash Programming Manual (PM0075)

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐