STM32F103C8T6打造简易示波器:ADC采样与TFT显示实战

发布时间:2026/11/4 8:27:29
STM32F103C8T6打造简易示波器:ADC采样与TFT显示实战 简介这份资源是面向嵌入式初学者与电子竞赛备赛者的STM32F103C8T6简易示波器完整工程Demo围绕ADC采样、定时器触发、串口传输与波形显示等核心环节帮助读者理解如何用Cortex-M3内核微控制器搭建一套可运行的数字示波器原型适合具备一定C语言与单片机基础、希望从理论走向实操的学习者。压缩包共298个文件、约8.56MB以c与h源码、Keil工程文件uvprojx、uvoptx、编译中间产物o、d、crf及hex、axf固件为主另含少量sct分散加载、bat脚本与map映射文件工程结构完整可直接导入IDE编译调试。目前已有2916人学习下载说明该案例在同类项目中具备较高参考价值。读者可从中获得ADC与定时器协同配置思路、串口数据上传与上位机绘图链路、硬件输入电路与抗干扰设计要点以及基于仿真器与日志的调试排错经验便于二次开发与课程设计复用。1. 一块十几块钱的蓝板子怎么就成了桌面上的简易示波器手头只有一块 STM32F103C8T6 最小系统板想测个 PWM 占空比、看个传感器输出的波形又不想搬出几千块的台式示波器——这个 demo 就是为这种场景准备的。它把 ADC 采样、定时器触发、TFT 屏幕刷新串成一条完整链路让这块 48 脚的小芯片变成一个能看波形的简易示波器。适合谁适合刚学完外设驱动、想找一个「能把多个模块串起来」的练手项目的嵌入式开发者也适合需要低成本现场调试工具的一线人员。整个项目不依赖复杂算法库核心就是采样时序和屏幕刷新的配合代码量可控改起来心里有底。2. 技术选型与整体架构为什么是 F103C8T6 加 TFT 这条路线2.1 主控与显示的组合逻辑STM32F103C8T6 是 Cortex-M3 内核主频 72MHz64KB Flash、20KB SRAM片上带 2 个 12 位 ADC、多个通用定时器。做简易示波器最吃紧的不是算力而是内存和采样带宽。12 位 ADC 单通道在规则组连续转换模式下配合 DMA 搬运能跑到接近 1Msps 的理论上限实际受限于 ADC 时钟分频稳定在几百 ksps 是现实的。显示部分常见做法是 1.8 寸或 2.4 寸 SPI TFT分辨率 128×160 或 240×320SPI 接口占用引脚少刷一屏波形的时间在几十毫秒量级对「看个大概波形」够用。选这条路线的原因很直接成本低、资料多、引脚够。F103C8T6 最小系统板十几块SPI TFT 模块二十块上下加起来不到一顿饭钱。代价是采样深度有限20KB SRAM 里要分出一块给 ADC 缓冲区再留出屏幕显存或直接写屏缓冲区做 1024 点、每点 2 字节就是 2KB剩下的空间还算宽裕。如果换成并口屏幕或者带显存的方案引脚和内存都会紧张所以 SPI 直写是更务实的选择。2.2 模块划分与数据流整个项目按职责拆成四块采样前端、触发与缓冲、波形渲染、人机交互。数据流是单向的——ADC 在定时器触发下采样DMA 把结果搬进环形缓冲区主循环检测到一帧采满后做触发判断满足条件就把缓冲区数据映射成屏幕坐标画出来同时更新频率、幅值等参数显示。/* 采样链路的核心配置定时器触发 ADC DMA */ void scope_sample_init(void) { /* TIM2 作为触发源更新事件驱动 ADC 启动转换 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef tim; tim.TIM_Prescaler 72 - 1; /* 72MHz / 72 1MHz 计数频率 */ tim.TIM_Period 100 - 1; /* 1MHz / 100 10kHz 采样率 */ tim.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); /* 更新事件作为 TRGO */ TIM_Cmd(TIM2, ENABLE); /* ADC1 通道 0外部触发来自 TIM2_TRGO */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef gpio; gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, gpio); ADC_InitTypeDef adc; ADC_StructInit(adc); adc.ADC_ContinuousConvMode DISABLE; /* 单次转换由触发源启动 */ adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; ADC_Init(ADC1, adc); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); /* DMA1 通道 1 搬运 ADC1 数据到缓冲区 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitTypeDef dma; DMA_StructInit(dma); dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)adc_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize ADC_BUF_SIZE; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; /* 环形模式持续覆盖 */ DMA_Init(DMA1_Channel1, dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE); }这段代码里几个参数决定了示波器的「手感」。TIM_Prescaler和TIM_Period共同决定采样率上面配置是 10kHz改TIM_Period就能调档比如改成 10 就是 100kHz。DMA_Mode_Circular让缓冲区自动循环填充主循环只需要看 DMA 的计数器或者用半满/全满中断来判断一帧是否采完。ADC_ContinuousConvMode DISABLE是关键如果开成连续转换定时器触发就失去意义采样率会变成 ADC 自己能跑多快就多快波形时间轴就乱了。这是新手最容易翻车的地方之一。2.3 触发机制与缓冲区设计示波器能稳定显示波形靠的是触发。没有触发每帧波形起始相位随机屏幕上就是一团左右晃动的线。这个 demo 的触发逻辑放在软件层一帧采满后从缓冲区里找第一个满足「上升沿穿越触发电平」的点把该点作为绘制起点这样波形就「钉」住了。/* 软件触发在缓冲区中寻找上升沿穿越触发电平的位置 */ uint16_t scope_find_trigger(const uint16_t *buf, uint16_t len, uint16_t level) { for (uint16_t i 1; i len; i) { /* 前一点低于电平、当前点不低于电平判定为上升沿 */ if (buf[i - 1] level buf[i] level) { return i; /* 返回触发点索引作为绘制起点 */ } } return 0; /* 未找到触发退化为从 0 开始绘制 */ }level是触发电平通常取 ADC 满量程的一半2048或者根据信号幅值动态调整。返回的索引作为绘制起点绘制时从该点开始取DRAW_POINTS个点映射到屏幕横坐标。缓冲区长度ADC_BUF_SIZE一般取 1024 或 2048取 1024 时一帧 10kHz 采样对应 102ms 的时间窗屏幕上横轴就是这 102ms。如果信号频率较高一帧里周期数太多波形会挤成一团这时候要么提高采样率、要么缩短绘制点数本质是调整「时基」。3. 从零跑起来工程搭建、烧录与屏幕点亮3.1 工程结构与依赖项目按标准外设库SPL组织目录结构大致如下scope_demo/ ├── CMSIS/ # 内核头文件与启动文件 ├── STM32F10x_FWLib/ # 标准外设库源码 ├── User/ │ ├── main.c # 主循环与初始化调度 │ ├── scope.c/.h # 采样、触发、渲染核心逻辑 │ ├── tft.c/.h # SPI TFT 驱动ILI9341 或 ST7735 │ └── stm32f10x_it.c # 中断服务函数 ├── Hardware/ │ ├── adc.c # ADC DMA 配置 │ └── timer.c # 定时器触发配置 └── Project/ # Keil 或 Makefile 工程文件依赖只有标准外设库和屏幕驱动不需要 RTOS。如果手头是 HAL 库的工程把scope_sample_init里的 SPL 调用换成对应的HAL_ADC_Start_DMA、HAL_TIM_Base_Start即可逻辑不变。屏幕驱动部分常见做法是把tft.c里的初始化序列按实际屏幕型号替换ILI9341 和 ST7735 的初始化命令不同但画点、画线接口可以保持一致。3.2 编译与烧录步骤以 Keil MDK 为例打开Project下的工程文件确认器件选的是 STM32F103C8输出选项里勾选生成 hex。编译前检查两个地方一是stm32f10x_conf.h里是否使能了 ADC、DMA、TIM、SPI 对应的外设头文件二是启动文件是否匹配startup_stm32f10x_md.s中容量。烧录用 ST-Link接线是 SWDIO、SWCLK、GND、3.3V 四根Keil 的 Debug 设置里选 ST-Link DebuggerFlash Download 里勾选 Reset and Run。# 如果用 Makefile arm-none-eabi 工具链编译和烧录命令大致如下 make clean make # 生成 build/scope_demo.hex st-flash write build/scope_demo.hex 0x8000000 # ST-Link 烧录烧录后如果屏幕不亮先查背光引脚是否拉高再查 SPI 的片选、数据命令切换引脚是否接对。SPI TFT 模块的引脚命名各家不同常见是 CS、DC、RST、MOSI、SCK有的还带 MISO 但读显存用不上。接线错误是「屏幕全白或全黑」的头号原因用万用表通断档逐个确认比反复改代码快得多。3.3 第一次上电该看到什么正常上电后屏幕先清屏然后画出坐标网格和一条水平基线接着开始刷新波形。如果信号源接在 PA0 上比如函数发生器输出一个 1kHz 正弦波幅值 03.3V屏幕上应该能看到稳定的正弦波形顶部或底部有一行参数显示当前采样率和触发电平。如果波形左右滚动不停说明触发没生效检查scope_find_trigger的返回值是否被正确用作绘制起点如果波形是一条直线先确认 ADC 引脚上确实有信号再查 DMA 是否真的在搬运可以在调试器里看adc_buf的内容是否变化。提示第一次调试建议先把采样率设低比如 1kHz用信号发生器给一个 100Hz 方波波形在屏幕上占几个格子肉眼容易判断对错。等链路通了再往上调采样率。4. 核心代码拆解采样、触发、渲染三段链路4.1 采样时序定时器与 ADC 的配合细节采样率精度直接决定波形时间轴的可信度。用定时器 TRGO 触发 ADC 的好处是采样间隔由硬件保证不受主循环里其他任务影响。上面scope_sample_init里TIM_Prescaler 71、TIM_Period 99算下来更新频率是 72MHz / 72 / 100 10kHz。这里有个容易忽略的点TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update)必须在TIM_Cmd之前调用顺序反了触发信号出不来ADC 永远等不到启动事件缓冲区一直是初值。这种时序问题不会报错只会表现为「采不到数据」排查时容易怀疑到 ADC 配置上去。另一个细节是 ADC 的采样时间。ADC_RegularChannelConfig里要设ADC_SampleTime信号源内阻大时采样时间要拉长否则采样电容充不满读数偏低。常见做法是先用ADC_SampleTime_55Cycles5如果发现幅值明显偏小再往上调。ADC 时钟也不能超过 14MHzRCC_ADCCLKConfig(RCC_PCLK2_Div6)在 72MHz 主频下得到 12MHz是安全值。4.2 触发与帧同步让波形「站住」软件触发的实现上面给了核心函数实际使用时还要处理「触发点太靠后导致绘制点数不够」的情况。如果触发点索引接近缓冲区末尾从该点往后取不满一屏的点常见做法是回绕到缓冲区开头继续取或者干脆丢弃这一帧等下一帧。前者实现简单但波形首尾可能不连续后者更干净但刷新率略降。demo 里一般选回绕因为环形缓冲区本身就是循环的回绕取点符合直觉。/* 从触发点开始取点绘制处理缓冲区回绕 */ void scope_draw_wave(const uint16_t *buf, uint16_t len, uint16_t trig_idx) { uint16_t points SCOPE_DRAW_POINTS; /* 屏幕横向可绘制的点数 */ for (uint16_t x 0; x points; x) { uint16_t idx (trig_idx x) % len; /* 回绕取索引 */ uint16_t val buf[idx]; /* ADC 值 0~4095 映射到屏幕纵向像素注意 y 轴方向 */ uint16_t y SCOPE_Y_BOTTOM - (val * SCOPE_Y_RANGE) / 4096; tft_draw_pixel(SCOPE_X_START x, y, SCOPE_WAVE_COLOR); } }SCOPE_DRAW_POINTS要和屏幕宽度匹配128 宽的屏取 120 左右留边距。SCOPE_Y_RANGE是纵向可用的像素高度映射公式里先乘后除避免整数溢出。SCOPE_Y_BOTTOM是波形区的底边 y 坐标因为屏幕坐标原点在左上角ADC 值越大 y 越小所以用减法。这段代码每帧执行一次120 个点逐个画像素SPI 屏在 18MHz 时钟下刷一帧大约十几毫秒加上采样等待整体刷新率在 2030 帧每秒肉眼看着是连续的。4.3 渲染优化局部刷新与网格分离如果每帧都清屏重画网格闪烁会很明显而且浪费时间。常见做法是把网格画一次就不动了每帧只刷新波形区域——先用背景色把上一帧的波形「擦掉」再画新波形。擦除和绘制用同一套坐标映射保证覆盖区域一致。/* 局部刷新先擦旧波形再画新波形网格保持不动 */ void scope_refresh(const uint16_t *buf, uint16_t len, uint16_t trig_idx) { /* 用背景色重绘波形区相当于擦除上一帧 */ tft_fill_rect(SCOPE_X_START, SCOPE_Y_TOP, SCOPE_DRAW_POINTS, SCOPE_Y_RANGE, SCOPE_BG_COLOR); /* 重画网格线如果网格在波形区内 */ scope_draw_grid(); /* 绘制新波形 */ scope_draw_wave(buf, len, trig_idx); }tft_fill_rect用 SPI 连续写像素比逐点画快很多。网格如果和波形区重叠擦除后要补画所以scope_draw_grid放在填充之后。如果屏幕支持硬件滚动或者有显存可以做得更精细但 F103C8T6 的 RAM 不足以开全屏显存240×320×2 字节就是 150KB远超 20KB所以直写屏幕是唯一现实的选择。这也意味着刷新过程中屏幕会有短暂撕裂对「看个大概」的场景可以接受追求更好效果就得换带显存的方案或者降低刷新区域。5. 避坑与排查那些让波形不出来的常见问题5.1 现象屏幕有网格但波形是一条直线原因通常是 ADC 没采到数据或者 DMA 没启动。先查ADC_DMACmd和DMA_Cmd是否都调用了两者缺一不可再查ADC_ExternalTrigConvCmd是否使能外部触发不开的话 ADC 不会响应定时器事件。还有一个隐蔽的点ADC 上电后需要校准ADC_ResetCalibration和ADC_StartCalibration没做的话读数可能偏差很大甚至卡住。解决方法是初始化末尾补上校准流程并等待校准完成标志。5.2 现象波形左右晃动触发像没生效先确认scope_find_trigger的返回值确实传给了绘制函数而不是被局部变量覆盖。再检查触发电平是否落在信号幅值范围内——如果信号是 01V触发电平设成 2048对应 1.65V永远找不到穿越点函数返回 0波形自然不稳。解决方法是把触发电平做成可调或者根据缓冲区最大值最小值动态取中点。另外如果信号噪声大单点比较容易误触发可以加一个简单的滞回上升沿要求前一点低于level - hys、当前点高于level hys。5.3 现象提高采样率后波形变形或丢点采样率提高后DMA 搬运频率上升如果主循环里刷屏耗时太长可能出现「一帧还没画完下一帧已经采满」的情况环形缓冲区被覆盖画出来的波形是拼接的。解决方法是双缓冲开两块缓冲区DMA 填 A 的时候主循环画 B用半满/全满中断切换。F103C8T6 的 RAM 做两块 1024 点缓冲共 4KB没问题。另一个原因是 SPI 刷屏太慢可以降低绘制点数或者提高 SPI 时钟注意屏幕手册的上限。5.4 现象烧录后程序不运行或运行一次就停先查启动文件是否匹配容量中容量芯片用md后缀的启动文件用错会进 HardFault。再查SystemInit里的时钟配置外部晶振没起振时程序会卡在等待 HSERDY 的循环里表现就是「不运行」。如果板子上没焊晶振改用内部 HSI 也能跑但采样率计算要按 8MHz 重新算分频。还有一种情况是中断向量表偏移没设对如果用了 bootloader 或者程序起始地址不是 0x8000000要在main开头调NVIC_SetVectorTable。注意调试时善用调试器的「Live Watch」看adc_buf前几个元素是否在变比反复烧录猜问题快得多。缓冲区不变就查采样链路缓冲区在变但屏幕不对就查渲染链路二分定位能省很多时间。6. 二次开发把 demo 改成顺手的工具这个 demo 的骨架清晰改造空间主要在三个方向时基与幅值档位、触发模式、数据导出。时基档位就是把TIM_Period做成可切换的数组按键切换时重新配置定时器并更新屏幕上的时间刻度显示幅值档位对应纵向缩放改SCOPE_Y_RANGE或者在前端加运放切换增益。触发模式可以从「自动」扩展到「正常」和「单次」自动模式下找不到触发就从 0 画正常模式下等触发才刷新单次模式捕获一帧后停止。实现上就是给scope_find_trigger的返回值加个状态判断主循环里根据模式决定是否调用scope_refresh。数据导出是个实用改造。F103C8T6 有 USART把缓冲区数据按文本或二进制格式发到串口上位机存成 CSV 就能做离线分析。发送时注意别和采样抢时间常见做法是暂停采样、发完再恢复或者用 DMA 串口发送。下面是一个简单的串口导出片段/* 通过 USART1 把一帧采样数据以 CSV 格式发出便于上位机分析 */ void scope_export_csv(const uint16_t *buf, uint16_t len) { char line[16]; for (uint16_t i 0; i len; i) { int n sprintf(line, %u\r\n, buf[i]); /* 每行一个采样值 */ for (int j 0; j n; j) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, line[j]); } } }sprintf在嵌入式里开销不小点数多时建议换成自己写的整数转字符串或者直接发二进制、上位机按字节解析。串口波特率至少 115200否则 1024 个点发完要好几秒。导出前记得暂停定时器不然发送过程中缓冲区还在被 DMA 改写发出去的数据前后不一致。改造时我习惯先动「参数层」再动「逻辑层」先把采样率、绘制点数、触发电平这些做成宏或者结构体字段确认改参数能生效再去改触发和渲染的逻辑。这样每次改动的影响面可控出了问题也容易回退。从那以后我每次给这类 demo 加功能都强制先把参数抽出来、跑一遍原始波形确认基线没坏再往上叠新逻辑——血泪经验是跳过这步直接改渲染最后往往分不清是采样错了还是画错了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询