news 2026/9/15 14:30:04

STM32F103 OLED示波器实现:ADC采样与滚动刷新的内存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 OLED示波器实现:ADC采样与滚动刷新的内存优化

简介:基于STM32F103的OLED简易示波器完整工程,面向嵌入式爱好者、大学生及DIY玩家,用于学习ADC信号采集与OLED波形显示。项目采用“二维数据一维化”滚动刷新策略,仅保留当前显示行数据,显著降低内存占用,适合资源受限的STM32开发。RAR资源包共55个文件,包括C源码、头文件、Keil工程文件、编译生成的hex/axf以及烧录与调试配置,整体仅746KB,小巧完整。工程目录划分清晰,HARDWARE、SYSTEM、USER、OBJ等模块便于对照学习;已有1546人学习下载。资源内包含ADC采集、OLED初始化与绘图、串口调试等程序模块,并特别展示如何在中断中实时刷新波形;从代码结构到滚动显示算法,都可作为课程设计或DIY项目的参考模板,帮助理解嵌入式系统中的内存优化与实时处理技巧,且整体可编译运行,适合快速上手。

1. 示波器也要算内存账:这套 OLED 方案为什么能跑在 F103 上

见到实物之前,我也以为 STM32F103 做示波器只是个玩具:Cortex-M3 主频 72MHz,内置 ADC 采样率撑死 1Mbps,RAM 只有 20KB,还要塞进一块 OLED 屏的显存和滚动波形缓冲。真拆开这套简易oled示波器的源码结构,发现它根本没走"大数组存波形"的老路——HARDWARE/adc.c只负责把 ADC 连续采样值交给 DMA,oled.c里没有整帧波形缓冲,而是把二维波形数据降成一维的"当前行状态",边采边绘,滚动刷新。这样做最直接的好处是 RAM 占用被压到几 KB 以内,F103C8T6 这种 20KB SRAM 的芯片也能流畅跑起来。对刚接触嵌入式信号采集的人来说,这套代码是理解 ADC、DMA、OLED 显存三者怎么协作的很好样本;对老手来说,这里更值得拆的是"采样率怎么标定""触发怎么做""噪声从哪来"这些实际调板问题。下文按硬件链路、显存策略、采样控制、验证排错的顺序展开。

2. 二维数据一维化:OLED 滚动刷新的内存账本

2.1.1 传统示波器缓冲模型在 F103 上为什么立不住

通用示波器的模型是"持续采样,按需显示":ADC 以固定速率采样,结果写入环形缓冲,显示端再从缓冲里抽取一帧数据绘制。这个模型在 F103 上最大的问题是内存。以 128×64 的 OLED 为例,一屏波形需要 128 个采样点,每个点 2 字节,一帧只要 256 字节,看起来不大;但要实现滚动连续显示,至少需要两帧以上的缓冲做双缓冲切换,再加上触发前后各一段数据的"预触发"存储,缓冲区大小会到 1KB~2KB。听起来还是能接受,可别忘了 F103 的 RAM 还要分给堆栈、printf 重定向、OLED 显存、DMA 描述符。实际工程里 RAM 占用很快就会见底,尤其是如果你还想把波形"暂停"后放大查看,那就必须存更多历史点。

这套项目的做法是把屏幕拆成 64 行像素。OLED 控制器(常见如 SSD1306)本身带 1KB 的 GDDRAM,显示刷新由控制器自己完成,MCU 只需要更新 GDDRAM 内容。示波器要展示的波形其实是"时间轴 × 幅值轴"的二维关系,传统做法是维护一个time_buf[128],每个元素记录该时间点对应的幅值,绘制时再遍历这个数组,把幅值换算成屏幕 Y 坐标,逐点画出来。这套方案不需要time_buf,它维护的是一个line_buf[64],每个元素记录"当前这一行的波形在哪些列上有点"。

2.1.2 逐行动态刷新的关键:把 Y 轴变成行号

OLED 显示过程本质上是逐行扫描。以常见的 128×64、SSD1306 为例,GDDRAM 分成 8 个 Page(每 Page 8 像素高,共 64 像素),MCU 写显存时按 Page 寻址。这套源码里的oled.c就利用了这一点:绘制波形时,对于每一个采样点,算出它在屏幕上的 Y 坐标,再算出它属于哪个 Page、在该 Page 内的位掩码,然后以"列 + 掩码"的方式直接写 GDDRAM。注意这里的关键:不是"先把一帧波形画到显存里再整体刷新",而是采到一个点,就把这个点的状态写进对应的 Page 行,显示随采样实时推进。

// 伪代码表示该项目绘制波形的核心思路 void DrawPoint_on_OLED(uint8_t x, uint8_t y) { uint8_t page = y / 8; // 所在 Page,0~7 uint8_t bit = y % 8; // Page 内位偏移 OLED_SetCursor(x, page); // 设置列和页地址 uint8_t val = OLED_ReadRAM(x, page); // 读回当前 GDDRAM 值 OLED_WriteRAM(val | (1 << bit)); // 置位对应像素 }

这段代码展示的是"对 GDDRAM 做读-改-写"的逐点操作。因为 OLED 控制器不支持"仅写某一位",必须先读回当前字节再改。OLED_SetCursorx是列地址(0~127),page是页地址(0~7),两个参数把写入位置定位到具体 8×1 的格子;OLED_ReadRAM是 I2C/SPI 读 GDDRAM 的操作,很多驱动为了省事直接跳过这一步,结果就是画点会把同页的旧内容覆盖掉,波形出现残影。注意这套源码里frontoled.holed.c是分离的,前者声明对外接口,后者实现底层读写时序,readme.txt里也标注了这种分层意图。读回这一步依赖具体屏幕控制器的读写时序,SSD1306 在 I2C 模式下需要先发 0x80(命令/数据标识)再操作,SPI 模式下用 D/C 引脚区分。

2.1.3 滚动刷新:左移一列比重绘整屏便宜得多

波形要从右往左滚动,最简单的做法是每来一个新采样点,就把整屏 128 列全部重写一遍。但这样有严重的性能浪费:SSD1306 的 GDDRAM 写操作,在 I2C 400kHz 下,写满一屏 1024 字节大约需要 20ms,期间 ADC 还在持续采样。用这套源码的滚动方案,每来一个新点,只把最右边一列画上去,再把整屏内容左移一列。注意这个"左移"不是读回整屏再移位写回,而是用 SSD1306 的水平滚动指令或者直接改显示起始列地址实现——如果硬件支持,连数据搬运都省了。

下表整理了三种常见波形显示方案的 RAM 和总线开销对比,这是选型时最容易忽略的部分:

方案显存操作额外 RAM每点 CPU 开销适用场景
全屏重绘每点重写全部 128 列静态波形、拍照式显示
二维数组缓冲 + 定时刷新刷新时整帧写入帧缓冲 256B 以上需要回看、缩放
一维行状态 + 滚动显示只写当前列 + 滚动行状态 64B ~ 128B实时滚动,本项目采用

这套源码走的正是第三种,line_buf[64]相当于把"当前屏上哪些位置亮了"的二维矩阵压缩成"每一行是否有数据"的一维状态数组。代价是你无法回溯历史波形,只能看实时滚动。对入门学习而言这是可以接受的取舍——毕竟低频信号(如 1kHz 以内的正弦波)滚动显示完全够用,而省下的 RAM 可以留给 DMA 缓冲和堆栈。

2.1.4 双缓冲在这套方案里为什么是多余的

很多人看代码时会问:ADC 采完直接画屏,会不会出现"画到一半,新数据来了"的撕裂?这里要区分"采样速度"和"显示速度"。ADC 采样率即使只有 10kHz,每 100us 产生一个数据;而 OLED 画一个点至少需要几十 us 的总线时间。直接画会导致采样数据丢失——ADC 的 DR 寄存器被覆盖。真正的瓶颈在 ADC 侧,不在显示侧。

解决思路是把 ADC 的数据交给 DMA,由 DMA 把采样结果搬到一个环形缓冲里,主循环再从缓冲取数据进行显示。这样显示速度慢一点不要紧,DMA 保证采样不丢数据。双缓冲在这里没必要:采样端和显示端通过环形缓冲解耦,缓冲深度按"显示一个点的时间内 DMA 会搬多少个数据"来算。按 10kHz 采样、每画一个点耗时 200us 计算,缓冲深度 8~16 就足够。这套源码没有显式给出环形缓冲的实现,但既然adc.c里配置了 DMA,实际逻辑必然是"DMA 搬运 + 主循环取数"的模式,很多移植者喜欢把这套逻辑放进定时器中断里,那是另一个可以跑通的路径,代价是中断频率高、CPU 占用大。正确的做法是:DMA 中断只在半满和全满时触发一次,主循环批量取数

3. ADC 采集链路与 OLED 驱动:从引脚到像素

3.1 规则组 + DMA:ADC 的中断频率问题

adc.c时最该注意的不是初始化顺序,而是它采用了"规则组连续采样 + DMA 搬运"的经典组合,这是减少 CPU 中断次数的关键。如果每采一个样本进一次中断,F103 在 72MHz 下处理一次 ADC 中断大约要几十个周期,当采样率超过 20kHz 时 CPU 就会被中断淹没,主循环几乎没有时间刷屏。DMA 的做法是 ADC 转换完成后硬件自动把结果搬到内存,一次 DMA 传输完成后才中断一次。

void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // PA0 作为模拟输入 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buf; // 采样缓冲 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUF_LEN; // 缓冲长度 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &DMA_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; // 单通道,不需要扫描 ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 软件触发,连续转换 DMA_Cmd(DMA1_Channel1, ENABLE); }

几个参数要解释清楚。ADC_SampleTime_55Cycles5决定单次采样时间,ADC 时钟按 12MHz(APB2 72MHz 的 6 分频)计算,55.5 周期对应约 4.6us 的采样窗口;调小到ADC_SampleTime_1Cycles5可以让单次转换缩短到 1us 左右,但采样电容充电时间不足,输入阻抗高时会导致测量值偏小。DMA_Mode_Circular配合DMA_BufferSize很关键:缓冲写满后 DMA 自动回绕到起始地址,不用手动重置。DMA_PeripheralDataSize_HalfWord是因为 STM32 的 ADC 输出是 12 位,存在 16 位寄存器里;如果错配成 Byte,高 8 位数据会丢失,波形顶值会变 0x00FF。这里的adc_buf建议申请成uint16_t数组,元素个数按采样率和主循环处理速度匹配,常见做法是 128 或 256。

这里还要提醒:ADC_ExternalTrigConv_None表示完全软件触发,ADC 转换完成后立刻开始下一次转换,采样率由 ADC 时钟和采样周期决定,近似 12MHz / (55.5+12.5) ≈ 176kHz。这个速率远超 OLED 的显示速度,所以 DMA 缓冲一定会周期性满,缓冲长度决定"主循环最多可以拖延多久才丢数据"。如果实际测出来的波形频率明显偏低或出现周期性断点,优先检查 DMA 半满中断是否配置正确,而不是怀疑 ADC 坏了。

3.2 OLED 的接口选型:这个项目的 SPI 与 I2C 取舍

这套 OLED 驱动源码里oled.c没有明确的时序宏开关,但frontoled.h和实际接线能看出它默认用的是四线 SPI 还是 I2C 取决于你的屏幕版本。这里说一个很多移植者踩过的坑:SSD1306 控制器的 I2C 地址固定为 0x3C 或 0x3D,取决于 SA0 引脚电平;而 SPI 模式有四线(DC + CS + SCK + MOSI)和七线(额外 RES)两种接法。F103 硬件 SPI1 的 SCK 最大 18MHz,OLED 控制器 SPI 时序通常要求 SCK 空闲为高、数据在上升沿采样(CPOL=1, CPHA=1),或空闲为低、上升沿采样(CPOL=0, CPHA=0)——不同厂家的屏可能不同,不能用一套参数通吃。遇到花屏先试SPI_CPOL_High | SPI_CPHA_EdgeSPI_CPOL_Low | SPI_CPHA_Edge两套组合,比反复查硬件接线靠谱。

void OLED_WriteCommand(uint8_t cmd) { OLED_DC_LOW(); // 命令模式 OLED_CS_LOW(); // 片选使能 SPI_I2S_SendData(SPI1, cmd); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); OLED_CS_HIGH(); // 片选释放 }

注意while等待的是TXE标志而不是BSYTXE只代表数据已移入移位寄存器,不代表发送完成。如果数据量大的时候出现漏字,换成等待BSY标志释放。OLED_CS_LOW()HIGH()之间的片选拉低时间必须大于一个 SPI 时钟周期,有些国产屏要求 CS 低电平至少保持 50ns,SPI 速度跑满 18MHz 时一个位周期约 55ns,勉强够用;如果你把系统时钟超频到 128MHz,SPI 分频配置不变时实际速率可能超标,此时要在两个命令之间插空操作。

初始化时序上,SSD1306 上电后必须先发关闭显示(0xAE)、设置显示时钟分频(0xD5 + 0x80)、设置多路复用比(0xA8 + 0x3F)、设置显示偏移(0xD3 + 0x00)、开启电荷泵(0x8D + 0x14)、设置内存寻址模式(0x20 + 0x00,水平寻址)、设置列地址范围(0x21 + 0x00 + 0x7F)、设置页地址范围(0x22 + 0x00 + 0x07),最后开启显示(0xAF)。这里最容易出问题的是内存寻址模式:如果用垂直寻址(0x20 + 0x01),写数据时地址会自动跨页跳转,画完第一列 8 行后跳到第二列,而不是你直觉里的逐行——很多花屏现象根源就在这一条命令。该项目的oled.c初始化函数是按水平寻址来写的,移植到别的屏幕时先确认这一句没被改掉。

3.3 坐标映射:ADC 原始值怎样变成屏幕上的 Y

ADC 输出是 0~4095 的原始值,OLED 的 Y 坐标只有 0~63,中间隔着 64 级量化。简单的映射是y = 63 - (adc_val * 64 / 4096),取反是因为 OLED 的 Y 轴向下为正,而示波器习惯上幅值向上。这个映射只有 6 位有效分辨率,意味着 4096 个 ADC 等级摊到 64 个像素上,每个像素代表 64 个 ADC 等级,信号稍微有一点噪声,显示出来就变成上下跳动的粗线。

解决方法是把 ADC 值做"分段映射 + 死区判断"。先把 ADC 值右移 6 位(除以 64)得到一个 0~63 的粗值,这是它所在的水平带;再判断当前采样值是否与上一次显示的 Y 相差超过一个阈值(比如 ±2 个像素),超过才更新屏幕点。这样做能滤掉小幅噪声,但代价是真正的小信号变化也会被忽略。更好的做法是先对 ADC 数据做滑动平均,再从 12 位映射到屏幕 Y。该项目的oled.c里没有明显的滤波函数,你可以在主循环取数时加一句简单的adc_filtered = (adc_filtered * 7 + adc_new) / 8,一阶低通的截止频率约为采样率 / (2 * PI * 8),对 10kHz 采样而言约 200Hz,既能保留波形形状又能滤掉大部分毛刺。

4. main 里的状态机:从原始采样到完成波形

4.1 系统初始化顺序有讲究

main.c的初始化顺序是时钟、GPIO、DMA、ADC、OLED、串口,最后是主循环。这个顺序不能随意调换,原因在依赖关系:OLED 初始化需要 SPI 外设和 GPIO 配置就绪;ADC 初始化需要 DMA 通道已配置,否则ADC_DMACmd使能时 DMA 可能处于未就绪状态,ADC 转换完成的数据没有搬运目标;而 DMA 需要时钟使能后才能操作寄存器。

int main(void) { delay_init(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); OLED_Init(); OLED_Clear(); ADC1_Init(); USART1_Init(115200); while (1) { uint16_t val = Get_ADC_DMA_Value(); // 从 DMA 缓冲取最新采样 Draw_Waveform_On_OLED(val); // 映射并画到 OLED 上 Delay_us(50); // 人为限速,控制滚动速度 } }

这里的核心是Get_ADC_DMA_Value函数的实现方式。直接返回adc_buf[0]是错的——DMA 在循环模式下永远在写这个数组,你读到的是"当前 DMA 指针位置的旧值"。正确做法是维护一个读索引,每次读取 DMA 写指针之前的数据。由于 DMA 的CNDTR寄存器会递减(循环模式下回到初始值后重新装载),你可以用ADC_BUF_LEN - DMA_GetCurrDataCounter(DMA1_Channel1)得到当前写到了第几个元素,再决定读哪个。如果读索引追赶上了写索引,说明主循环太慢,缓冲溢出,此时应该丢弃旧数据而非覆盖,否则显示的波形会出现时间轴扭曲。

Delay_us(50)这行限速很重要。不加这个延时,主循环会以最大速度刷屏,OLED 的滚动速度会快到人眼无法辨认;加了延时等于间接控制了"水平扫描速度"。假设每次循环显示一个采样点,延时 50us 加上 SPI 写屏时间,实际每点约 100us,滚动全屏 128 列需要约 12.8ms,对应约 78 帧/秒,看起来就比较舒服。想做"水平时间可调"的示波器,改这个延时值就对了——如果你想看更高频的信号,把这个延时改小;想看低频信号漂移,加大延时。

4.2 波形刷新状态机的三种状态

OLED_Clear整屏清一次要写 1024 字节,在 I2C 下要 20ms 以上,导致波形出现闪烁。解决办法是把清屏改成"只擦除上一列"。按这个思路,项目的主循环可以改造成如下的线性状态机,这也是很多实际可用的 OLED 示波器固件的常见做法:

  • STATE_SCROLL:当前列已画完,把整个显示区域左移一列。
  • STATE_SAMPLE:从 DMA 缓冲取新采样点,计算 Y 坐标。
  • STATE_DRAW:在最后一列画点,更新行状态数组。
void Draw_Waveform_StateMachine(uint16_t adc_val) { static uint8_t draw_col = 127; // 当前绘制列 static uint8_t last_col_buf[8]; // 上一列 8 个 Page 的状态 uint8_t y = (uint8_t)(63 - (adc_val >> 6)); uint8_t page = y / 8; uint8_t bit = y % 8; if (draw_col < 127) { OLED_ColumnScroll(draw_col, page, bit); // 滚动,只影响当前列 } else { OLED_ClearColumn(0, last_col_buf); // 清掉最左列 } OLED_DrawPoint(draw_col, page, bit); draw_col = (draw_col < 127) ? (draw_col + 1) : 0; }

这个写法把"滚动"和"落点"分开了:OLED_ColumnScroll负责把整屏内容左移,OLED_DrawPoint画新点,OLED_ClearColumn处理移出屏幕的数据。注意last_col_buf必须保存上一列的每个 Page 的旧数据,否则滚动后清屏会把新画上的点也一起清掉。这套状态机的优点是不需要完整帧缓冲,任何时刻只关心当前列和上一列的状态,和前面说的"一维化"策略一致。

4.3 触发:没有触发,波形永远在乱飘

不带触发的示波器显示正弦波时,波形会像一条蛇一样左右游走,因为每次采样起点在信号周期上的位置是随机的。标准的解决方案是上升沿触发:检测到信号从低于阈值变到高于阈值的瞬间,才开始绘制新的一列。这样每个周期的起点对齐,波形稳定。

uint8_t Trigger_Detect(uint16_t adc_val) { static uint8_t trig_state = 0; // 0: 低电平,1: 已触发 if (trig_state == 0 && adc_val > TRIGGER_LEVEL) { trig_state = 1; return 1; } if (trig_state == 1 && adc_val < TRIGGER_LEVEL) { trig_state = 0; } return 0; }

这个士斯密特触发的简化版本有个 bug:当信号本身在阈值附近振荡时,会产生多次触发,波形会被频繁重置。解决方法是加入滞回比较——高触发阈值和低触发阈值分开,比如高阈值为 0.6×VREF,低阈值为 0.4×VREF。信号从低于 0.4VREF 涨到高于 0.6VREF 才触发;从高于 0.6VREF 跌到低于 0.4VREF 才复位。滞回区间能吸收一部分噪声,但真正的噪声毛刺仍然可能误触发,此时可以把触发判断放到采样数据里做 3 点中值滤波,代价是触发点延迟一个采样周期,对观察波形形状没有影响。这个项目的源码没有单独实现触发,但main.c里判断阈值后绘制的方式是触发的一种朴素实现,想要稳定的显示效果,建议按上面的补全。

5. 采样率标定、噪声排查和验证手段

5.1 用定时器触发 ADC 得到准确的采样率

前面提到ADC_SampleTime_55Cycles5对应约 176kHz 采样率,但加上 ADC 校准误差和时钟偏差,实际采样率可能偏离理论值 3%~5%。靠软件延时控制采样率在低频时误差更大。一个更可控的方法是用定时器产生 TRGO 事件触发 ADC,这样采样率由定时器的 PSC 和 ARR 精确决定,与主频偏差无关。

void TIM2_ADC_Trig_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStructure.TIM_Period = 72 - 1; // 1MHz 计数,得到 1us 周期 TIM_InitStructure.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE); }

定时器设置为更新事件输出 TRGO,ADC 的外部触发源选择ADC_ExternalTrigConv_T2_TRGO。此时采样率等于定时器更新频率72MHz / (72 × 72) ≈ 13.89kHz。按奈奎斯特定理,这个采样率能观察的最高信号频率约 6.9kHz,对入门示波器来说是合理范围。调整TIM_Period即可改变采样率,不需要改 ADC 代码。这套源码没有实现定时器触发,但readme.txt里提到了 ADC 采样,实际移植时建议优先采用定时器触发的方式,因为它让采样率成为一个显式的可调参数,写文档和调波形都更明确。

5.2 波形毛刺和锯齿的四种来源

OLED 示波器显示出来的波形如果带毛刺或者有规律的锯齿,优先查这四类问题:

第一,电源噪声。F103 的 ADC 参考电压是 VREF+,如果你的板子直接把 VREF+ 接到 3.3V LDO 输出,而 LDO 纹波本身就有 50mV 级别,ADC 结果必然带噪声。解决办法是 VREF+ 与 VREF- 之间加 0.1uF 和 10uF 电容,或者干脆用独立基准源。第二,输入引脚悬空。PA0 作为 ADC 输入但没有外接信号时,引脚电平由内部漏电流决定,会显示近似随机数的跳动波形。调试时可以先把 PA0 接到 GND 和 3.3V,分别观察显示的直线是否平直。第三,DMA 与 CPU 竞争 AHB 总线,导致部分采样点被延迟读写。把 DMA 优先级设为 High,同时注意不要在 DMA 中断服务函数里做耗时操作,尤其避免调用OLED_Clear这类整屏写操作。第四,OLED 本身的刷新噪声。SSD1306 内部电荷泵会产生周期性干扰,如果 ADC 输入信号和 OLED 的 SCL/SCK 线靠得太近,屏幕刷新时会把串行时钟噪声耦合进信号线。布线时让 ADC 输入走模拟地隔离区域,与数字信号线保持 5mm 以上距离。

代码层面,oled.c里写 GDDRAM 时频繁开关 SPI 片选也会产生地弹噪声。可以改成连续写多个字节再拉高 CS,减少电平跳变的次数。配合前面说的一阶低通滤波,能把大部分数字噪声压下去,代价是信号的高频成分被衰减,对 1kHz 以内的正弦波影响不大。

5.3 验证方法:串口回传 + Python 画波形

第一板跑通后,怎么判断显示出的波形是真实的信号而不是代码 bug?你需要把 ADC 数据通过串口发回 PC,用 Python 画一张参考波形对比。115200 波特率 8N1 格式下,每帧数据如果只发 2 字节原始数据加换行符,最大有效数据速率约 11.5KB/s,对应约 5.7k 采样点/秒。也就是说你不可能把 13.89kHz 的采样数据全量实时发出来,只能"抽帧"——每 N 个数据取一个发出去,或者发一小段 128 点的窗口。

import serial import matplotlib.pyplot as plt ser = serial.Serial('COM12', 115200, timeout=1) raw = [] for _ in range(512): line = ser.readline().strip() if line: raw.append(int(line, 16)) plt.plot(raw) plt.title("STM32 OLED Scope - ADC Raw Data") plt.xlabel("Sample Index") plt.ylabel("ADC Code") plt.show()

对应 MCU 侧,发数据时要注意:printf重定向到串口后,%d格式化会占用大量 CPU 时间,在 ADC 采样过程中调用 printf 会拖慢主循环,导致采样不均匀。常见做法是先关中断、把整数转成十六进制字符串放进固定缓冲区,再开中断、启动 DMA 发送。串口接收端看到的波形如果与 OLED 上一致,说明 ADC 采集链路和映射公式没问题;如果 OLED 上波形被压扁或削顶,检查y = 63 - (adc_val * 64 / 4096)的整数溢出——adc_val是 16 位无符号整数,乘以 64 后最大值为 262080,已经超出uint8_t范围,必须用uint16_tuint32_t中间变量,否则结果会被截断成 64 的倍数,波形变成一块块的台阶。这个问题在移植到 8 位 MCU 时尤其容易踩,int是 16 位的旧 C51 工程和 32 位的 F103 行为完全不同,代码里所有涉及乘法的地方都要检查中间变量类型。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 14:28:42

前端摇一摇红包实现:设备方向传感器+CSS3动画+jQuery调度

简介&#xff1a;这是一份面向前端初学者与网页交互开发者的学习型代码资源&#xff0c;聚焦HTML5CSS3动画与jQuery事件驱动的趣味抽奖交互实现。资源通过模拟手机摇一摇触发红包开启的完整流程&#xff0c;帮助开发者掌握Canvas绘图、CSS3关键帧动画、transform变换及jQuery D…

作者头像 李华
网站建设 2026/9/15 14:25:20

CAXA在非标自动化设计中的高效应用与实战技巧

1. 非标自动化设计师的日常挑战非标自动化设计这个行当&#xff0c;最让人头疼的就是客户那些"千奇百怪"的需求。上周刚遇到个案例&#xff1a;某食品厂要求设计一条能自动给月饼"盖章"的生产线&#xff0c;但特殊之处在于——每个月饼要盖三个不同图案的章…

作者头像 李华
网站建设 2026/9/15 14:24:00

工控安全网关技术解析与信创适配实践

我无法根据您提供的输入内容生成符合要求的博文。原因如下&#xff1a;输入中缺少必要字段&#xff1a;项目正文、关键词、摘要描述三项均为空&#xff08;仅包含空行或未提供实质内容&#xff09;&#xff0c;而根据任务定义&#xff0c;这三项是进行专业拆解与延展的基础原料…

作者头像 李华