STM32F103 ADC多通道+DMA+OLED显示完整工程实现
2026/9/15 16:22:08 网站建设 项目流程

简介:这是一份基于STM32F103微控制器的AD多通道采集与OLED显示完整工程资源,面向嵌入式系统初学者与开发者,适用于传感器网络、数据记录、信号分析等常见场景,解决从多路模拟信号采集、ADC配置到OLED实时显示的开发需求。包内共433个文件,约13.43MB,以79个C源码和87个H头文件为主,涵盖ADC初始化、数据读取、OLED驱动、数据处理等完整功能模块;同时包含uvprojx等工程配置文件、hex烧录文件及编译中间产物,方便直接打开工程或对照学习。已有386人浏览学习。通过该资源,读者可深入理解STM32CubeMX、HAL库配置方式,掌握多通道采样、中断处理、滤波算法以及I2C/SPI控制OLED的完整流程;同时兼顾电源管理与异常处理设计思路,无论入门还是项目复用都极具参考价值。

1. 一块屏、八路采样:这个标题背后的真实需求

把"AD多通道采集"、"STM32F103"和"OLED"三个词拼在一起,最常见的现实场景就出来了:一块F103最小系统板上接了电位器、电流互感器二次侧或热敏电阻分压网络,要求在调试台上不连电脑就能看到多路电压值。最常见做法是用STM32CubeMX把ADC配置成多通道加DMA循环采集,再用I2C挂一块0.91寸OLED,屏上直接输出每一路的实时电压,省掉上位机和串口线。

新手常犯的错是裸轮询:HAL_ADC_Start加HAL_ADC_PollForConversion,采完一路再切下一路,数据是能出,但CPU空转、通道切换有间隙,采集几路之后主循环节奏全乱。标题里这组词组合起来,真正值钱的是"多通道"和"显示"之间的衔接方式——DMA把采集结果连续填进缓冲区,主循环只管取数、格式化、刷屏。这套路是仪表、电池监测、电源调试这类场景的通用骨架,看完本文你得到的是一份能直接抄走的工程模板。

2. STM32F103 的 ADC 多通道采集原理:扫描规则组与 DMA 搬运

2.1 规则组扫描模式:通道顺序由配置决定

STM32F103 的 ADC1 有 16 个外部通道,但数据寄存器 DR 只有一个。所谓"多通道采集",本质上是用规则组(Regular Group)按顺序把多个通道依次接入转换器,每一个通道的结果在转换完成后写入同一个 DR,如果程序不及时读走,下一个通道的转换结果会直接覆盖掉旧值。

开启扫描模式(Scan Mode)后,规则组的转换顺序完全由 CubeMX 里添加通道的顺序决定。也就是说 IN0、IN1、IN2 按 rank 1、2、3 排列,转换结果就按这个顺序依次出现,跟引脚物理编号无关。例如在 CubeMX 的 ADC1 配置面板里依次添加 IN0、IN1、IN2…IN7,转换完一轮后 DR 里出现的顺序必然是 IN0 最先、IN7 最后。如果通道配置顺序写成 IN3、IN1、IN0,那么缓冲区里前三项对应的是 IN3、IN1、IN0 的值——这是后面读数组判断电压最关键的前提,错了整屏数据全部张冠李戴。

规则组还有一个容易忽略的限制:扫描模式下如果不开连续转换,一轮扫完就会停住,下一次触发才重新开始。配合 DMA 循环模式时,一般把连续转换(Continuous Conversion)打开,让 ADC 自己一轮接一轮跑,DMA 持续把 DR 里的结果搬到内存,三者形成一个不受 CPU 干预的数据流水线。

2.2 DMA 循环搬运:让 ADC 自己往数组里写数据

多通道采集场景里,中断方式不是不能用,但每转换完一个通道就进一次中断,8 通道就是 8 次中断/轮,中断服务程序里如果再做数据处理,CPU 占用率会非常难看。DMA 的价值是把"从 DR 读数据"这个动作从 CPU 手里拿走,由 DMA 控制器直接完成,而且 STM32F103 的 DMA 外设支持循环模式(Circular),地址自动回卷,天然适合"周期性刷新一批采样值"的采集模型。

对应的 DMA 关键参数只有四个:

  • Direction:Peripheral To Memory(从外设到内存)
  • Mode:Circular(循环模式)
  • Peripheral Increment:Disable(外设地址固定,始终是 ADC DR 寄存器)
  • Memory Increment:Enable(内存地址递增,按顺序填数组)

外设地址固定、内存地址递增这一对组合容易理解:源始终是同一个 DR,目标是数组的连续地址。数据宽度必须设置成 Half Word(16 位),因为 F103 的 ADC 是 12 位分辨率,转换结果存放在 16 位 DR 里。如果误设成 Word,DMA 每搬一次会跨过 4 个字节,缓冲区里的数据就错位了。

#define ADC_CH_NUM 8 volatile uint16_t adc_buf[ADC_CH_NUM]; // 启动 DMA 采集,adc_buf 会被不断刷新 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_CH_NUM);

这段代码里 adc_buf 用了 uint16_t 数组,类型和 DMA 数据宽度对齐;volatile是为了防止编译器把主循环里的读取优化成只读一次缓存,这一点对老手也要再强调一次——DMA 在后台改这个数组,CPU 侧不加 volatile,优化开 O2 后很容易读到陈旧数据。启动函数只需要调用一次,Circular 模式下它会一直在后台跑,不需要反复 Start_Stop。

2.3 采样时间与总转换耗时:先算清楚再设参数

ADC 的转换时间不是随便拍的。F103 的 ADC 时钟最高 14MHz,一般从 APB2(72MHz)分频得到,可选 2、4、6、8 分频。12 位转换本身固定占 12.5 个 ADC 时钟周期,加上用户可选的采样周期,单通道总转换时间公式为:

Tconv = (采样周期 + 12.5) / ADC时钟频率

例如 APB2 为 72MHz、ADC 分频 6 得到 12MHz,采样周期设为 71.5,那么单通道耗时就是(71.5 + 12.5) / 12MHz = 7us,8 通道一轮大概 56us。如果采样周期缩到 1.5,单通道降到约 1us,速度提升了 7 倍,但输入源阻抗稍大(比如 10k 以上分压网络)时采样结果会偏低,因为采样电容没来得及充满电压就被切走了。

采样周期的选择原则不复杂:信号源阻抗大(电位器、热敏电阻分压)就往大里设,比如 239.5;信号源是运放输出这种低阻抗驱动,可以往小里设换取速度。多通道采集还有一个隐性约束——如果把 8 个通道全部设成 239.5 周期,每轮转换 168us,对应刷新率约 6kHz,对显示类和大多数传感器应用都够用,却不适合需要同步高速采样的场景。工程上更常见的折中是:快的通道设短周期,慢速传感器通道单独设长周期,混合配置省总时间。

启动多通道 DMA 采集前,还需要做一次片上校准:

HAL_ADCEx_Calibration_Start(&hadc1);

这一行放在 HAL_ADC_Start_DMA 之前,作用是修正芯片内部的电容失调误差。F103 的 ADC 校准不需要外部基准,直接在初始化后跑一次即可,工厂里管这个叫"去零点漂移"。跳过去不调也不会完全不工作,但实测中低量程段的线性度会差一点,逢上精密采集就是隐患。

3. 用 STM32CubeMX 配置 ADC 多通道 + DMA + I2C 最小工程

3.1 引脚分配与时钟树:先把基础工程建对

打开 STM32CubeMX,选择 STM32F103C8T6,在 Pinout 视图里依次做三件事:把 PB6、PB7 分配到 I2C1(分别是 SCL 和 SDA),把 PA0 到 PA7 分配到 ADC1 的 IN0 到 IN7,然后进 Clock Configuration 把系统时钟设到 72MHz。ADC 时钟在 RCC 的 APB2 分频里选 6 分频(12MHz),这个值的意义在于离最高 14MHz 不远,同时又给采样周期留了一定余量。

需要注意的是硬件 I2C1 在 F103 上默认就挂在 PB6/PB7,但很多开发板把 OLED 接到了别的引脚,比如 PA5/PA7 或软件模拟 I2C。CubeMX 里如果选硬件 I2C1,就必须用 PB6/PB7,除非你改配置到 I2C2(PB10/PB11)。OLED 的 I2C 从机地址默认是 0x3C(7 位地址),写坐标是 0x78,这块后面写驱动时还会用到。

3.2 ADC 参数面板逐项对照:这张表直接照着填

进入 ADC1 的 Parameter Settings,按以下表格逐项配置,这些参数直接决定 DMA 能否把 8 路数据按预期顺序灌进数组:

配置项取值说明
Scan Conversion ModeEnabled开启扫描,规则组多通道按序转换
Continuous Conversion ModeEnabled连续转换,配合 DMA 循环实现不间断采集
Discontinuous Conversion ModeDisabled不使用间断模式,否则一轮只转换部分通道
DMA Continuous RequestEnabled让 DMA 持续接收 ADC 请求,老版本在 DMA Settings 里
Number Of Conversion8规则组的通道数量,不是最大通道号
External Trigger Source软件触发F103 用 TIM 触发也能跑,这里用软件启动最简洁
ADC Clock Prescaler6 分频APB2 72MHz 分频得到 12MHz 的 ADC 时钟

通道列表里依次添加 IN0 到 IN7,采样时间建议先统一设 239.5,等实物跑起来再根据信号源阻抗微调。这里有几个初学者容易踩的细节:Number Of Conversion 设成 8 之后,通道列表里必须有 8 个条目,多了少了 CubeMX 会直接报错;软件触发模式下不用在代码里做任何触发配置,只要调用了 HAL_ADC_Start_DMA,硬件自动启动第一次转换。

3.3 DMA 配置与生成后的两行关键代码

在 ADC1 的 DMA Settings 里添加 DMA Request,方向选 Peripheral To Memory,模式选 Circular,数据宽度 Peripheral 和 Memory 都选 Half Word。Memory Increment 默认就是 Enabled,外设地址递增保持 Disabled,不要手痒把它打开——ADC 的数据寄存器是固定地址,打开了 DMA 会去读一串不存在的地址。

生成 MDK 工程后,CubeMX 已经把初始化代码写好了,但在 while 之前需要手动补两行:

uint16_t adc_buf[8]; // 放在文件顶部,注意加 volatile // 在 MX_ADC1_Init() 之后、主循环之前执行 HAL_ADCEx_Calibration_Start(&hadc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 8);

HAL_ADC_Start_DMA 的第三个参数传 8,指的是 DMA 缓冲区按元素计数,不是字节数。因为 ADC 的数据宽度是 Half Word,所以实际搬运 16 字节。很多"只显示第一路"的奇怪问题就出在这个参数上,传成 16 会让 DMA 工作到缓冲区边界之外。

3.4 OLED 接线与硬件 I2C 的备选方案

0.91 寸 OLED 是 4 针或 6 针 I2C 接口,接线方式很固定:VCC 接 3.3V,GND 接 GND,SCL 接 PB6,SDA 接 PB7。如果你的板子把 OLED 接到了任意 GPIO 上,那 CubeMX 里就别选 I2C1,直接全配成 GPIO 输出,代码里顺序翻转 SCL/SDA 模拟时序——这个方案在 F103 上其实更常见,因为硬件 I2C 在这个型号上出过不少兼容性的坑,很多工程师干脆默认用软件模拟 IIC 驱动 OLED。

软件模拟 I2C 的好处是引脚随便选、时序自己控制,但主循环里被打断时容易产生毛刺。硬件 I2C 则要留意总线上是不是存在地址冲突——0.91 寸 OLED 通常只有一个从机,如果挂第二块 I2C 器件,确认地址不能一样。实机调试时如果 OLED 不亮,先用示波器量 SCL/SDA 有没有波形,比反复改代码快得多。

3.5 下载前检查:BOOT0、SWD 引脚与 DAP 下载失败

PCB 打样回来第一次下载时最常卡在"识别不到芯片"。这里有个容易误会的点:BOOT1 引脚在正常 Flash 启动模式下不用管,BOOT0 必须被拉低;有的人看到下载失败就去拨 BOOT1,没意义。如果 ST-LINK 提示 DAP 下载失败,按顺序检查:SWDIO/SWCLK 是否被复用到其他功能、BOOT0 是否确实是低、目标板供电是否稳定。按住复位键再点下载,等进度条出现再松手——这个技巧能解决相当一部分首次连接失败的尴尬。

4. HAL 库驱动 OLED 并实时显示多通道数值

4.1 I2C 协议中的控制字节:命令和数据怎么区分

OLED 驱动芯片 SSD1306 的 I2C 传输格式和普通传感器不太一样。每一次写操作,主机先发从机地址 0x78(0x3C 左移一位),再发一个控制字节,控制字节为 0x00 表示后续是命令,0x40 表示后续是显存数据。也就是说,命令和数据的区分不在地址位,而是靠这个控制字节完成,有别于 EEPROM 那种靠寄存器地址区分读写对象的方式。

void OLED_WR_Byte(uint8_t dat, uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, 0x78, cmd, I2C_MEMADD_SIZE_8BIT, &dat, 1, 100); }

上面这个函数用 HAL 库的 Mem_Write 把控制字节当内存地址用,第一次写从机地址,第二次写内部地址(即控制字节),第三次写数据。网上流传最广的江协科技 OLED 驱动源码里,OLED_WR_Byte 就是这个模式,HAL 库工程直接摘过来即可。若选用软件模拟 I2C,就把 HAL_I2C 换成 GPIO 翻转加延时,无非是 SCL 拉高拉低之间插入约 5us 的延时,把 8 位数据逐位送出去。

4.2 SSD1306 显存模型:128x32 是怎么被映射成 4 页的

0.91 寸 OLED 分辨率是 128x32,内部显存按"页"组织:每页 8 个像素高,对应 32 像素高正好分成 4 页(Page 0 到 Page 3),每页横向 128 个字节。这意味着整屏显存只有4 x 128 = 512字节,显示一个字符时不需要操作像素点,而是修改对应页内某个字节的位。

OLED_ShowString 这类函数内部都维护着一个 OLED_GRAM[4][128] 数组,先往数组里写像素,再把整个数组刷进屏幕。全屏刷新在 400kHz I2C 下大约 13ms,100kHz 下要 50ms 左右,对于电压显示足够快。江协科技的模板驱动里就是"全量刷屏"的思路,在 128x32 这种小屏上完全够用,不需要像大屏那样做分页 DMA。

4.3 主循环里做三件事:取数、算电压、刷字符

有了 DMA 在后台填 adc_buf,主循环就能专心做显示。0.91 寸屏幕一行刚好 16 个 ASCII 字符(8x6 字体,128/8=16),8 个通道拼成 4 行显示,正好一屏放满。

char line[24]; uint8_t i; while (1) { for (i = 0; i < 4; i++) { float v_left = adc_buf[i * 2] * 3.3f / 4095.0f; float v_right = adc_buf[i * 2 + 1] * 3.3f / 4095.0f; sprintf(line, "CH%d:%.2f CH%d:%.2f", i * 2 + 1, v_left, i * 2 + 2, v_right); OLED_ShowString(0, i * 8, line); } HAL_Delay(100); }

这段代码用 sprintf 把两个通道的电压拼进一行,OLED_ShowString 的第一个参数是列坐标,第二个是行像素坐标,与页地址无关——驱动函数内部会把 y 坐标转成对应页和页内偏移。电压计算公式里的 4095 来自 12 位 ADC 的满量程,3.3V 是参考电压。如果供电不是精确的 3.3V,把 3.3f 换成实测值,或者用精密基准源接一个通道做反推校准,这是后面第 5 章要展开的话题。

HAL_Delay(100) 把显示刷新率压到 10Hz,人眼读数不卡,同时给 DMA 留足了采样窗口。不需要为显示做双缓冲,0.91 寸的 512 字节显存本身就在驱动层维护,主循环只读 adc_buf,两者互不干扰。

4.4 别忘了初始化顺序:上电时序和首屏

OLED 驱动初始化要保证芯片上电稳定后再送命令。常规做法是在 OLED_Init 里先延时 100ms,然后依次发送关显示(0xAE)、时钟分频、复用率、显示偏移、起始行、对比度、扫描方向等一串命令,最后开显示(0xAF)。这些命令序列在 SSD1306 datasheet 里有标准模板,不需要自己发明,摘用现成驱动的初始化数组最稳。

如果 OLED 上电后屏幕是黑屏但 I2C 波形正常,排查方向基本就两个:初始化命令里漏了 0xAF,或者地址不匹配。注意 0x91 寸 OLED 也有用 SH1106 芯片的,SH1106 的初始化序列和 SSD1306 大体兼容,但列地址是 0x00 到 0x7F 带 2 列偏置,细节差异会导致边缘显示异常,这种问题离线调试时最容易忽略。

5. 把"能显示"做到"测得准":滤波、局部刷新与常见坑排查

5.1 采样稳定性:先把数值跳动问题解决

ADC 显示值如果在小数点后两位反复跳,先别急着改代码。最优先确认几个硬件因素:PA0-PA7 引脚有没有悬空引脚(用长的杜邦线接信号源相当于天线)、模拟地与数字地有没有单点接地、供电是不是开关电源直供。硬件上忍不了的噪声,软件上做中值或者均值滤波。

uint16_t get_median_value(uint8_t ch) { uint16_t buf[5]; uint8_t i, j; uint16_t tmp; memcpy(buf, (uint16_t *)adc_buf + ch, sizeof(buf)); // 注意数组是 uint16_t // 简单冒泡排序,取中值 for (i = 0; i < 4; i++) for (j = i + 1; j < 5; j++) if (buf[i] > buf[j]) { tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } return buf[2]; }

中值滤波对尖峰脉冲干扰的抑制效果比均值好,代码也容易理解:每次取同一通道的连续 5 个样本,排序后取中间值。这个滤波函数放在显示循环里调用,等于是以牺牲一点响应速度为代价换来了读数的稳定。如果通道数多,更高效的方案是把中值滤波挪到 DMA 中断里做滑窗,但对显示类应用没必要。

5.2 局部刷新:别让 OLED 刷新拖慢采样节奏

全量刷 512 字节在小屏上本来就不慢,但如果采样率和显示刷新率都要提升,就要改用局部刷新。SSD1306 支持按页设置列地址范围,只重发本页变化的字符区域。显示第 2 行文本时,只需更新 Page 1 对应列地址范围的字节,I2C 上的数据量大幅减少。

void OLED_Refresh_Page(uint8_t page, uint8_t col_start, uint8_t col_end) { uint8_t c; OLED_WR_Byte(0xB0 + page, OLED_CMD); // 设置页地址 Page0~3 OLED_WR_Byte(col_start & 0x0F, OLED_CMD); // 列低地址 OLED_WR_Byte(0x10 | (col_start >> 4), OLED_CMD); // 列高地址 for (c = col_start; c <= col_end; c++) OLED_WR_Byte(OLED_GRAM[page][c], OLED_DATA); // 只发送这一页的数据 }

这个函数替代全量刷新后,主循环里的显示代码就从OLED_Refresh()改成按需调用OLED_Refresh_Page(1, 0, 63)。局部刷新的代价是函数调用变复杂,要对每个字符区域预先计算占用的列范围。对于 8 位 ASCII 字符,一行文本就是 128 列,半行刷新也够,实际收益主要来自后续想加曲线或进度条时。

5.3 五个必查故障点

现象大概率原因处理
只有第一路有数值,其余固定不变DMA 的 Memory Increment 未开启CubeMX 里勾上 Memory Increment,重新生成
数值跳到合理范围之外ADC 引脚悬空或信号源阻抗过大未接传感器时把引脚接 GND,调长采样时间
OLED 花屏或乱码I2C 速率过高或供电噪声400kHz 降到 100kHz,看波形是否有毛刺
刷屏时采样值偶发跳变DMA 写入和 CPU 读数组冲突先拷贝一份数组再滤波,或短临界区
整屏偏移一列或左右不对称用的是 SH1106 但按 SSD1306 驱动确认屏幕芯片型号,改用对应列偏移命令

这五个问题覆盖了我接触过的绝大多数 F103+OLED 采集工程的返工场景。最后一个问题最容易坑人——同样写着 0.91 寸 OLED,SSD1306 和 SH1106 的列寻址细节不同,买到便宜屏之前先确认芯片,能省掉一晚上的排错时间。把 I2C 速率降下来、滤波加上、局部刷新做出来,这套采集显示系统的稳定性和响应速度基本就到了头,剩下的工作就是把参考电压从 3.3V 换成独立基准源,把电压计算精度往 1mV 级别推。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询