news 2026/11/4 8:27:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 内核,主频 72MHz,64KB Flash、20KB SRAM,片上带 2 个 12 位 ADC、多个通用定时器。做简易示波器,最吃紧的不是算力而是内存和采样带宽。12 位 ADC 单通道在规则组连续转换模式下,配合 DMA 搬运,能跑到接近 1Msps 的理论上限,实际受限于 ADC 时钟分频,稳定在几百 ksps 是现实的。显示部分常见做法是 1.8 寸或 2.4 寸 SPI TFT,分辨率 128×160 或 240×320,SPI 接口占用引脚少,刷一屏波形的时间在几十毫秒量级,对「看个大概波形」够用。

选这条路线的原因很直接:成本低、资料多、引脚够。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 Debugger,Flash 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 正弦波,幅值 0~3.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 时钟也不能超过 14MHz,RCC_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 时钟下刷一帧大约十几毫秒,加上采样等待,整体刷新率在 20~30 帧每秒,肉眼看着是连续的。

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的返回值确实传给了绘制函数,而不是被局部变量覆盖。再检查触发电平是否落在信号幅值范围内——如果信号是 0~1V,触发电平设成 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 加功能,都强制先把参数抽出来、跑一遍原始波形确认基线没坏,再往上叠新逻辑——血泪经验是,跳过这步直接改渲染,最后往往分不清是采样错了还是画错了。希望帮到你。

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

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

专吃套话的写作清理法:拆掉“赋能”“抓手”等八股文术语

“专吃八股文的蓝色大肥鱼”&#xff0c;这个名字看起来像个段子&#xff0c;实际上是我给自己一套文稿清理方法起的代号。干编辑这行久了&#xff0c;你会发现一个特别魔幻的现象&#xff1a;每天从邮箱、工作群、AI对话框里涌出来的文字&#xff0c;字数越来越多&#xff0c;…

作者头像 李华
网站建设 2026/11/4 8:25:00

同事SKILL与反蒸馏SKILL:机器学习模型协同训练新范式

1. 前沿技术背景解析 最近在机器学习模型优化领域&#xff0c;两种新型训练策略——同事SKILL&#xff08;Colleague SKILL&#xff09;和反蒸馏SKILL&#xff08;Anti-Distillation SKILL&#xff09;引发了广泛讨论。这两种方法都源于对传统知识蒸馏技术的反思与创新&#xf…

作者头像 李华
网站建设 2026/11/4 8:24:06

PF_INET与AF_INET区别解析及网络编程规范

1. 深入解析PF_INET与AF_INET的本质区别 在Linux网络编程领域&#xff0c; PF_INET 和 AF_INET 这对看似简单的宏定义&#xff0c;背后却隐藏着计算机网络协议栈设计的演进历史。我第一次在项目代码评审中发现团队成员混用这两个常量时&#xff0c;也曾产生过疑惑——为什么…

作者头像 李华
网站建设 2026/11/4 8:24:05

PDF翻译痛点全解析:排版、OCR与工具实战指南

PDF 翻译的痛点&#xff0c;我太懂了。学术党、科研人、还有那些天天跟外文说明书较劲的工程师&#xff0c;谁没在“打开PDF—复制—粘贴到翻译软件—格式炸裂/乱码/断行”的循环里浪费过一两个小时&#xff1f;市面上不是没有翻译工具&#xff0c;但PDF这个格式实在太特殊了&a…

作者头像 李华
网站建设 2026/11/4 8:23:57

6款主流饼图工具横向评测与选型指南

1. 饼图制作工具选择的重要性 数据可视化已经成为现代职场和学术研究的必备技能。作为最基础也最直观的图表类型之一&#xff0c;饼图因其简单明了的特点&#xff0c;被广泛应用于商业报告、学术论文、市场分析等各种场景。一个制作精良的饼图能够&#xff1a; 直观展示各部分…

作者头像 李华
网站建设 2026/11/4 8:23:46

NVIDIA GPU架构演进与AI基础设施适配指南

1. 这不是一张显卡的升级史&#xff0c;而是一套AI基础设施的进化图谱 你拆开一台训练大模型的服务器&#xff0c;看到的不是一块RTX 4090&#xff0c;而是NVIDIA A100——它插在PCIe插槽里&#xff0c;散热器上印着“SXM4”&#xff0c;旁边连着液冷管道&#xff1b;你打开Py…

作者头像 李华