简介基于STM32F103C6的简易示波器Keil5工程源码适合嵌入式初学者与电子爱好者学习模拟信号采集、ADC-DMA数据传输及USB HID通信。工程包含完整的MDK-ARM项目文件、驱动层与中间件其中ADC通过DMA收集数据再经HID协议实时上传PC便于观察波形或二次开发。压缩包共969个文件以C源码572个、头文件257个、汇编启动文件51个及Keil工程配置为主另有少量链接脚本、库文件和说明文档整体仅11.72MB结构清晰。目前已有922人学习下载是理解STM32数据采集链路和USB设备类的典型实践案例。1. 为什么用 STM32F103C6 做示波器值得花这个时间一块 Flash 只有 32KB、RAM 只有 10KB 的芯片想做成一台能看波形、能调触发的示波器听起来像在钢丝上跳舞。但你回头看看手头那张 F103C6 最小系统板12 位 ADC、双 ADC 同步能力、DMA、两个定时器、一个 USB 设备控制器刚好就是示波器最需要的那几块拼图。很多开发者在某项目里用 F103C6 做过波形记录仪核心链路用到的资源不足芯片总量的 60%剩下 40% 还被留给了 LCD 刷新和按键响应。这个标题真正值钱的地方不在“能不能做”而在用什么思路把采样、触发、显示这三件事塞进一颗入门级 MCU让它在 1kHz 到 100kHz 范围里真正能当台式机用。开源代码很多但直接能跑、跑起来不抖的却很少原因往往不是寄存器配错了而是触发和时序没想清楚。2. 资源盘点与硬件方案F103C6 的极限配置在哪里2.1 先把 ADC 到 DMA 的链路算清楚采样率不是看数据手册很多人拿到 F103C6看手册写着 12 位 ADC、1μs 转换时间就以为能以 1MSPS 采样。这个误解会在示波器上第一个翻车。1μs 指的是 ADC 时钟 14MHz 时、最短采样周期 1.5 周期加 12.5 周期的总转换时间但工程上你几乎不会用最短采样周期去跑否则噪声和参考电压波动会让低 2 位完全不可信。我一般会把 ADC 时钟压到 12MHz采样周期设成 7.5 周期实际单通道采样率约 700kSPS换取稳定、可复现的波形。关键链路是定时器产生触发脉冲 → ADC 收到外部触发开始转换 → 转换完成后 DMA 把结果搬进内存环形缓冲。中间如果让 CPU 参与一次 ADC 中断进出就得消耗 30 个周期以上采样率直接掉三分之一所以 DMA 是必选项。F103C6 只有 DMA1但它的通道 1 正好对应 ADC1不需要抢占。我建议你在纸上先写一遍数据流采样率、每周期点数、屏幕宽度、时基档位然后把数字填进去。比如屏幕 240 像素宽每格 24 像素10 格满屏。如果基础时基是 100μs/div一屏 1ms以 600kSPS 采样一屏采 600 个点。预触发要看到触发点之前的波形环形缓冲至少要有两屏的容量也就是 1200 点。F103C6 的 10KB RAM 扣掉栈、全局变量、LCD 缓冲后能拨给采样缓冲的大约是 4KB按 uint16_t 算能存 2048 点足够覆盖预触发的一屏加显示的一屏。2.2 前端电路分压、钳位、偏置三个电阻撑起量程如果直接把被测信号引到 PA0F103C6 的 ADC 引脚最多承受 3.3V超过就是烧引脚而且输入阻抗不够信号源会被拉偏。一个可用的模拟前端至少要包含三件事分压衰减、二极管钳位、直流偏置。最常见的做法是三档量程2V/div、1V/div、0.5V/div。2V/div 档对应满量程 20V用 10:1 分压1V/div 档对应 10V 满量程用 5:10.5V/div 档直接 1:1 进入后级。分压电阻选 1% 精度两个电阻串联后接到运放跟随器再用两个 1N4148 反向并联到 3.3V 和 GND 之间做钳位防止负压和过压打到 ADC。运放用一颗单电源轨到轨的型号就能完成跟随但 C6 的 ADC 输入阻抗本身不高跟随器主要解决的是“信号源阻抗变化导致分压比漂移”的问题不是噪声问题。直流偏置要单独说。示波器要能看纯交流信号比如音频放大器输出它可能有 -2V 到 2V 的摆动。如果直接接 ADC负半周被削掉。所以在跟随器后面加一个由电位器和运放构成的加法器给信号叠加 1.65V 的直流偏移让 -2V 到 2V 映射到 ADC 的 0.15V 到 3.15V。这一步在软件里不花任何成本但硬件上漏掉它你的示波器就只能看正电压。前端元件的选型参数我列一个参考表按 0.5V/div 到 2V/div 三档档位满量程分压比R1输入侧R2接地侧输入阻抗2V/div±20V10:190kΩ10kΩ100kΩ1V/div±10V5:180kΩ20kΩ100kΩ0.5V/div±5V4:175kΩ25kΩ100kΩ开关量程用单刀三掷的模拟开关或者旋转开关都行。如果只想快速搭一个能用的版本不做量程切换直接固定 0.5V/div后级靠电位器微调也完全够入门。这部分硬件不是标题源码直接覆盖的但源码里的 ADC 校准系数、量程档位表都是按这个前端结构设计的。3. 采集核心定时器触发 ADC 与 DMA 环形缓冲的源码骨架3.1 定时器触发用 TIM1 的 TRGO 把采样率稳住ADC 的采样率由定时器决定而不是 while 循环里翻转引脚。F103C6 的 ADC1 支持外部触发常见的触发源里 TIM1 的 TRGO 是最稳的一个。它的思路是TIM1 按 ARR 计数值定时产生更新事件更新事件通过 TRGO 输出给 ADCADC 收到触发后启动一次转换转换完成后 DMA 自动把结果搬到内存。配置 TIM1 的寄存器时有一个容易忽略的细节PSC 和 ARR 的计算必须把 ADC 时钟和 TIM1 的时钟源分开推算。TIM1 挂在 APB2 上当 APB2 分频系数为 1 时TIM1 时钟等于系统时钟 72MHz。要得到 100kHz 采样率ARR 设为 719PSC 设为 0。如果要把采样率降到 10kHzARR 设 7199。下面给出标准的 StdPerib 库写法void TIM1_Trigger_Config(uint32_t sample_rate_hz) { TIM_TimeBaseInitTypeDef TIM_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); TIM_InitStruct.TIM_Prescaler 0; // 72MHz 不分频 TIM_InitStruct.TIM_CounterMode TIM_CounterMode_Up; // 向上计数 TIM_InitStruct.TIM_Period (72000000UL / sample_rate_hz) - 1; TIM_InitStruct.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, TIM_InitStruct); TIM_SelectOutputTrigger(TIM1, TIM_TRGOSource_Update); TIM_Cmd(TIM1, ENABLE); }这段代码的逻辑是TIM1 每个计数周期溢出一次溢出事件通过 TRGO 送出去ADC 就触发一次。PSC 固定为 0相当于让 ARR 直接承担分频任务。注意 ARR 是 16 位寄存器最大值 65535所以最低采样率约为 1098Hz。如果你需要更低采样率比如 500Hz就必须让 PSC 参与分频否则 ARR 会溢出。实际调时基时我把档位划成 1kHz、2kHz、5kHz、10kHz、20kHz、50kHz、100kHz、200kHz、500kHz每一档单独算 ARR切换时基只改一个变量不复用同一套 PSC。3.2 DMA 环形缓冲10KB 内存下的预触发方案DMA 的作用是让 ADC 转换结果形成一条持续流动的数据河CPU 只在需要绘制波形时去河里取一段。不要开启 DMA 传输完成中断做波形处理否则高采样率下中断频率高得吓人主循环被拖死。我习惯的做法是DMA 配置成循环模式设置一个环形缓冲区在任意时刻用软件扫描最新一屏数据。环形缓冲的容量我定为 2048 个 uint16_t正好 4KB。DMA 配置如下#define SAMPLE_BUF_SIZE 2048 uint16_t adc_ring_buf[SAMPLE_BUF_SIZE]; void ADC1_DMA_Config(void) { DMA_InitTypeDef DMA_InitStruct; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStruct.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStruct.DMA_MemoryBaseAddr (uint32_t)adc_ring_buf; DMA_InitStruct.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStruct.DMA_BufferSize SAMPLE_BUF_SIZE; DMA_InitStruct.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStruct.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStruct.DMA_Mode DMA_Mode_Circular; DMA_InitStruct.DMA_Priority DMA_Priority_High; DMA_InitStruct.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStruct); DMA_Cmd(DMA1_Channel1, ENABLE); }注意 DMA_PeripheralDataSize 必须是 HalfWord因为 ADC 的 12 位结果在寄存器里以半字存储如果配成 ByteDMA 会把高字节低字节拆开采样数据全乱。DMA_Mode 配成 Circular 后DMA 写满 2048 个点自动回卷覆盖最老的数据这个特性是预触发的基础。CPU 侧通过读取 DMA1_Channel1-CNDTR 寄存器获取当前写位置这个值指向 DMA 即将写入的下一个位置也就是环形缓冲的最新边界。3.3 触发电平与边沿在软件里找回触发点F103C6 内部没有硬件比较器无法像专用示波器那样由模拟比较器产生触发信号。所以触发判定放在软件里从环形缓冲区的最新位置往前找一段数据找到跨越触发电平并且方向符合边沿选择的采样点以该点为窗口起点绘制波形。判断上升沿的核心代码如下uint16_t trigger_level 2048; // 1.65V 对应 ADC 值约 2048 uint8_t rising_edge 1; int32_t find_trigger_point(uint16_t *buf, int32_t start_idx, int32_t search_len) { int32_t i; int32_t idx; // search_len 是往回找的距离通常设为一屏点数再加预触发量 for (i 0; i search_len - 1; i) { idx (start_idx - i SAMPLE_BUF_SIZE) % SAMPLE_BUF_SIZE; int32_t prev_idx (idx - 1 SAMPLE_BUF_SIZE) % SAMPLE_BUF_SIZE; if (rising_edge) { if (buf[prev_idx] trigger_level buf[idx] trigger_level) { return idx; } } else { if (buf[prev_idx] trigger_level buf[idx] trigger_level) { return idx; } } } return -1; // 没找到返回 -1上层走自动触发 }这个函数是软件触发里最核心的部分。start_idx 取 DMA 当前写位置search_len 取缓冲长度减去预触发余量找到的点往前推一屏就是显示窗口。两个细节值得注意一是环形缓冲的下标回绕必须用取模而不是条件判断因为写入位置可能正好越过数组边界二是触发电平要加 10 到 20 个 LSB 的迟滞否则噪声在电平附近反复穿越触发点提前或延迟波形在屏幕上左右横跳。迟滞的做法是上升沿查找时用“低于电平-迟滞”作为起点条件跨过“高于电平迟滞”才算触发不会因为一个毛刺就乱跳。找不到触发点时要回退到自动触发模式直接取最新一屏数据绘制并且屏幕左上角绘制一个“AUTO”标识。这是模拟示波器扫描和数字示波器 roll 模式的差别很多源码把找不到点直接画空白用户会以为示波器坏了。4. 显示与交互LCD 刷新与旋钮响应的 Keil5 工程组织4.1 波形绘制把 ADC 数据变成一条不闪的曲线LCD 用 SPI 接口的 240x320 屏是 F103C6 最稳妥的选择因为 C6 没有 FSMC 外设并口屏接不了。SPI 刷屏本身不快所以优化重点放在减少无效写入上不整屏清屏而是只画一组静态网格和一条波形曲线。网格先画好存在画布数组里每次刷新只把新波形覆盖旧波形的位置不动的部分不写。绘制波形前先把 ADC 原始值映射到屏幕坐标。ADC 是 12 位0 到 4095 对应 0V 到 3.3V屏幕垂直分辨率 240 像素映射公式y 240 - (adc_value * 240 / 4096)。这里的反方向是为了让波形向上画因为 LCD 的 y 轴向下增长。水平方向一屏取 240 个采样点从左到右画点即可。void lcd_draw_waveform(uint16_t *buf, int32_t start_idx, uint16_t x_offset) { int32_t i; uint16_t y_prev; uint16_t pixel_x, pixel_y; int32_t idx; // 前一个点坐标用于连线 idx start_idx; y_prev 240 - (buf[idx] * 240 / 4096); for (i 0; i DISP_WIDTH; i) { idx (start_idx i * DISP_SCALE) % SAMPLE_BUF_SIZE; pixel_x i; pixel_y 240 - (buf[idx] * 240 / 4096); lcd_draw_line(pixel_x, pixel_y, pixel_x - 1, y_prev, LCD_COLOR_CYAN); y_prev pixel_y; } }DISP_SCALE 是抽点间隔由当前采样率和屏幕宽度共同决定。比如采样率 600kHz屏幕宽度 240如果把全部 600 个点全画出来每根竖线上会重叠 2.5 个点曲线变成一团。所以按 DISP_SCALE sample_rate / (240 * sweep_freq) 计算保证一屏恰好是对应时基的完整波形周期数。绘制时先画网格再画波形最后再画触发位置的水平箭头层序不要反。刷新策略上我第一次做这个项目时把整屏 SPI 全写帧率只有 6fps肉眼可见闪烁这是最大的翻车点。后来改成“双缓冲 脏矩形”思路维护一个 240x240 的画布数组波形变化只更新受影响像素LCD 只写变化行帧率提到 18fps 左右。对于 240x320 的 SPI LCDSPI 时钟提到 18MHz 是安全的再高就要看具体屏的驱动能力C6 的 SPI 外设最高可以跑 36MHz但不是每块屏都扛得住。4.2 参数设置时基、量程、触发电平旋钮怎么分配引脚交互部分最少需要三个输入时基调大调小、触发电平上下、触发模式切换。F103C6 的引脚资源不富裕所以优先用旋转编码器而不是多个按键。一个 EC11 编码器接 PA0/PA1 两个引脚配合内部上拉就能同时完成“旋转调时基”和“按下切换触发模式”两件事。触发电平我用另一个 EC11 或者直接用 ADC 电位器采样。引脚分配按功能集中放置方便改线功能引脚配置ADC 输入PA4模拟输入触发电平电位器PA5模拟输入EC11_A / EC11_BPA0 / PA1上拉输入EC11 按键PA2上拉输入LCD SCK / MOSI / CS / DCPB3 / PB5 / PB6 / PB7复用推挽USB D / D-PA11 / PA12复用时基档位切换时要连带修改 TIM1 的 ARR不能只在显示层缩放。软件里维护一张档位表每一项包含采样率、ARR 预装载值、每屏点数typedef struct { uint32_t sample_rate; uint16_t arr; uint16_t points_per_div; } timebase_t; const timebase_t tb_table[] { { 500000, 143, 500 }, // 100us/div { 200000, 359, 200 }, // 250us/div { 100000, 719, 100 }, // 500us/div { 50000, 1439, 50 }, // 1ms/div { 20000, 3599, 20 }, // 2.5ms/div { 10000, 7199, 10 }, // 5ms/div };points_per_div 是调试时用的参考值实际绘制时根据屏幕宽度和触发电平修正。旋钮每转一格当前档位索引加一减一越界时停在边界并闪烁提示不要循环回绕否则调档位时容易失去方向感。编码器读取要在主循环里做消抖判断状态机记录上次的 A 相电平检测边沿后根据 B 相电平判断旋转方向。这个代码很老套但别用定时器中断里去读编码器F103C6 的中断延迟在这种场景下会让旋转方向判断出错浪费你可能一个晚上。5. 常见问题与排查示波器采样翻车的 5 个现场5.1 波形上下跳动地电平都稳不住先把参考电压排查了现象是输入悬空时屏幕上的波形还在小幅跳动短路输入端到 GND 后波形中心不在零点甚至出现负值。多数情况下是 VDDA 和 VREF 没有接好。F103C6 的 VREF 在 LQFP48 封装里和 VDDA 是同一个引脚,但很多最小系统板的 ADC 参考没有单独滤波。解决方法是在 VDDA 到 VREF 之间接一个 1μF 钽电容并联 100nF 陶瓷电容且紧贴芯片引脚如果板载 3.3V 是 DC-DC 出来纹波大再加一个 10Ω 电阻组成 π 型滤波。这个问题属于硬件但它的表现是“ADC 数据看起来不稳定”容易被误判成软件滤波不够。5.2 采样率标称 1MSPS信号频率一高就走样ADC 全速 1.5 周期采样看着输出波形还能接受但拿它去测一个 50kHz 的方波沿变得模模糊糊。原因是 ADC 时钟 14MHz、1.5 周期采样的有效位数会掉到 9 位左右量化噪声直接反映在波形上同时采样间隔抖动也因为触发源时钟分配不均匀而出现。解决的思路是降低采样率把 ADC 时钟降到 12MHz采样周期设为 7.5实际采样率降到约 650kSPS但有效位恢复到 11 位。示波器的指标从来不是“ADC 最大采样率”而是“在某个采样率下还能看清水纹波”所以别盲目追求最大采样率。5.3 Keil5 编译报错 declaration may not appear after executable statement把变量声明写在函数的可执行语句后面在 Keil5 的默认编译模式下是 C90 标准不允许这种写法。我在写触发函数时就踩过一次先调用了 DMA 读寄存器然后才声明 int32_t idx编译直接红一片。解决办法有两个一是把所有变量声明提到函数块开头二是打开 C99 模式。注意 Keil5 里 C99 模式在 Options for Target - C/C - Language / Code Generation 下拉框里选择但 C99 模式下部分库函数的隐式声明检查会放宽代码编译过了但运行时容易出诡异问题所以我在工程里坚持用 C90 声明风格让编译器帮我把问题挡在编译期。5.4 触发位置乱跳波形抓不住自动触发模式下波形能正常滚动切到上升沿单次触发后按下等待触发按钮波形随机停在一个位置和设定电平完全对不上。原因为环形缓冲的预触发量不够触发点落在缓冲边界之外软件找不到完整的一屏波形回退到自动模式了。解决方法是调整查找范围把 search_len 设到缓冲长度的 75% 而不是 50%留出预触发深度同时检查触发判断的延迟如果触发点刚好在旧数据里就等待 DMA 写入位置越过触发点后再取窗口。软件触发毕竟不是硬件比较器触发点的抖动正负一个采样周期是正常的不要太早怀疑代码。5.5 屏幕刷屏闪烁波形有拖影SPI 刷屏时网格、波形、刻度文字每次都重画帧率低时能看到明显的底色翻转过程。优化关键是分层刷新网格和坐标文字画一次存入显存波形刷新只对比新旧波形变化行单独 SPIDMA 写。F103C6 的 SPI 支持 DMA 传输把像素数据放到数组里SPI_I2S_DMACmd 使能 DMA 后CPU 可以回去继续处理旋钮和触发不卡刷新。但注意 SPI DMA 每次传输结束后要清 DMA 中断标志位否则下一次传输不启动。6. 进阶双通道同时采集与波形导出把 C6 用透6.1 用 ADC1 和 ADC2 同步规则模式实现真正的同时双通道F103C6 上的 ADC1 和 ADC2 可以组成双 ADC 同步规则组由主 ADC 的触发信号同时启动两个 ADC各自采样不同的通道得到的是同一时刻的两路波形而不是时分复用的两路数据。这对调试两个相关信号特别有用比如看 I2C 的 SCL 和 SDA 或者 PWM 的输入输出。配置上大致是把 ADC1 设为双 ADC 模式下的主模式ADC2 设为从模式调用 ADC_Init 时把 ADC_Mode 设为 ADC_Mode_RegSimultADC_ExternalTrigConv 让 ADC1 和 ADC2 都挂到 TIM1 的 TRGO。DMA 需要同时搬运两路结果数据格式是 ADC1 结果、ADC2 结果交替排列在内存中。ADC_InitStruct.ADC_Mode ADC_Mode_RegSimult; ADC_InitStruct.ADC_ExternalTrigConv ADC_ExternalTrigConv_T1_TRGO; ADC_InitStruct.ADC_ContinuousConvMode DISABLE; ADC_InitStruct.ADC_DataAlign ADC_DataAlign_Right; ADC_Init(ADC1, ADC_InitStruct); ADC_InitStruct.ADC_Mode ADC_Mode_RegSimult; ADC_InitStruct.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 从模式不配触发 ADC_Init(ADC2, ADC_InitStruct);双 ADC 模式会占用 DMA 的额外通道F103C6 的 DMA1 通道分配表中ADC1 的结果固定走通道 1,双 ADC 模式建议用 DMA 的通道 1 以 32 位宽度读取这样一次 DMAX 传输同时拿到两个结果。把 ADC 数据和触发点对应关系写对之前先各画各的验证通道差异不超过 1 个 LSB然后再合并显示。同步双通道的代价是采样率维持不变但换来的是时序一致性,值得。6.2 波形导出通过 USB 虚拟串口把采样数据交给上位机F103C6 的 USB 2.0 全速设备控制器可以做成虚拟串口把波形数据传给上位机。这里有一个坑USB 需要使用外部晶振或者直接由外部 48MHz 时钟输入内部 8MHz 晶振直接跑 USB 是不行的时序根本过不了枚举。我在这条路上折腾过后来加了 8MHz 外部晶振才看到上位机弹串口枚举成功的提示。USB CDC 类设备的描述符和收发逻辑固定工程里不算实现复杂但工作量集中在回调函数上。如果你只有 32KB Flash放一套波形采集加 LCD 加 CDC 描述符会有点紧张需要把优化等级提到 -O2。数据发送不要用 printf直接把 ADC 值打包成带时间戳的二进制帧上位机按固定长度解析更可靠。示波器实时数据量为 600k 字节每秒USB 全速实际吞吐约 1MBps完全够用。把这些原始数据拿到上位机后,还能做离线频谱分析这已经是把 C6 用到了边界的玩法也是这个方向真正值钱的地方。最后说个教训我第一次调双 ADC 时花了两天查数据错位最后发现是 DMA 缓冲区宽度没同步改还按单通道半个字配的结果两路数据的顺序交叉错乱。嵌入式里这种事很常见问题不在芯片在以为“改一个参数就行”的侥幸。做这个项目时每改一个配置先只验证那一层再叠加下一层。希望帮到你。本文还有配套的精品资源点击获取