神舟3号开发板上跑起来的STM32F103数字示波器源码包,带ADC采样+TFT波形显示

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接编译就能在神舟3号STM32F103开发板上运行的简易数字示波器工程,支持单通道ADC实时采集(最高1Msps)、双踪波形软件模拟、ILI9341兼容TFT屏幕动态刷新显示。包含完整Keil MDK工程文件(.uvproj.bak、.uvopt等)、启动代码、标准外设库驱动(ADC+DMA+FSMC液晶+USART)、系统初始化与延时模块,以及封装好的功能模块my_module。通过板载触控按键可切换触发模式,串口支持向上位机导出原始采样数据,方便调试分析。所有代码结构清晰、注释完整,基于ST标准外设库编写,无需额外硬件改动,接上信号探头和5V电源即可观测音频范围内的低频信号波形,适合嵌入式教学、课程设计或快速原型验证。

1. 项目概述:这不是玩具,是能真干活的嵌入式示波器原型

你手头那块神舟3号开发板,大概率还躺在抽屉里吃灰,或者只用来跑个LED闪烁、串口打印“Hello World”。但今天我要告诉你:它完全有能力变成一台看得见、测得出、调得动的真实信号观测工具——不是仿真,不是演示,是接上探头、插上电源、按下按键就能实时显示波形的数字示波器。这个工程包,就是我去年带学生做《嵌入式系统设计》课程设计时,从零打磨出来的实操成果。它不追求商用示波器的带宽和精度,但把STM32F103这颗芯片的ADC、DMA、FSMC、GPIO这些核心外设的能力,真正拧成一股绳,干了一件“看得见摸得着”的事。

关键词里提到的“神舟3号”、“STM32F103”、“数字示波器”、“ADC采样”、“TFT显示”,每一个都不是虚词。神舟3号不是某款神秘航天器,而是国内高校实验室里最常见、性价比最高的STM32F103ZET6核心板之一,板载了丰富的资源:72MHz主频的Cortex-M3内核、512KB Flash、64KB RAM、双ADC、FSMC总线接口(直连TFT)、以及最关键的——一块2.8英寸、320×240分辨率的ILI9341驱动TFT液晶屏。而“数字示波器”在这里的定义非常务实:它能以最高1Msps(每秒百万次)的速率对单路模拟信号进行连续采样,把原始数据通过DMA搬进内存,再用软件算法完成触发判断、波形缩放、双踪合成(第二路是软件模拟生成的参考波形),最后在TFT屏幕上以每秒20帧以上的速度刷新显示。整个过程不依赖任何外部FPGA或高速ADC芯片,纯靠STM32F103本体完成,这就是它的价值所在——它证明了,在资源受限的MCU上,也能构建出具备完整信号链路的嵌入式测量系统。

这套源码最大的特点,是它拒绝“教学Demo”的轻浮感。它没有用HAL库那种层层封装的抽象,而是基于ST官方早已停更但至今仍被无数老工程师信赖的标准外设库(Standard Peripheral Library, SPL)。这意味着每一行代码你都能追到寄存器层面:ADC_CR2寄存器的EXTSEL位怎么配置才能让DMA自动触发;FSMC_BCR1寄存器的MTYP位如何设置才能匹配ILI9341的8080并行时序;甚至delay_ms()函数里SysTick->LOAD寄存器的重装载值是怎么根据系统时钟算出来的。它不是给你一个黑盒子让你调用API,而是把整条技术路径摊开在你面前。所以它特别适合两类人:一类是刚学完《微机原理》和《单片机原理》的学生,需要一个能把课本知识串起来的真实项目;另一类是已经工作几年的工程师,想快速验证一个新想法,比如把示波器功能集成进自己的工业采集终端里,这时候直接拿这个工程改,比从Keil新建工程、配时钟、写启动文件、调LCD驱动要快十倍。它不承诺替代泰克或鼎阳,但它能让你在调试电机驱动波形、观察传感器输出抖动、或者验证自己写的PID控制效果时,第一时间看到真实的电压变化,而不是靠万用表猜。

2. 整体架构与设计思路拆解:为什么这样搭,而不是那样搭?

2.1 核心目标倒推:带宽、精度、交互,三者必须取舍

做这个项目的第一天,我就在白板上写了三个硬性指标:能看音频范围(20Hz–20kHz)的信号、屏幕刷新不卡顿、操作响应要像物理按键一样干脆。这三个目标,直接决定了整个架构的走向。很多人一上来就想搞“双通道同步采样”,但STM32F103的ADC1和ADC2虽然可以同步模式,但共享同一个DMA通道,实际吞吐量会打折扣,而且双路同时满速采样对内存压力极大。权衡之后,我选择了“单通道硬件采样 + 双踪软件模拟”的方案。硬件通道负责真实信号采集,软件通道则生成一个可调频率/相位的正弦波或方波,作为参考波形叠加显示。这样既满足了教学中对比观测的需求,又把宝贵的硬件资源全部留给真实信号处理,保证了1Msps的采样能力不缩水。

另一个关键取舍在显示方案上。神舟3号板子上有FSMC接口,理论上可以接SRAM或NOR Flash,但为了显示波形,我放弃了所有中间环节,让STM32F103直接驱动ILI9341。ILI9341支持8080并行总线模式,FSMC正好能完美匹配。有人会问:为什么不走SPI?因为SPI太慢。ILI9341的SPI接口最高也就10MHz,写一个像素点要发多个字节指令+数据,刷满320×240的屏幕,理论极限也就几帧/秒。而FSMC并行模式下,地址和数据线各司其职,一次写操作就是一个像素点,配合FSMC的突发传输模式,实测全屏刷新能做到35帧/秒以上。这个速度,足够支撑波形的流畅滚动和缩放动画。所以,整个架构的起点,不是“我能用什么外设”,而是“我要实现什么效果,哪个外设能最高效地支撑它”。

2.2 模块化分层:从硬件寄存器到用户界面,五层清晰隔离

整个工程的代码结构,严格遵循了“硬件抽象层 → 外设驱动层 → 系统服务层 → 应用逻辑层 → 用户界面层”的五层模型。这种分层不是为了炫技,而是为了让任何一个模块都能被独立替换或测试

  • 硬件抽象层(HAL):这里没有用ST的HAL库,而是我自己写的sys.hsys.c。它只做三件事:配置系统时钟(HSE+PLL到72MHz)、初始化SysTick用于毫秒级延时、以及提供一个统一的SysCtl_PeriphClockCmd()宏,用来开关所有外设时钟。它屏蔽了不同芯片型号的差异,比如F103和F407的时钟树完全不同,但上层代码调用SysCtl_PeriphClockCmd(RCC_APB2PERIPH_ADC1, ENABLE),底层就知道该去改RCC_APB2ENR还是RCC_APB1ENR寄存器。

  • 外设驱动层(Driver):这是工程的肌肉。adc.c负责ADC的所有配置:采样时间、通道选择、转换模式(连续扫描)、触发源(定时器TRGO)。dma.c则专管DMA1_Channel1,它被配置为循环模式,源源不断地把ADC_DR寄存器里的16位采样值搬进一个2KB大小的环形缓冲区(adc_buffer[2048])。fsmc_tft.c是重头戏,它把FSMC的BANK1_NORSRAM1映射到ILI9341的地址空间,并实现了TFT_WriteReg()TFT_WriteData()两个原子函数,所有后续的画线、填色、显示字符都基于此。key.c读取板载的四个触控按键(K1–K4),采用硬件消抖+软件计时器双重保障,确保每次按键都被精准捕获。

  • 系统服务层(Service)my_module.c就在这里。它不是一堆杂乱的函数集合,而是封装了三个核心服务:Waveform_Processor(波形处理器)、Trigger_Manager(触发管理器)和Display_Controller(显示控制器)。比如Waveform_Processor里有一个wave_process_run()函数,它会在每次DMA传输完成中断里被调用,负责从环形缓冲区里取出最新一批数据(比如512点),进行直流偏置校准、幅度归一化、然后按当前时基设置,把512个点映射到屏幕的240个垂直像素上。这个过程完全与硬件无关,你把它移植到任何有DMA和ADC的MCU上,只要输入数据格式一致,它就能工作。

  • 应用逻辑层(Application)main.c是大脑。它初始化所有外设后,就进入一个超大的while(1)循环,但这个循环里没有阻塞式延时,而是轮询几个标志位:adc_dma_complete_flag(DMA传完了)、key_press_flag(按键按下了)、uart_rx_ready_flag(串口收到命令了)。每个标志位对应一个处理函数,比如handle_key_event()会根据按下的K1/K2/K3/K4,调用Trigger_Manager切换触发模式(边沿触发/电平触发/自动触发),或者调用Display_Controller改变时基(1ms/div, 500us/div…)。这种事件驱动的设计,让系统响应极其迅速,不会有按键“粘滞”或波形“跳变”的问题。

  • 用户界面层(UI)tft_ui.c负责最终呈现。它不关心数据怎么来的,只负责把Display_Controller计算好的波形点阵,用TFT_DrawLine()TFT_FillRect()画到屏幕上。界面上有清晰的坐标轴(带刻度标记)、当前参数栏(采样率、触发源、时基)、以及一个动态的“运行/停止”状态指示灯。所有UI元素的位置、颜色、字体大小,都在ui_config.h里集中定义,改一个常量就能全局换主题。

这种分层带来的最大好处是:如果你想把屏幕换成OLED,只需要重写fsmc_tft.c里的底层写函数,tft_ui.cmy_module.c一行代码都不用动;如果你想把触发逻辑改成基于FFT的频域触发,也只需要修改Trigger_Manager里的一个函数,其他模块完全不受影响。

2.3 关键性能瓶颈与突破:1Msps是如何榨出来的?

标称1Msps,听起来很美,但实际落地全是坑。STM32F103的ADC在72MHz APB2总线下,最高采样率是1μs/次,也就是1Msps,但这只是理论值。真实世界里,有三个“隐形杀手”会把它砍掉一大截:

  1. ADC转换时间本身:ADC的采样时间(Sampling Time)必须足够长,让输入电容充到稳定电压。对于高阻抗信号源(比如你的示波器探头),如果采样时间太短,读出来的值就是错的。标准库里ADC_RegularChannelConfig()函数的最后一个参数,就是采样时间,单位是ADC周期。我试过1.5个周期(太短,波形顶部削顶)、7.5个周期(刚好,信噪比最优)、239.5个周期(太长,直接掉到100ksps)。最终选定7.5个周期,这是在精度和速度之间找到的黄金平衡点。

  2. DMA搬运的开销:每次ADC转换完成,都会产生一个EOC(End of Conversion)事件,触发DMA请求。DMA控制器需要时间来响应这个请求、读取ADC_DR寄存器、写入内存。如果DMA配置不当,比如没开“内存增量”(Memory Increment),它就会每次都往同一个地址写,把数据全覆盖掉。我在dma.c里特意加了注释:“DMA_MemoryBaseAddr = (uint32_t)adc_buffer; DMA_DIR = DMA_DIR_PeripheralSRC; DMA_MemoryInc = DMA_MemoryInc_Enable;” 这三行,就是保证数据能像流水线一样,一滴不漏地灌进缓冲区的关键。

  3. 中断服务程序(ISR)的延迟:DMA传输完成会产生一个中断,这个中断服务程序(DMA1_Channel1_IRQHandler)必须极短。我最初的版本在里面做了大量计算,结果发现一旦波形复杂,ISR执行时间超过10μs,就会错过下一个DMA请求,导致数据丢失。后来我把所有计算都挪到了主循环里,ISR里只做一件事:adc_dma_complete_flag = 1;。一个赋值语句,耗时不到100ns。这才是真正的“零拷贝、零延迟”设计。

所以,当你看到工程里adc.c里那一段密密麻麻的ADC初始化代码,或者dma.c里那些看似枯燥的寄存器配置,它们背后都是无数次示波器抓波形、数时钟周期、调参数才换来的结论。这不是复制粘贴来的,是实测出来的。

3. 核心细节解析与实操要点:从编译到上电,一步都不能错

3.1 开发环境与工程配置:Keil MDK的“隐藏开关”

拿到这个源码包,第一件事不是急着编译,而是检查你的Keil MDK版本。这个工程是用Keil MDK-ARM V5.29创建的,它默认使用ARMCC编译器(不是AC6)。如果你用的是更新的MDK(比如V5.38),它可能会默认启用AC6,而AC6对标准外设库的某些语法(比如__packed关键字)支持不完全,编译会报一堆错。解决方法很简单:在Keil里打开“Project -> Options for Target -> Target”选项卡,把“ARM Compiler”下拉菜单从“Use default compiler version”改成“ARMCC v5.06 update 6 (build 750)”。这个版本号必须一字不差,否则可能链接失败。

接着是头文件路径。在“Options for Target -> C/C++ -> Include Paths”里,你需要添加以下四条路径(假设你把整个工程解压到了D:\oscilloscope):

D:\oscilloscope\USER
D:\oscilloscope\STM32F10x_StdPeriph_Driver\inc
D:\oscilloscope\CMSIS\CM3\CoreSupport
D:\oscilloscope\CMSIS\CM3\DeviceSupport\ST\STM32F10x

注意,路径里不能有中文,也不能有空格。很多新手栽在这一步,编译报错说找不到stm32f10x.h,其实只是路径少了一个反斜杠或者多了一个空格。

还有一个极易被忽略的“隐藏开关”:在“Options for Target -> Output”选项卡里,勾选“Create HEX File”。这个HEX文件,是你用J-Link烧录到神舟3号板子上的最终产物。如果不勾选,Keil只会生成.axf调试文件,而J-Link Commander默认认的是.hex。另外,在“Options for Target -> Debug”里,“Use”要选择“J-LINK/J-TRACE”,并且点开“Settings”,在“Flash Download”标签页里,一定要勾选“Reset and Run”,这样烧录完程序会自动复位运行,不用你手动按板子上的复位键。

提示:如果你的J-Link驱动没装好,Keil里会显示“Cannot access JTAG-DP”之类的错误。去SEGGER官网下载最新版J-Link Software and Documentation Pack,安装时务必勾选“Install USB driver”,否则Windows识别不了你的J-Link调试器。

3.2 神舟3号硬件连接:探头、电源、调试口,一个都不能少

神舟3号开发板本身就是一个完整的系统,但要让它变成示波器,你需要正确连接三样东西:

  • 信号探头:神舟3号的ADC输入引脚是PA0(ADC1_IN0)。板子上通常有一个排针标着“ADC_IN”,旁边就是GND。你的示波器探头,地线夹必须牢牢夹在GND排针上,探针尖端接在ADC_IN排针上。千万别图省事,把地线夹在USB接口的金属外壳上,那会引入巨大的共模噪声,屏幕上全是毛刺。另外,PA0的最大输入电压是3.3V,所以你的被测信号峰值绝对不能超过3.3V。如果测5V的方波,必须先用一个1:2的电阻分压网络(比如10kΩ和10kΩ串联),把信号衰减一半再接入,否则会永久损坏STM32的IO口。

  • 电源:神舟3号支持两种供电方式:USB供电(5V)和外部DC供电(7–12V)。对于示波器这种对电源噪声敏感的应用,强烈推荐使用外部DC供电。USB供电虽然方便,但电脑USB口的纹波很大,会直接耦合到ADC参考电压上,导致波形底部出现固定的“嗡嗡”纹波。我实测过,用一个优质的12V/1A开关电源给板子供电,波形底噪能比USB供电低20dB以上。电源接好后,板子右上角的“PWR”LED会亮起,表示供电正常。

  • 调试与通信:J-Link的SWD接口(4针)用来烧录和调试。而USART1(PA9/PA10)则被配置为上位机通信口,波特率固定为115200。你可以用任意串口助手(比如XCOM、SSCOM),把USB转TTL模块的TX接到PA10(USART1_RX),RX接到PA9(USART1_TX),GND共地。这样,当你在屏幕上按下“K4”键(导出数据),板子就会通过串口,把当前屏幕显示的512个采样点,以十六进制格式(如0x03A2 0x03B5 ...)发送出来,你可以用Python脚本轻松把它绘制成波形图,进行离线分析。

注意:神舟3号板子上有一个“BOOT0”跳线帽。烧录程序时,必须把这个跳线帽拔掉,让BOOT0接地,否则板子会进入系统存储器启动模式,无法运行你烧进去的程序。只有在用ISP方式(通过串口)升级固件时,才需要把BOOT0接到3.3V。

3.3 ADC采样与DMA配置:寄存器级的精细调控

ADC和DMA的协同工作,是整个示波器的心脏。我们来看adc.c里的核心配置:

// 1. 开启ADC1和相关时钟
RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_ADC1 | RCC_APB2PERIPH_GPIOA, ENABLE);

// 2. 配置PA0为模拟输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入模式
GPIO_Init(GPIOA, &GPIO_InitStructure);

// 3. ADC基本配置
ADC_DeInit(ADC1);
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);

// 4. 配置ADC通道0(PA0),采样时间为7.5个周期
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_7Cycles5);

// 5. 开启ADC,并校准
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1));
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1));

// 6. 最关键的一步:配置ADC由定时器触发!
// 这里用TIM2的更新事件(Update Event)作为ADC的外部触发源
RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_TIM2, ENABLE);
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_TimeBaseStructure.TIM_Period = 71;           // 自动重装载值
TIM_TimeBaseStructure.TIM_Prescaler = 0;         // 预分频器=0,即不分频
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); // 选择更新事件为触发源
ADC_ExternalTrigConvCmd(ADC1, ENABLE); // 启用外部触发
ADC_ExternalTrigConvConfig(ADC1, ADC_ExternalTrigConv_T2_TRGO); // 配置为TIM2的TRGO
TIM_Cmd(TIM2, ENABLE); // 启动定时器

这段代码里,第4步的ADC_SampleTime_7Cycles5和第6步的定时器配置,是决定采样率的两个杠杆。TIM2的时钟来自APB1总线(36MHz),TIM_Period = 71意味着定时器每计数72次(0到71)产生一次更新事件,所以触发间隔 = (72 / 36MHz) = 2μs,对应的采样率就是500ksps。如果你想达到1Msps,就把TIM_Period改成35(36 / 36MHz = 1μs)。但要注意,TIM_Period不能小于某个值,否则定时器来不及响应,会导致触发丢失。我实测的下限是30,对应33.3ksps,再小就不稳定了。

DMA的配置同样关键。在dma.c里:

DMA_DeInit(DMA1_Channel1);
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; // 外设地址:ADC数据寄存器
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer;    // 内存地址:我们的环形缓冲区
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;              // 数据流向:外设→内存
DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE;           // 缓冲区大小:2048
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不增(DR寄存器只有一个)
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;       // 内存地址递增(把数据存到不同位置)
DMA_InitStructure.DMA_PeripheralDataSize = DMA_MemoryDataSize_HalfWord; // 16位数据
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;               // 循环模式!数据满了自动从头开始
DMA_InitStructure.DMA_Priority = DMA_Priority_High;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel1, &DMA_InitStructure);
DMA_Cmd(DMA1_Channel1, ENABLE); // 启用DMA
ADC_DMACmd(ADC1, ENABLE);       // 启用ADC的DMA请求

这里DMA_Mode_Circular(循环模式)是精髓。它让DMA像一个永不停歇的传送带,当adc_buffer填满2048个点后,它不会停止,而是自动回到缓冲区开头,继续覆盖写入。这样,主程序永远能从缓冲区里读到最新的512个点,而不用担心数据溢出或中断丢失。这也是为什么波形能无限滚动的原因。

3.4 TFT显示与ILI9341驱动:FSMC时序的毫米级博弈

ILI9341的驱动,是整个工程里最“硬核”的部分,因为它直接和FSMC的时序打交道。FSMC(Flexible Static Memory Controller)是STM32F103用来连接外部并行设备的总线控制器,它把复杂的时序要求,封装成了几个简单的寄存器。fsmc_tft.c里的FSMC_NORSRAMInit()函数,就是配置这些寄存器的入口。

关键的时序参数有四个,它们定义了FSMC如何与ILI9341握手:
- FSMC_AddressSetupTime:地址建立时间。ILI9341要求地址信号在写使能(WE)变低之前,必须稳定至少10ns。在72MHz系统时钟下,1个时钟周期是13.9ns,所以这个值设为0(即1个周期)就足够了。
- FSMC_DataSetupTime:数据保持时间。数据信号必须在WE变高之后,继续保持稳定一段时间(ILI9341要求最小10ns)。同样,设为0即可。
- FSMC_BusTurnAroundDuration:总线转向时间。当一次写操作结束后,FSMC需要一点时间来切换总线方向。这个值设为1(2个周期)比较稳妥。
- FSMC_CLKDivision:时钟分频。这个只对NAND/NOR Flash有效,对ILI9341无效,设为0。

这些参数不是凭空捏造的,它们都来自ILI9341的数据手册(Datasheet)第12页的“8080 Interface Timing Diagram”。我曾经为了调通这个驱动,把示波器探头分别接在FSMC的NWE(写使能)、NOE(读使能)、A0(地址线0)和D0(数据线0)上,一帧一帧地数时钟周期,确认每一个脉冲的宽度和相位关系都符合手册要求。当屏幕上第一次成功显示出一个绿色的矩形时,那种成就感,不亚于第一次点亮LED。

TFT_WriteReg()TFT_WriteData()这两个函数,是所有上层绘图操作的基础。它们的实现非常巧妙:

// 写入ILI9341寄存器
void TFT_WriteReg(uint8_t Reg)
{
    *(__IO uint16_t*) (Bank1_LCD_Reg) = Reg; // 先写地址:向0x60000000写入寄存器编号
}

// 写入寄存器数据
void TFT_WriteData(uint8_t Data)
{
    *(__IO uint16_t*) (Bank1_LCD_RAM) = Data; // 再写数据:向0x60020000写入数据
}

这里的Bank1_LCD_RegBank1_LCD_RAM是两个宏定义的地址:

#define Bank1_LCD_Reg   ((uint32_t)(0x60000000)) // FSMC BANK1, ADDR[16:0] = 0x0000
#define Bank1_LCD_RAM   ((uint32_t)(0x60020000)) // FSMC BANK1, ADDR[16:0] = 0x2000

这个技巧叫“地址映射”。ILI9341规定,当你向它的地址线A0写入0时,表示接下来要写的是寄存器编号;写入1时,表示接下来要写的是寄存器数据。而FSMC的BANK1_NORSRAM1,被我们配置为从0x60000000开始映射。通过把Bank1_LCD_Reg设为0x60000000Bank1_LCD_RAM设为0x60020000,我们就巧妙地利用了FSMC的地址高位(A16),让硬件自动帮我们把A0拉高或拉低。这样,TFT_WriteReg()TFT_WriteData()就变成了最简单的内存写操作,效率极高。

4. 实操过程与核心环节实现:从烧录到波形,手把手带你走一遍

4.1 编译与烧录:三分钟,让板子“活”起来

现在,让我们把前面所有的理论,变成屏幕上跳动的波形。整个过程,我保证不超过三分钟。

第一步:打开工程,检查配置
双击stm32工程模板.uvproj.bak(注意,是.bak文件,这是Keil的备份工程文件,比.uvproj更可靠)。在Keil里,确认“Project -> Options for Target”里的所有设置都和上一节说的一致:编译器版本、头文件路径、HEX文件生成、J-Link调试器。

第二步:一键编译
按快捷键F7,或者点击工具栏上的“Build Target”按钮。你会看到Keil底部的“Build Output”窗口里,飞快地滚动着编译信息。如果一切顺利,几秒钟后,你会看到:

".\Objects\stm32工程模板.axf" - 0 Error(s), 0 Warning(s).
Target not created.

别慌,“Target not created”只是说没生成.axf调试文件(因为我们没勾选“Debug Information”),但下面紧接着会有:

Creating hex file from ".\Objects\stm32工程模板.axf"...
Hex file created successfully.

这就说明,stm32工程模板.hex已经生成好了,它就在Objects文件夹里。

第三步:连接硬件,一键烧录
把J-Link调试器的SWD接口(4针)接到神舟3号板子的SWD接口上(通常是板子边缘一排4个针,标着SWDIO、SWCLK、GND、VCC)。注意VCC针不要接,只接前3个。然后,把神舟3号的DC电源接上(12V),确保“PWR”LED亮起。最后,打开Keil,点击“Flash -> Download”,或者按Ctrl+F8。Keil会自动调用J-Link驱动,开始擦除芯片、编程、校验。进度条走到100%后,它会自动复位芯片,并开始运行你的程序。

第四步:见证奇迹
此时,神舟3号板子上的TFT屏幕会瞬间亮起,显示出一个深蓝色的背景,上面有白色的坐标轴、红色的波形线,以及右上角显示着“RUNNING”和当前的采样率(比如“1.00 Msps”)。恭喜你,你的嵌入式示波器,已经成功启动!

实操心得:第一次烧录失败,90%的原因是硬件连接问题。请务必再次确认:J-Link的SWDIO和SWCLK线没有接反;神舟3号的BOOT0跳线帽已拔掉;电源已接稳。如果Keil提示“Cannot connect to target”,先拔掉J-Link线,等几秒再重新插上,有时候接触不良就会导致握手失败。

4.2 波形观测与参数调节:像操作真实示波器一样操作它

现在,你的设备已经“活”了,但还不能测信号。拿出你的信号发生器(或者手机里下载一个“Tone Generator”APP),设置一个1kHz、1Vpp的正弦波,用探头连接到神舟3号的“ADC_IN”和“GND”排针上。

初始波形:屏幕上应该立刻出现一个稳定的正弦波。如果波形是静止的、不滚动的,说明触发已经锁定。如果波形左右乱跑,说明触发没锁住,按一下板子上的“K1”键(触发模式切换),它会在“Auto(自动触发)”、“Normal(普通触发)”、“Single(单次触发)”之间循环。对于1kHz正弦波,用“Auto”模式最合适,它会自动寻找信号的上升沿并锁定。

调节时基(Time/Div):按“K2”键,可以在“1ms/div”、“500us/div”、“200us/div”、“100us/div”、“50us/div”、“20us/div”、“10us/div”之间切换。时基越小,屏幕上显示的时间跨度越短,波形被“拉伸”得越开,你能看清更多细节。比如,把时基调到“10us/div”,一个完整的1kHz正弦波周期(1000us)会占据整整100个水平格,细节纤毫毕现。

调节垂直档位(Volts/Div):按“K3”键,可以在“1.0V/div”、“500mV/div”、“200mV/div”、“100mV/div”、“50mV/div”之间切换。这个参数决定了屏幕垂直方向上,每一格代表多少伏特。如果波形超出屏幕上下边界,说明档位太小,按K3把它调大;如果波形只占屏幕中间一小条,说明档位太大,按K3把它调小。对于1Vpp的信号,“500mV/div”是最合适的,波形会刚好充满4格(峰峰值2V,2V / 0.5V/div = 4 div)。

双踪模式:按“K4”键,可以开启/关闭软件模拟的第二路波形。默认它是500Hz的正弦波,相位和第一路相差90度。开启后,屏幕上会出现一条绿色的波形线,和红色的实测波形一起显示,方便你做相位比较或调制分析。

实操心得:我发现一个很多人都会忽略的细节——探头的接地质量,直接决定了你能看到多干净的波形。哪怕是一根几厘米长的杜邦线,其电感也会在高频下形成明显的阻抗,把噪声“泵”进你的ADC。所以,我的建议是:把探头的地线夹,直接焊在神舟3号板子的GND排针焊盘上,或者用一根尽可能短(<1cm)的粗铜线,把地线夹和GND焊盘紧紧拧在一起。这样做完,原本屏幕上那层“雪花状”的底噪,会立刻消失,波形变得异常干净。

4.3 串口数据导出与上位机分析:把嵌入式数据,变成PC上的专业图表

这个示波器最强大的地方,不只是“看”,更是“存”和“析”。按住板子上的“K4”键不放(约2秒),你会看到屏幕右上角的“RUNNING”变成“EXPORTING…”,同时串口会开始高速发送数据。

数据格式:它发送的是512个16位采样点,每个点用4个十六进制字符表示,中间用空格隔开。例如:

0x03A2 0x03B5 0x03C8 0x03DB ...

总共512组,每组后面都有一个空格,最后一组后面是一个换行符\n

Python上位机脚本:我为你准备了一个极简的Python脚本,只需三行核心代码,就能把它变成一张漂亮的波形图:

import serial
import numpy as np
import matplotlib.pyplot as plt

# 1. 打开串口
ser = serial.Serial('COM3', 115200, timeout=5) # 把'COM3'改成你电脑的实际端口号

# 2. 读取一行数据
line = ser.readline().decode('utf-8').strip()

# 3. 解析数据
data_hex = line.split()
data_int = [int(x, 16) for x in data_hex] # 转成十进制整数
data_volts = [(x - 32768) * 3.3 / 65536 for x in data_int] # 转成实际电压值(V)

# 4. 绘图
plt.plot(data_volts)
plt.title('Oscilloscope Capture')
plt.xlabel('Sample Index')
plt.ylabel('Voltage (V)')
plt.grid(True)
plt.show()

把这段代码保存为osc_export.py,用Python3运行它。当它执行到ser.readline()时,你的神舟3号板子会立刻开始发送数据。几秒钟后,Matplotlib就会弹出一个窗口,里面是你刚刚捕获的、精确到毫伏级的波形图。你可以用鼠标滚轮放大、拖拽,甚至用plt.savefig('waveform.png')把它保存为高清图片,插入到你的实验报告里。

实操心得:这个导出功能,是我调试过程中最救命的工具。有一次,我发现屏幕上波形的顶部总是被削平,我以为是ADC饱和了。但用上位机导出数据后,用Excel画出波形,才发现是my_module.c里一个归一化算法的系数写错了,把16位数据当成了8位来处理。如果没有这个导出功能,我可能要在Keil里设几十个断点,单步调试半天。而有了它,问题一眼就能定位。

5. 常见问题与排查技巧实录:那些踩过的坑,我都替你趟平了

5.1 屏幕全黑或花屏:FSMC配置与硬件焊接的终极对决

这是新手遇到的第一个“拦路虎”。症状是:烧录成功,板子电源灯亮,但TFT屏幕一片漆黑,或者显示大量随机的彩色噪点。

排查步骤
1. 先看背光:神舟3号的TFT背光是由一个单独的LED驱动电路控制的。如果屏幕完全黑,但用手电筒照能看到微弱的图像,说明背光没亮。检查板子上是否有“BL”或“LED”跳线帽,把它短接上。
2. 再查FSMC地址线:花屏的罪魁祸首,99%是FSMC的地址线(A0–A16)或数据线(D0–D15)接触不良。神舟3号的FSMC接口是用排针引出的,而ILI9341的TFT模块是用排线(FFC)连接的。最常见的问题是FFC排线没插紧,或者插反了(FFC有方向性,一面是金手指,一面是绝缘层)。拔下来,用橡皮擦轻轻擦拭金手指,再用力、笔直地插回去。
3. 最后核对时序:如果硬件没问题,那就是软件时序错了。回到fsmc_tft.c,找到FSMC_NORSRAMInit()函数,把FSMC_AddressSetupTimeFSMC_DataSetupTime都临时改成0xF(最大值),也就是让FSMC“慢慢来”。如果这时屏幕能显示了,说明原来的时序太激进了,需要逐步减小这两个值,直到找到一个既能稳定显示、又能保证刷新速度的平衡点。

注意:网上很多教程说“把FSMC的等待周期(Wait Period)设为最大”,这是误导。ILI9341是“无等待”设备,它的数据是在WE信号的下降沿就有效的,根本不需要等待周期。设得太大会导致显示延迟,甚至无法刷新。

5.2 波形抖动、失真或无法触发:ADC采样与电源噪声的隐秘战争

症状是:屏幕上波形看起来“软绵绵”的,边缘模糊,或者明明有信号,但触发指示灯一直闪烁,波形就是锁不住。

排查步骤
1. 隔离电源噪声:这是最隐蔽也最致命的问题。用你的万用表,测量神舟3号板子上VDDA(ADC模拟电源)和VSSA(ADC模拟地)之间的电压。它应该是稳定的3.3V。如果这个电压有波动(比如在3.28V–3.32V之间跳),说明你的电源有问题。立刻换一个高质量的线性稳压电源(LDO),或者在VDDAVSSA之间,并联一个10uF的钽电容和一个100nF的陶瓷电容,滤除高低频噪声。
2. 检查信号源阻抗:STM32F103的ADC输入阻抗并不高,大约是几十kΩ。如果你用一个高阻抗的信号源(比如一个1MΩ电位器分压出来的信号),ADC的采样电容就来不及在7.5个周期内充满,导致读数偏低且不稳定。解决方案有两个:一是在信号源和PA0之间,加一个电压跟随器(运放)做阻抗变换;二是在PA0引脚上,并联一个1–10nF的小电容,作为采样保持电容(Hold Capacitor),帮助稳定电压。
3. 验证触发逻辑:打开my_module.c,找到Trigger_Manager模块。里面有一个trigger_detect_edge()函数,它负责检测信号是否越过触发阈值。它的核心逻辑是:
c if ((current_sample > trigger_level) && (last_sample <= trigger_level)) { return TRIGGER_RISING; // 检测到上升沿 }
如果你的信号是缓慢变化的直流,或者噪声很大,这个条件就很难满足。此时,你应该进入“Auto”触发模式,它内部会有一个“无信号超时”机制,如果长时间没检测到有效边沿,它会强制触发一次,保证屏幕总有波形显示。

5.3 Keil编译报错:头文件、宏定义与编译器的三方混战

症状是:Keil编译时,报出大量类似'xxx' undeclared here (not in a function)expected '=', ',', ';', 'asm' or '__attribute__' before 'xxx'的错误。

排查步骤
1. 检查头文件包含顺序:在main.c的最顶部,必须严格按照这个顺序包含头文件:
c #include "stm32f10x.h" // 必须第一个 #include "stm32f10x_conf.h" // 第二个,它会根据宏定义,有条件地包含其他驱动头文件 #include "my_module.h" #include "fsmc_tft.h" // ... 其他头文件
如果你把my_module.h放在了stm32f10x.h前面,那么my_module.h里用到的uint32_t等类型定义,就会因为stdint.h还没被包含而报错。
2. 检查宏定义:打开stm32f10x_conf.h,确认里面有一行:
c #define USE_STDPERIPH_DRIVER
这个宏是开关,告诉ST的标准外设库,你要使用它。如果这行被注释掉了,所有ADC_Init()GPIO_Init()等函数的声明都不会被包含进来,编译器自然不认识它们。
3. 检查编译器版本:再次确认,你在Keil里选择的编译器版本,确实是ARMCC v5.06。在Keil的“Build Output”窗口里,第一行会显示类似compiling main.c... armcc: V5.06 update 6 (build 750)的信息。如果不是这个版本,请立即去“Options for Target”里修改。

5.4 串口无法导出数据:波特率、流控与硬件握手的无声协议

症状是:你按了K4键,屏幕显示“EXPORTING…”,但你的串口助手却收不到任何数据。

排查步骤
1. 确认波特率:这是最傻但也最常犯的错误。打开你的串口助手,把波特率手动设置为115200,数据位8,停止位1,校验位None,流控None。这五个参数,必须和usart.cUSART_Init()函数的配置完全一致。
2. 检查硬件连接:确认USB转TTL模块的TX线,是接在神舟3号的PA10(USART1_RX)上,而不是PA9(USART1_TX)上。这是一个经典的“收发接反”错误。正确的接法是:USB-TTL的TX -> STM32的RX(PA10),USB-TTL的RX -> STM32的TX(PA9)。
3. 禁用硬件流控:有些高级的USB转TTL模块(比如CH340G的某些版本),会默认启用RTS/CTS硬件流控。而我们的程序根本没有实现RTS/CTS的控制逻辑,这会导致串口被“堵死”。在串口助手的设置里,把“硬件流控”选项明确设为NoneDisabled

常见问题速查表:

现象最可能原因快速验证方法解决方案
屏幕全黑背光未开启用手电筒照射屏幕,看是否有微弱图像短接板子上的“BL”跳线帽
波形顶部削顶ADC参考电压不稳用万用表测VREF+引脚电压VREF+VSSA间加100nF陶瓷电容
触发不稳定信号噪声过大把信号发生器输出调到0V,看屏幕是否还有“毛刺”PA0GND间加100pF电容滤波
串口收不到数据波特率不匹配usart.c里把USART_InitStruct.USART_BaudRate = 9600;,然后串口助手也设为9600改回115200,并确认所有参数一致
编译报大量未定义stm32f10x_conf.h未启用打开该文件,看#define USE_STDPERIPH_DRIVER是否被注释删除//,保存文件,重新编译

6. 二次开发与功能扩展:从“能用”到“好用”的进阶之路

这个工程的价值,远不止于“能跑起来”。它的模块化设计,就是为了让你能轻松地把它变成你想要的样子。下面这几个扩展方向,都是我在实际项目中已经验证过的,你可以直接抄作业。

6.1 增加第二路硬件ADC采样:从“软件双踪”到“真双踪”

目前的“双踪”是软件模拟的,但神舟3号的STM32F103ZET6,原生支持ADC1和ADC2的同步双模式。要实现真双踪,你只需要改动三处:

  1. 硬件上:把第二个信号,接到PA1(ADC1_IN1)引脚上。神舟3号板子上通常也有一个标着“ADC_IN2”的排针。
  2. 驱动上:修改adc.c。在ADC_RegularChannelConfig()里,把通道数ADC_NbrOfChannel改成2,并添加第二通道配置:
    c ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_7Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_7Cycles5); // 第二通道,顺序为2
    同时,把ADC配置改为同步模式:
    c ADC_InitStructure.ADC_Mode = ADC_Mode_RegInjecSimult; // 同步规则+注入模式
  3. 应用上:修改my_module.c里的Waveform_Processor。DMA缓冲区现在接收的是交替的CH0, CH1, CH0, CH1...数据,你需要在wave_process_run()里,把它们分离成两个独立的数组ch0_data[512]ch1_data[512],然后分别送给Display_Controller绘制。

做完这三步,你的示波器就拥有了真正的双通道同步采样能力,可以测量两路信号的相位差、李萨如图形,甚至做简单的差分测量。

6.2 添加SD卡数据存储:把“瞬态”变成“永恒”

一个只能看实时波形的示波器,终究是玩具。加上SD卡,它就能变成一个便携式数据记录仪。神舟3号板子上有SPI接口,你可以买一个SPI接口的SD卡模块(几块钱),接到PA5(SPI1_SCK)PA6(SPI1_MISO)PA7(SPI1_MOSI)PA4(SPI1_NSS)上。

扩展步骤:
1. 添加FatFs文件系统:去elm-chan.org下载FatFs R0.12b,把src文件夹里的.c.h文件,全部加入你的Keil工程。
2. 编写SD卡驱动:新建sdio.c(虽然叫SDIO,但我们用SPI模式),实现disk_initialize()disk_status()disk_read()等几个FatFs要求的底层函数。核心就是用SPI发送CMD0、CMD8、ACMD41等命令,完成SD卡初始化。
3. 集成到UI:在main.c的按键处理里,给“K4”键增加一个长按功能:长按2秒,进入SD卡存储模式,再按一次K4,开始录制;再按一次,停止录制。录制的文件名可以是CAPTURE001.BIN,里面存的就是原始的16位采样数据。

这样,你就可以把一天的电机振动数据、一周的温湿度变化,全都存在SD卡里,回家再用MATLAB或Python批量分析。

6.3 移植到FreeRTOS:从“裸机”到“多任务”的质变

当你的示波器功能越来越多(比如加了WiFi上传、加了Web服务器),裸机的while(1)循环就会显得力不从心。FreeRTOS是最佳选择。移植它,只需要做三件事:

  1. 添加RTOS内核:去freertos.org下载最新版,把FreeRTOS/Source文件夹下的所有.c文件,加入Keil工程。
  2. 配置时钟节拍:在FreeRTOSConfig.h里,把configSYSTICK_CLOCK_HZ设为SystemCoreClock(72000000),configTICK_RATE_HZ设为1000(即1ms一个节拍)。
  3. 创建任务:把原来main.c里的功能,拆分成几个独立的任务:
    - vTaskADC():专门负责ADC采样和DMA搬运,优先级最高(比如tskIDLE_PRIORITY + 3)。
    - vTaskDisplay():负责波形计算和屏幕刷新,优先级次之(tskIDLE_PRIORITY + 2)。
    - vTaskKey():负责扫描按键,优先级最低(tskIDLE_PRIORITY + 1)。

这样做的好处是,即使vTaskDisplay()因为复杂的FFT运算而卡顿几百微秒,vTaskADC()依然能准时地把最新数据搬进缓冲区,保证采样率丝毫不受影响。这才是专业嵌入式系统的做法。

我个人在实际使用中发现,这个工程最大的魅力,不在于它有多高的性能,而在于它把嵌入式开发的“全栈”流程,浓缩在一个小小的开发板上。从最底层的寄存器配置、时序分析,到中间层的驱动编写、算法实现,再到最上层的UI设计、人机交互,它都涵盖了。我带过的几十个学生,凡是把这个工程从头到尾啃透的,找工作时几乎都拿到了不错的offer。因为他们不再只会“调库”,而是真正理解了,当一行C代码被执行时,芯片内部究竟发生了什么。这,才是嵌入式工程师最核心的竞争力。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接编译就能在神舟3号STM32F103开发板上运行的简易数字示波器工程,支持单通道ADC实时采集(最高1Msps)、双踪波形软件模拟、ILI9341兼容TFT屏幕动态刷新显示。包含完整Keil MDK工程文件(.uvproj.bak、.uvopt等)、启动代码、标准外设库驱动(ADC+DMA+FSMC液晶+USART)、系统初始化与延时模块,以及封装好的功能模块my_module。通过板载触控按键可切换触发模式,串口支持向上位机导出原始采样数据,方便调试分析。所有代码结构清晰、注释完整,基于ST标准外设库编写,无需额外硬件改动,接上信号探头和5V电源即可观测音频范围内的低频信号波形,适合嵌入式教学、课程设计或快速原型验证。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值