STM32F103C8 四轴飞行器遥控器 — 项目整体说明文档
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(蜂鸣确认) |
摇杆校准流程
- 将摇杆置于中位(油门杆置于最低位)
- 长按 Key_Left 约 1.5 秒,红灯闪 3 次进入校准模式
- 系统自动采集 50 个样本(约 0.5 秒)
- 计算中位偏移量(目标中值 1500,油门目标底值 1000)
- 偏移量自动补偿到后续摇杆数据中
- 校准数据保存到 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 特性实现双向通信:
- 遥控器 作为发射端,发送控制数据包
- 飞行器 作为接收端,收到数据后在 ACK 应答包 中携带遥测数据
- 遥控器在
NRF24L01_TX()中等待 5ms,检查RX_DR状态位 - 若收到 ACK 负载,提取遥测数据并解析;同时置
NRF_Connect = 1 - 若达到最大重发次数仍未收到 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. 相关文档
- MyADC 模块说明文档 — ADC 模数转换与 DMA 数据采集驱动详解
16. 注意事项
-
NRF24L01 供电:NRF24L01 对电源纹波敏感,建议使用独立 3.3V LDO,并在 VCC 和 GND 间并联 10μF + 0.1μF 去耦电容。
-
摇杆中位漂移:电位器摇杆存在个体差异和温漂,建议首次使用或温度变化较大时重新执行校准。
-
Flash 寿命:校准数据使用 Flash 最后一页存储,Flash 典型擦写寿命约 1 万次,校准操作频率很低(仅在需要时手动触发),不会影响寿命。
-
FreeRTOS 堆栈:各任务堆栈大小已在代码中指定,若增加功能(如新增显示页面),需注意栈溢出风险,建议通过
uxTaskGetStackHighWaterMark()监控。 -
DMA 缓冲一致性:DMA 在后台持续更新
STICK_ADC_DATAS[6]数组,CPU 读取时不存在竞态条件(DMA 写入半字是单周期原子操作),但要注意数组长度必须 ≥ 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 个通道的转换结果自动循环写入该数组。
内部流程:
-
时钟使能
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 要求)
-
GPIO 配置
- PA0、PA1、PA2、PA3 配置为模拟输入(
GPIO_Mode_AIN) - PB0 配置为模拟输入
- PA0、PA1、PA2、PA3 配置为模拟输入(
-
ADC 配置
- 独立模式(
ADC_Mode_Independent) - 连续转换使能(
ADC_ContinuousConvMode = ENABLE) - 扫描模式使能(
ADC_ScanConvMode = ENABLE) - 通道数:6(
ADC_NbrOfChannel = 6) - 数据右对齐(
ADC_DataAlign_Right) - 无外部触发 / 软件触发(
ADC_ExternalTrigConv_None) - 使能内部温度传感器与 VREFINT 通道(
ADC_TempSensorVrefintCmd(ENABLE))
- 独立模式(
-
ADC 校准
- 先关闭 ADC,等待稳定后再开启
- 复位校准寄存器,等待完成
- 启动校准,等待完成
- 共执行两轮校准(软件注释说明了 STM32 手册建议:每次上电后至少执行一次校准)
-
通道扫描顺序配置
顺序 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)。
-
DMA 配置
- 通道:DMA1 Channel1
- 缓冲大小:6(与通道数一致)
- 方向:外设到内存(
DMA_DIR_PeripheralSRC) - 外设地址:
&ADC1->DR(固定,不自增) - 内存地址:
arrayAddr(自增) - 数据宽度:半字(16 位,匹配 ADC 12 位结果)
- 模式:循环模式(
DMA_Mode_Circular) - 优先级:高(
DMA_Priority_High) - 无内存到内存传输
-
启动转换
- 使能 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. 注意事项
-
上电校准:ADC 校准时必须先关闭 ADC(
ADON=0),等待至少 2 个 ADC 时钟周期后再开启校准。本模块在两处都执行了校准流程,共进行两轮校准,确保精度。 -
VREFINT 激活:使用内部参考电压(CH17)和温度传感器(CH16)前,必须调用
ADC_TempSensorVrefintCmd(ENABLE)激活内部通道。 -
采样时间:VREFINT 通道推荐最小采样时间为 17.1μs,因此配置为 239.5 Cycles(约 20μs)。摇杆和电池通道使用 55.5 Cycles 即可满足要求。
-
连续转换:由于开启了连续扫描模式,ADC 会不断按顺序扫描 6 个通道并通过 DMA 更新缓冲区,无需软件反复触发。
-
已废弃的函数:
MyADC_GetAnalogValue()原本通过软件轮询方式依次读取 CH17 和 CH8 来计算电压,现已废弃,改为通过 DMA 自动采集 VREFINT 和电池值后再计算电压。该函数在头文件中已被注释。 -
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写入0x45670123和0xCDEF89AB,任一错误则 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=01010101b,0xAA=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 权限(关键!)
为什么必须上锁?
- 防止程序跑飞误擦除:若系统因电磁干扰或软件 bug 导致 PC 跳转异常,未上锁的 FPEC 可能被随机指令触发擦除,破坏用户代码区 → 设备变砖
- 防止误写破坏配置:
FLASH_CR的 PG/PER 位被意外置位后,任何 Flash 地址的写操作都会触发编程 - 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. 与项目中其他存储方案的对比
本项目同时拥有 片上 Flash 和 W25Q64 外部 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 独立使用),构成了一个完善且可靠的嵌入式配置存储子系统。
相关文档:
- MyADC 模块说明文档 — ADC 采集与摇杆数据获取
- 项目整体说明文档 — 系统架构、数据流与任务调度
参考手册:STM32F10x Flash Programming Manual (PM0075)
更多推荐



所有评论(0)