双轴摇杆(ADC + DMA 采集,OLED 实时显示)
双轴摇杆(ADC + DMA 采集,OLED 实时显示)
STM32F103C8T6 读双轴模拟摇杆的 X/Y 电压,数值实时刷在 OLED 上,并检测摇杆下按按键。
示例程序源自洋桃1号开发板的标准库工程,本文把它完整移植到 HAL 库,并记录标准库→HAL 的驱动改写方法与两处必踩的坑(ADC 超频、SWD 连不上内核)。
最终效果:
txt Zhangshaohua ← 第 0 页,居中
ADC TEST ← 第 2 页,居中
ADC_IN6:2048 ← 摇杆 X 轴
ADC_IN7:2048 ← 摇杆 Y 轴
扳动摇杆数值实时变化,按下摇杆时屏幕左上角出现 Y。
#一、元信息
| 字段 | 值 |
|---|---|
| 芯片/平台 | STM32F103C8T6(HAL 库,64 KB Flash / 20 KB RAM) |
| 接口类型 | ADC1 双通道 + DMA 循环搬运(PA6=IN6 / PA7=IN7);OLED SSD1306 硬件 I2C1(SCL=PB6 / SDA=PB7,地址 0x78);按键 GPIO 上拉输入(PB2) |
| 验证状态 | ✅ 已验证 |
| 验证环境 | 洋桃1号开发板 / STM32CubeIDE 自带 arm-none-eabi-gcc 14.3.1 + CMake + Ninja / ST-Link V2(SWD)/ STM32CubeProgrammer CLI / 2026-09-10(2026-09-11 复核修正启动模式相关结论) |
| 驱动方式 | HAL(ADC+DMA 连续扫描、I2C 阻塞发送);OLED 驱动由洋桃标准库范例移植 |
| 资源占用 | FLASH 10584 B / 64 KB(16.15%),RAM 1784 B / 20 KB(8.71%) |
#二、接线表
| 信号 | MCU 引脚 | 模块引脚 | 备注 |
|---|---|---|---|
| 摇杆 X 轴 | PA6 | 摇杆 VRX | ADC1_IN6,模拟输入 |
| 摇杆 Y 轴 | PA7 | 摇杆 VRY | ADC1_IN7,模拟输入 |
| 摇杆按键 | PB2 | 摇杆 SW | 上拉输入,按下为低电平;注意 PB2 同时是 BOOT1(实测插入摇杆时 PB2 读数为 0,电平以寄存器为准) |
| OLED SCL | PB6 | OLED SCL | I2C1,复用开漏 |
| OLED SDA | PB7 | OLED SDA | I2C1,复用开漏 |
| OLED 电源 | 3.3V / GND | OLED VCC / GND | 洋桃编号 OLED0561,SSD1306;I2C 地址 0x78,指令字节 0x00,数据字节 0x40,总线 200 kHz |
⚠️ PB2 = BOOT1 是本板必须记住的坑:摇杆按键接在 PB2 上(按下接地),该引脚会同时影响 BOOT1 的采样电平。但「摇杆插着 = BOOT1=1」并不成立——实测摇杆模块插着、程序正常运行时读
GPIOB_IDR,PB2 为 0。实际电平要读寄存器确认(GPIOB_IDR地址0x40010C08的 bit2),不能靠猜。真正会导致 SWD 连不上的是 BOOT0 悬空被板载 10K 上拉成 1,再叠加 BOOT1=1 → 进 SRAM 启动 → 内核跑飞。解法与完整设置见 洋桃1号下载模式速查。
#三、踩坑清单
先说结论(忘了就看这里):ASP 灯暗亮(双击 MODE 关闭自动下载)+ 长按使灯闪 2 次 +
BOOT0 接 GND + 断电重新上电 → ST-Link 普通模式即可连接烧录。
千万别把 BOOT0 悬空——板载 10K 上拉会让它变成高电平。
- ADC 时钟必须显式分频到 /6(最隐蔽):F1 的 ADCCLK 上限 14 MHz,PCLK2 是 72 MHz,HAL 默认
/2会配成 36 MHz 严重超频,转换值会漂。必须显式写RCC_ADCPCLK2_DIV6→ 12 MHz。 - SWD 报
Unable to get core ID多半是启动模式问题,不是接线问题:启动模式决定 SWD 能否连接——BOOT0=0 走主闪存、BOOT0=1+BOOT1=0 走系统 Bootloader,两者 SWD 都 ✅;只有 BOOT0=1 且 BOOT1=1(内置 SRAM)才连不上。解法是 BOOT0 接 GND(不能悬空)→ 断电再上电(BOOT 电平是上电瞬间采样的)。 - 用 ST-Link 的正确档位是「灯暗亮 + 闪 2 次 + BOOT0 接 GND」:双击 MODE 关掉自动下载(ASP 灯从「正常亮」变「较暗」),再长按停在闪 2 次。开着自动下载会让 ASP 反复抢 BOOT0,程序跑不起来。
- 「长按闪 2 次 = 连不上」是旧结论,已推翻:长按到「闪 2 次」(RAM ISP 档)只改变 ASP 对 BOOT1 的处理;BOOT0 已接 GND 固定为 0 时,芯片仍从 Flash 启动,BOOT1 是无关项,SWD 照连不误。致命组合只有「BOOT0 悬空=1 + BOOT1=1」。
- PS1 脚本含中文必须存 UTF-8 带 BOM:否则 PowerShell 5.1 按 GBK 解析会报语法错误。
- 换过编译器后必须删
build/重新配置:否则 CMake 缓存里还是系统 MinGW 的 gcc,报unrecognized command line option '-mthumb'。 - 汉字库/图片库用不到就裁掉:
CHS_16x16.h、PIC1.h在本工程无用,删掉能省不少 Flash。
#四、实现步骤
#4.1 ADC + DMA 连续采样
要点三个,少一个都不行:
- ADC 时钟分频(见踩坑 1)
- 扫描 + 连续 + 循环 DMA:两个通道自动轮转,数据自动刷新到内存,主循环直接读数组
- 启动前校准
c/* ADC 时钟 = PCLK2 / 6 = 12MHz(F1 上限 14MHz) */
RCC_PeriphCLKInitTypeDef PeriphClkInit = {0};
PeriphClkInit.PeriphClockSelection = RCC_PERIPHCLK_ADC;
PeriphClkInit.AdcClockSelection = RCC_ADCPCLK2_DIV6;
HAL_RCCEx_PeriphCLKConfig(&PeriphClkInit);
cvolatile uint16_t ADC_DMA_IN[2]; /* [0]=IN6(X轴) [1]=IN7(Y轴) */
hadc1.Instance = ADC1;
hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; /* 多通道扫描 */
hadc1.Init.ContinuousConvMode = ENABLE; /* 连续转换 */
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; /* 右对齐,0~4095 */
hadc1.Init.NbrOfConversion = 2;
HAL_ADC_Init(&hadc1);
__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);
sConfig.Channel = ADC_CHANNEL_6; /* PA6 */
sConfig.Rank = ADC_REGULAR_RANK_1;
sConfig.SamplingTime = ADC_SAMPLETIME_28CYCLES_5;
HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Channel = ADC_CHANNEL_7; /* PA7 */
sConfig.Rank = ADC_REGULAR_RANK_2;
HAL_ADC_ConfigChannel(&hadc1, &sConfig);
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)ADC_DMA_IN, 2);
DMA 配置:DMA1_Channel1、外设到内存、外设地址不变、内存地址递增、半字、循环模式。
#4.2 摇杆按键
PB2 上拉输入,按下为低:
cGPIO_InitStruct.Pin = GPIO_PIN_2;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
#4.3 OLED 驱动移植:标准库 → HAL
原驱动用 StdPeriph 的 I2C_SAND_BYTE / I2C_SAND_BUFFER 发数据。HAL 里只需把它们换成一次 HAL_I2C_Master_Transmit,格式为 [控制字节 + 数据...]:
cstatic void OLED_WriteBytes(u8 ctrl, const u8 *buf, u16 len)
{
u8 tmp[40];
tmp[0] = ctrl;
for (u16 i = 0; i < len; i++) tmp[i + 1] = buf[i];
HAL_I2C_Master_Transmit(&hi2c1, OLED0561_ADD, tmp, len + 1, 100);
}
static void OLED_WriteByte(u8 ctrl, u8 dat)
{
u8 tmp[2] = { ctrl, dat };
HAL_I2C_Master_Transmit(&hi2c1, OLED0561_ADD, tmp, 2, 100);
}
顺手做了个优化:原驱动显示一个 8x16 字符要发 16 次单字节,改成每页 8 字节一次发送,I2C 事务从 16 次降到 2 次:
cvoid OLED_DISPLAY_8x16(u8 x, u8 y, u16 w)
{
u8 t, c = 0;
y = y + 2; /* 驱动芯片以 0x02 列为屏幕最左列 */
for (t = 0; t < 2; t++) {
OLED_WriteByte(COM, 0xb0 + x);
OLED_WriteByte(COM, y / 16 + 0x10);
OLED_WriteByte(COM, y % 16);
OLED_WriteBytes(DAT, &ASCII_8x16[(w * 16) + c - 512], 8);
c += 8; x++;
}
}
字模表 ASCII_8x16[] 直接从原工程复制即可(-512 是因为字库从空格 0x20 开始)。
#4.4 显示居中
屏幕 128 px 宽,8x16 字体每行 16 个字符,居中就是算起始列:
cstatic void OLED_ShowCentered(uint8_t page, const char *str)
{
uint8_t len = 0, x, i;
while (str[len] != '\0') len++;
if (len > 16) len = 16;
x = ((16 - len) / 2) * 8; /* 起始列像素 */
for (i = 0; i < len; i++)
OLED_DISPLAY_8x16(page, x + i * 8, str[i]);
}
ADC 数值用同样的思路拼成 "ADC_IN6:2048" 再整体居中,避免用 sprintf(省 Flash):
cstatic void OLED_ShowAdcValue(uint8_t page, const char *label, uint16_t val)
{
char buf[17];
uint8_t i = 0;
while (*label != '\0' && i < 12) buf[i++] = *label++;
buf[i++] = (val / 1000) % 10 + '0';
buf[i++] = (val / 100) % 10 + '0';
buf[i++] = (val / 10) % 10 + '0';
buf[i++] = (val % 10) + '0';
buf[i] = '\0';
OLED_ShowCentered(page, buf);
}
#五、调用示例
(✅ 已验证:主循环直接读 DMA 数组,200 ms 刷新)
cOLED_ShowCentered(0, "Zhangshaohua");
OLED_ShowCentered(2, "ADC TEST");
while (1) {
OLED_ShowAdcValue(4, "ADC_IN6:", ADC_DMA_IN[0]);
OLED_ShowAdcValue(6, "ADC_IN7:", ADC_DMA_IN[1]);
/* 摇杆按下(低电平)时左上角显示 Y */
OLED_DISPLAY_8x16(0, 0,
(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2) == GPIO_PIN_RESET) ? 'Y' : ' ');
HAL_Delay(200);
}
#六、编译与烧录
本文所有操作走脚本(工具链自动发现,无需配置 PATH):
powershell# 新建工程
powershell -ExecutionPolicy Bypass -File D:\stm32_env\scripts\new_project.ps1 -Name joystick_adc -Dir D:\stm32_demo
# 编译
powershell -ExecutionPolicy Bypass -File D:\stm32_env\scripts\build.ps1 -ProjectDir D:\stm32_demo\joystick_adc
# 烧录
powershell -ExecutionPolicy Bypass -File D:\stm32_env\scripts\flash.ps1 -ProjectDir D:\stm32_demo\joystick_adc
输出:
txt[66/66] Linking C executable joystick_adc
Memory region Used Size Region Size %age Used
FLASH: 10584 B 64 KB 16.15%
RAM: 1784 B 20 KB 8.71%
编译成功
Download verified successfully
MCU Reset - Software reset is performed
烧录完成
#七、参考库/链接
- 工具箱脚本:
D:\stm32_env\scripts\(new_project / build / flash / erase / probe / reset / keil / uart_flash) - 本文工程:
D:\stm32_demo\joystick_adc - OLED HAL 驱动:
User\oled0561.c/User\oled0561.h - 字模表:
User\ASCII_8x16.h - 下载模式 / SWD 连不上排查:洋桃1号下载模式速查
- OLED(SH1106,软件 I2C 版)驱动档案:/kb/stm32/display/oled-sh1106
- ADC 单次转换踩坑(EXTTRIG / 恒 65535):ADC+OLED 调试复盘
换芯片时改三处:
CMakeLists.txt顶部的MCU_LINE/CPU_FLAGS/ 链接脚本路径,再换启动文件和链接脚本MEMORY段即可。
#八、更新日志
| 日期 | 类型 | 更新内容 |
|---|---|---|
| 2026-09-11 | 修改 | 修正启动模式相关结论:① 「摇杆插着 = BOOT1=1」为误判——实测插入摇杆时 PB2 读数为 0,电平须读 GPIOB_IDR bit2 确认;② 「长按闪 2 次 = 连不上」作废——ST-Link 正确档位是「灯暗亮 + 闪 2 次 + BOOT0 接 GND」;③ 踩坑清单新增结论速查框 |
| --- | --- | --- |
| 2026-09-10 | 新增 | 首次建档 |