
简介面向嵌入式学习者的STM32F103C6示波器完整工程源码包基于Keil5开发环境以简单示波器原型演示模拟信号采集、传输与上位机通信的完整链路。工程采用ADC配合DMA高效采集模拟电压再通过USB HID协议实时发送至PC适合学习ARM Cortex-M3外设驱动、低开销数据传输及USB协议开发。压缩包约11.72MB共969个文件以C源文件572个、头文件257个为主另含汇编文件、Keil工程配置、中间件库及说明文档驱动、源码、配置文件分目录存放便于按模块检索与调试。已有922人学习下载。通过阅读和修改源码可深入理解ADCDMA配置、HID枚举与数据上报流程也能基于中间件和驱动代码进一步扩展滤波、存储或波形分析功能是嵌入式信号采集项目较实用的参考范例。1. 一块 10 块钱的 F103C6 能做示波器一个工程理清采样、触发与 Keil5 移植STM32F103C6 这片子常被认为是“入门到精通”的应收尾项目可真拿它做一台能看波形的简易示波器时不少人才发现引脚不够、内存太小、DMA 与 ADC 的配合各种玄学。这份资源是一个完整 Keil5 工程目标芯片是 STM32F103C6主频 72MHz内置 10 位 ADC、最多 3 个定时器外加上位机串口协议就能在 PC 上实时绘制信号波形。它适合两类人一是课程设计需要“从零攒一个采集系统”的在校生二是想验证 ADC 多通道扫描、DMA 环形缓冲、定时器触发这些底层机制的在职嵌入式工程师。接下来我从原理讲到移植再把触发抖动的坑一个个拆开保证你拿到工程后一下午就能跑出波形。2. 采样架构ADC、DMA 与定时器触发为什么顺序不能乱2.1 先把 F103C6 的采样瓶颈说透F103C6 内置的 ADC 是 12 位逐次逼近型但这份资料里默认配置成 10 位精度来换取更高采样率。最大 ADC 时钟是 14MHz采样周期最短 1.5 个周期因此理论上单次转换最快约 1us也就是 1MSPS 左右。实际工程里不会跑到极限因为你要同时处理串口发送和波形显示CPU 没空等转换结果。正确的采集链路是定时器产生触发信号ADC 在触发边沿启动转换转换完成后由 DMA 把结果搬到内存缓冲区CPU 只在缓冲区半满或全满时介入。资料里把 DMA 设置为循环模式配合一个 2KB 的数组这样 ADC 可以持续采样而不丢数据。我拿到工程后第一件事就是把ADC_ContinuousConvMode这个参数确认好——如果是单次转换模式DMA 只搬运一轮就停了波形根本不动。/* ADC 初始化关键参数 */ ADC_InitStructure.ADC_Mode ADC_Mode_Independent; // 独立模式 ADC_InitStructure.ADC_ScanConvMode DISABLE; // 单通道扫描 ADC_InitStructure.ADC_ContinuousConvMode DISABLE; // 由定时器触发 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_CC2; // 定时器2的CC2触发 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; // 右对齐 ADC_InitStructure.ADC_NbrOfChannel 1; // 采样通道数这里第 4 行是关键DISABLE意味着 ADC 不是自由运行模式而是等待定时器事件。如果你把这里改成ENABLE采样率将由 ADC 时钟直接决定定时器触发就失效了示波器时基将无法控制。外部触发源选的是T2_CC2也就是定时器 2 的捕获/比较通道 2对应脉冲宽度调制输出模式。每次定时器计数到达比较值时产生一个触发信号ADC 启动一次转换。2.2 DMA 环形缓冲为什么半满中断比全满中断更顺手数据搬运走 DMA 后CPU 要做的是在“合适的时机”取走数据并打包发送。资料里用了DMA_IT_HT半传输和DMA_IT_TC传输完成两个中断听起来合理但实际调试时发现半满中断更实用。void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) // 缓冲区前半部分已满 { DMA_ClearITPendingBit(DMA1_IT_HT1); ProcessBuffer(0, ADC_BUFFER_SIZE / 2); // 处理前半段 } if (DMA_GetITStatus(DMA1_IT_TC1)) // 缓冲区全部填满 { DMA_ClearITPendingBit(DMA1_IT_TC1); ProcessBuffer(ADC_BUFFER_SIZE / 2, ADC_BUFFER_SIZE); // 处理后半段 } }中断里只做取数和标志位设置真正的波形处理放到主循环。原因是 F103C6 的 Flash 只有 32KB中断函数写复杂了很容易超过 64KB 的 RAM 限制而串口发送又是阻塞的放在中断里会让采样循环卡顿。另一个原因是 DMA 循环模式下缓冲区是首尾相接的如果 CPU 处理速度跟不上 DMA 写入速度新旧数据会混在一起波形会变成一片噪声。这种资源里常见的处理方式是ProcessBuffer内部只做一次 memcpy 到临时数组随后用标志位通知主循环。提示不要把波形缩放、光标测量这类计算放进 DMA 中断里。F103C6 的嵌套向量中断控制器优先级分组默认是抢占优先级 2 位、子优先级 0 位中断里耗时超过 100us 就可能丢 DMA 半满中断。2.3 采样率与时基的换算触发分频的底层逻辑资料里示波器界面上有时间格比如 1ms/div这个值不是随便填的。假设屏幕显示 10 格每格 50 个采样点那么一屏总共 500 点。如果时基是 1ms/div那么 10 格就是 10ms采样率需要 500 点 / 10ms 50kSPS。定时器 2 的触发频率就要设置成 50kHz。/* 定时器2触发频率 72MHz / (PSC1) / (ARR1) */ TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // 72 分频得到 1MHz TIM_TimeBaseStructure.TIM_Period 20 - 1; // 1MHz / 20 50kHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up;这是一种“固定屏幕点数反推触发频率”的设计思路。很多新手把定时器频率设成最高比如 1MHz结果一屏只采到几个点波形根本看不出形状。资料里的默认配置是 1ms/div 对应 50kSPS每格 50 点画出来的正弦波 10 个周期左右眼睛看着舒服。要改时基的话只需联动修改TIM_Period和上位机的“每格时间”参数二者必须严格匹配否则波形水平方向会被拉伸或压缩。3. Keil5 工程结构拆解文件分工、启动配置与下载烧录3.1 拿到压缩包后第一步核对启动文件与芯片型号这个工程是基于标准外设库StdPeriph_Lib的不是 HAL 库。所以你在 Keil5 里打开工程后第一件事是检查Device选项。选择的是STM32F103C6如果误选成STM32F103C8也能编译过但 Flash 容量识别会错误下载时可能提示校验失败。启动文件startup_stm32f10x_md.s对应中等密度产品C6 属于中等密度不要换成hd.s高密度或ld.s低密度。标准外设库版本建议使用 V3.5.0工程文件里自带的库函数stm32f10x_adc.c、stm32f10x_dma.c、stm32f10x_tim.c等文件都已按 C6 裁剪过不需要额外调整。真正要自己配置的是全局宏定义STM32F10X_MD和USE_STDPERIPH_DRIVER。如果你在 C/C 选项卡里漏了这两个宏定义编译器会直接报stm32f10x.h内部错误因为芯片类型没有提前声明。/* 在 stm32f10x_conf.h 中开启需要的模块 */ #define _ADC #define _DMA #define _TIM #define _GPIO #define _USART这几个宏看起来是“可选的”实际上缺一个对应外设的函数就会被编译进工程但初始化时外设时钟不会打开现象诡异比如 ADC 读出来一直是 0DMA 中断不触发。我习惯把stm32f10x_conf.h打开后全部保留反正 C6 的 Flash 写不满编译时间也不差这几秒。3.2 从零移植的关键步骤照抄还是重构三条建议如果不想直接用原工程而是往自己的模板里加功能顺序建议是先跑通串口打印再配置 DMA 中断最后接 ADC 采样。反过来会非常崩溃——DMA 数据全是乱的你根本不知道是串口发错还是 ADC 采样有问题。假设你已经有一个能点灯的最小工程加入示波器功能的步骤如下# 第 1 步复制标准外设库文件到工程目录以 V3.5.0 为例 # 将 Libraries/STM32F10x_StdPeriph_Driver/src 下需要的 .c 文件加入工程分组 # 需要的文件列表 # stm32f10x_adc.c # ADC 外设驱动 # stm32f10x_dma.c # DMA 控制器驱动 # stm32f10x_tim.c # 定时器触发源 # stm32f10x_usart.c # 串口发送波形数据到上位机 # stm32f10x_gpio.c # 引脚复用配置 # stm32f10x_rcc.c # 时钟使能这个必须有# 第 2 步在系统时钟初始化后手动开启外设时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);这三条时钟打开语句缺一不可。F103C6 的外设时钟默认是全关的你如果只开了 GPIO 而忘了 DMA 时钟程序不会报错但 DMA 请求永远得不到响应ADC 转换完成标志会一直卡在等待状态。排查这类问题我一般是先用调试器查看RCC-APB2ENR寄存器的值确认对应位是否为 1这一步能排除八成“玄学故障”。3.3 上位机协议串口帧格式与波形对齐资料包里除了 Keil5 工程还附带一个 PC 端上位机程序。串口默认波特率 921600数据帧格式是帧头两个字节0xAA 0x55随后是 500 个 int16 采样点每点低字节在前。上位机按这个格式解析后绘制波形。/* 串口发送一帧波形数据 */ void SendWaveform(uint16_t *buffer, uint16_t len) { uint8_t frame_header[2] {0xAA, 0x55}; USART_SendData(USART1, frame_header[0]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, frame_header[1]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); for (uint16_t i 0; i len; i) { USART_SendData(USART1, buffer[i] 0xFF); // 低字节 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (buffer[i] 8) 0xFF); // 高字节 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); } }这段代码有几个细节值得注意USART_FLAG_TXE表示发送数据寄存器为空但发送移位寄存器可能还在工作如果紧接着修改数据会造成帧内字节错乱。更稳妥的做法是等USART_FLAG_TC发送完成再发下一字节但那样会严重拉低吞吐量。资料里用的是 TXE意味着最后一字节可能还没发完下一帧帧头就来了上位机偶尔会出现波形闪烁或错位。弥补办法是上位机端做帧同步——扫描帧头0xAA 0x55如果后面跟的数组长度不对就丢弃重找帧头。提示不要试图在 F103C6 上同时实现“边采样边串口全速发送”再加一个 OLED 刷新。72MHz 主频和 20KB RAM 撑不住三线并行常见做法是采样时暂停发送发送时暂停采样用开关切换。资料工程里用了一个全局标志 g_uart_busy 来实现这个互斥。4. 避坑与排查从波形乱跳到大面积噪声四条血泪经验4.1 现象波形幅值只有实际的一半原因ADC 配置成了 12 位但上位机按 10 位做归一化显示。如果 F103C6 的 ADC 用的是 12 位精度满量程值是 4095而上位机绘图区按 1024 的满量程缩放波形高度就只有一半。解决把 ADC 的ADC_Res_10b改成ADC_Res_12b或者上位机程序里把归一化分母改成 4095。资料里默认是 10 位和 1024 匹配但有人为了“提升分辨率”改动过此处后没同步上位机就出现了这个翻车现场。4.2 现象DMA 中断进不去波形卡在最后一段原因DMA 中断标志不清除或者中断优先级不够。F103C6 的中断优先级分组如果设置成NVIC_PriorityGroup_2DMA 通道 1 的抢占优先级要在串口之前否则串口中断长时间占用DMA 半满中断被延迟甚至丢弃。排查步骤是先看调试器里DMA1-ISR的HTIF1位是否一直为 1——如果是说明中断确实产生了但没进入处理函数。解决确保NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1而串口设置为 2。4.3 现象接上真实信号后波形有大量毛刺原因ADC 输入引脚没有做 RC 滤波信号源内阻较高时采样电容的电荷注入会形成毛刺。最常见的是直接把函数信号发生器输出端用杜邦线接到 PA1毛刺满天飞。解决在 PA1 对地并一个 100nF 陶瓷电容串一个 1kΩ 电阻构成低通滤波。但注意这个 RC 截止频率约 1.6kHz用来测 50Hz 市电没问题测 100kHz 方波就会严重失真。资料里没有装滤波电路这是硬件层需要自己补的不要以为软件改个参数就能消除毛刺。4.4 现象串口助手收到的数据全是AA 55重复原因接线错误或波特率不符。F103C6 的 USART1 默认引脚是 PA9TX和 PA10RX如果你用了板载 USB 转串口芯片要看跳线帽是否把 PA9 连到了 RX 端对 USB 转串口芯片而言它是 RX不是 TX。这属于硬件接反问题和软件无关。另外如果上位机显示波形“分裂成两半”多半是串口接收缓冲不足比如串口助手一次性收 1004 个字节但接收缓冲区只有 512 字节数据被截断。解决串口助手设置里把接收缓冲区调到至少 2048 字节。4.5 现象下载程序后芯片锁死无法重新烧录原因GPIO 配置把调试端口占用了。F103C6 的 PA13/PA14 默认是 SWDIO/SWCLK如果初始化代码里把这两个引脚复用成普通 GPIO 并输出低电平就会导致调试器连不上。解决用串口 ISP 模式擦除。把 BOOT0 拉高、BOOT1 拉低复位后使用串口下载工具把整个 Flash 擦除再跳回 BOOT0 低电平重新用 ST-Link 下载。资料工程里没有动这两个引脚但如果你扩展了按键扫描要注意别把 PA13/PA14 加进去。5. 进阶玩法把单通道示波器改成双通道与触发模式扩展5.1 双通道采样DMA 多通道扫描的正确配置F103C6 只有一个 ADC 模块但可以通过多通道扫描实现“伪双通道”。把 PA0 和 PA1 分别接到通道 0 和通道 1启用扫描模式DMA 搬运时数据会交替存储。ADC_InitStructure.ADC_ScanConvMode ENABLE; // 开启扫描 ADC_InitStructure.ADC_NbrOfChannel 2; // 两个通道 /* 设置两个通道的采样顺序和采样时间 */ ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_239Cycles5);注意两个通道的采样时间用 239.5 周期是为了保证两个通道间隔足够稳定减少串扰。开启扫描模式后DMA 缓冲区里的数据是 [ch0, ch1, ch0, ch1, ...]上位机需要按奇偶位置拆出两个波形。这是“时间共享”方案采样率会减半因为一个触发周期内要完成两次转换。其实 F103C6 没有第二个 ADC 硬件做不到真正的同时采样如果你的信号是正弦波这种周期信号相位差影响可以忽略。5.2 触发模式上升沿触发到底怎么写简单示波器一般没有触发功能波形会水平乱跑。加触发需要硬件比较器或者软件找阈值。资料工程用的是软件触发即在缓冲区里查找幅值从低于阈值到高于阈值的点然后把该点作为波形的起点。/* 软件上升沿触发找到第一个超过阈值的采样点 */ int32_t FindTriggerEdge(int16_t *buffer, uint16_t len, int16_t threshold) { for (uint16_t i 1; i len; i) { if (buffer[i - 1] threshold buffer[i] threshold) { return i; // 返回触发点的索引 } } return -1; // 未找到保持当前波形不动 }FindTriggerEdge返回的是一个索引值上位机拿到后把这段数据从索引处截断并循环移位视觉上波形就“停住”了。实际调试中发现阈值应该设置为信号幅值的 50% 左右比如正弦波峰峰值 3.3V阈值设 1.65V触发点在最陡峭的位置波形看起来最稳定。阈值设得太高或太低会导致触发抖动波形左右晃动。这个函数虽然是软件实现的但配合串口帧率 50fps 左右效果已经很接近硬件触发了。5.3 时基滚轮动态调整采样率的 UI 技巧资料的上位机界面有一个“时基”滑动条从 100us/div 到 100ms/div。底层实现是每档对应一组TIM_Prescaler和TIM_Period组合。我建议不要用公式实时计算 TIM 寄存器值而是预置一张表/* 时基-定时器参数映射表 */ typedef struct { uint16_t prescaler; uint16_t period; uint16_t sample_rate; // 用于上位机显示 } TimeBaseEntry; const TimeBaseEntry time_base_table[] { {72 - 1, 1 - 1, 1000000}, // 100us/div {72 - 1, 4 - 1, 200000}, // 500us/div {72 - 1, 20 - 1, 50000}, // 1ms/div {720 - 1, 20 - 1, 5000}, // 10ms/div };这样避免运行时做浮点除法F103C6 没有 FPU浮点运算会让定时器更新出现几十微秒的延迟导致采样不均匀。表驱动的方式还方便你后期增加档位——只需要在数组里加一行同时在上位机下拉框里加一个字符串就行。6. 把工程改造成数据记录仪串口无限流存储到 SD 卡示波器做完以后它的采集链路其实是一套完整的高速数据采集系统。我拿到这个资源后第一个改造就是砍掉实时波形显示把数据存到 SD 卡里做长时间记录。改造点集中在三处关闭上位机实时发送、把 DMA 缓冲区加大到 8KB、加入 FATFS 文件系统写入。/* 将定时器触发频率降到 1kHz每秒钟产生 1000 次 ADC 转换 */ TIM_Prescaler 720 - 1; // 72MHz/720 100kHz TIM_Period 100 - 1; // 100kHz/100 1kHz /* 主循环里每攒够 512 字节写一次 SD 卡 */ if (dma_half_complete_flag || dma_full_complete_flag) { f_write(file, dma_buffer offset, 512, written); offset (offset 512) % DMA_BUFFER_SIZE; }这里有几个关键点要注意SD 卡写入是耗时操作F103C6 用 SPI 模式驱动 SD 卡单次 512 字节写入耗时约 2~5ms。这个时间窗口内 DMA 仍在后台采样如果采样率太高比如 50kSPS每毫秒会产生 50 个采样点2ms 内就积压 100 个点8KB 缓冲区能扛一阵但持续写入 4KB/s 时理论吞吐能跟上实际因为写放大和 FAT 表更新速度会掉到 2KB/s 左右。我的经验是采样率降到 2kHz 以下记录时长能做到几十分钟不丢数据。如果你需要更高的记录速率想办法压缩——把 int16 转成 uint8舍弃低字节数据量直接减半多数传感器信号并不需要 10 位完整精度。另一个容易踩的坑是 SD 卡初始化时 SPI 时钟频率不能太高。F103C6 的 SPI 外设最大 18MHz但 SD 卡上电后默认工作在低速率模式需要用 400kHz 以下完成初始化再切换到高速模式。很多人直接开 18MHz 导致卡初始化失败返回FR_NOT_READY。资料里没有涉及这部分但如果你照着公共库的模板写八成会遇到这个问题。改造完成后的验证方法很简单插上 SD 卡记录一段时间拔卡插到电脑上用 Python 解析 16 进制数据画图。# 解析记录文件并绘制波形使用 Python matplotlib import struct import matplotlib.pyplot as plt with open(record.bin, rb) as f: data f.read() # 每两个字节为一个小端 int16 采样点 samples struct.unpack( h * (len(data) // 2), data) plt.plot(samples) plt.xlabel(Sample Index) plt.ylabel(ADC Value (10-bit)) plt.show()如果能画出连续平滑的波形说明这套采集链路没有问题如果曲线有周期性缺口多半是 SD 卡写入阻塞了 DMA 半满中断处理。从那以后我每次改完采集逻辑都要强制走一遍“故意填满缓冲区再写入”的压力测试确认没有数据覆盖才敢继续调别的地方。希望这篇拆解能帮你少浪费一个调 VLAN——哦不是少浪费一个调示波器的周末也希望你在改动触发逻辑时能和我一样把“查寄存器状态”当作第一反应直接看DMA1-ISR和ADC1-SR比盲改参数省事得多。本文还有配套的精品资源点击获取