stm32/input/joystick-dual-axis-adc.md

双轴摇杆(ADC + DMA 采集,OLED 实时显示)

知识库 · STM32 📅 初版 2026-09-10 ✏️ 更新于 2026-09-11

双轴摇杆(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摇杆 VRXADC1_IN6,模拟输入
摇杆 Y 轴PA7摇杆 VRYADC1_IN7,模拟输入
摇杆按键PB2摇杆 SW上拉输入,按下为低电平;注意 PB2 同时是 BOOT1(实测插入摇杆时 PB2 读数为 0,电平以寄存器为准)
OLED SCLPB6OLED SCLI2C1,复用开漏
OLED SDAPB7OLED SDAI2C1,复用开漏
OLED 电源3.3V / GNDOLED 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 上拉会让它变成高电平。

  1. ADC 时钟必须显式分频到 /6(最隐蔽):F1 的 ADCCLK 上限 14 MHz,PCLK2 是 72 MHz,HAL 默认 /2 会配成 36 MHz 严重超频,转换值会漂。必须显式写 RCC_ADCPCLK2_DIV6 → 12 MHz。
  2. SWD 报 Unable to get core ID 多半是启动模式问题,不是接线问题:启动模式决定 SWD 能否连接——BOOT0=0 走主闪存、BOOT0=1+BOOT1=0 走系统 Bootloader,两者 SWD 都 ✅;只有 BOOT0=1 且 BOOT1=1(内置 SRAM)才连不上。解法是 BOOT0 接 GND(不能悬空)→ 断电再上电(BOOT 电平是上电瞬间采样的)。
  3. 用 ST-Link 的正确档位是「灯暗亮 + 闪 2 次 + BOOT0 接 GND」:双击 MODE 关掉自动下载(ASP 灯从「正常亮」变「较暗」),再长按停在闪 2 次。开着自动下载会让 ASP 反复抢 BOOT0,程序跑不起来。
  4. 「长按闪 2 次 = 连不上」是旧结论,已推翻:长按到「闪 2 次」(RAM ISP 档)只改变 ASP 对 BOOT1 的处理;BOOT0 已接 GND 固定为 0 时,芯片仍从 Flash 启动,BOOT1 是无关项,SWD 照连不误。致命组合只有「BOOT0 悬空=1 + BOOT1=1」。
  5. PS1 脚本含中文必须存 UTF-8 带 BOM:否则 PowerShell 5.1 按 GBK 解析会报语法错误。
  6. 换过编译器后必须删 build/ 重新配置:否则 CMake 缓存里还是系统 MinGW 的 gcc,报 unrecognized command line option '-mthumb'。
  7. 汉字库/图片库用不到就裁掉:CHS_16x16.h、PIC1.h 在本工程无用,删掉能省不少 Flash。

#四、实现步骤

#4.1 ADC + DMA 连续采样

要点三个,少一个都不行:

  1. ADC 时钟分频(见踩坑 1)
  2. 扫描 + 连续 + 循环 DMA:两个通道自动轮转,数据自动刷新到内存,主循环直接读数组
  3. 启动前校准
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 段即可。

#八、更新日志

📝共 3 条记录
日期类型更新内容
2026-09-11修改修正启动模式相关结论:① 「摇杆插着 = BOOT1=1」为误判——实测插入摇杆时 PB2 读数为 0,电平须读 GPIOB_IDR bit2 确认;② 「长按闪 2 次 = 连不上」作废——ST-Link 正确档位是「灯暗亮 + 闪 2 次 + BOOT0 接 GND」;③ 踩坑清单新增结论速查框
---------
2026-09-10新增首次建档