简介:本项目以STM32F103微控制器为核心,设计并实现了一款简易示波器,涵盖信号采集、处理与显示全过程。STM32F103基于ARM Cortex-M3内核,具备高性能、低功耗及丰富外设,通过其内置ADC完成模拟信号到数字信号的转换,并利用Keil开发环境进行软件编程与调试。系统由硬件电路、系统初始化、用户功能代码和显示模块组成,支持实时波形显示,适用于基础电子测量场景。该项目融合了嵌入式开发与信号处理技术,是掌握STM32应用开发的典型实践案例。
STM32F103构建高性能简易示波器的完整工程实践
你有没有试过用一块十几块钱的STM32F103“蓝色药丸”开发板,做出一台能看正弦波、方波甚至音频信号的数字示波器?🤔 别笑!这可不是天方夜谭。在嵌入式世界里,这种“小马拉大车”的奇迹每天都在发生。
想象一下:一个只有64KB SRAM和512KB Flash的MCU,要完成高速ADC采样、DMA无干扰数据搬运、实时波形绘制、触发逻辑判断……听起来是不是有点像让小学生去解微积分题?😅 但正是ARM Cortex-M3内核的强大架构设计,加上开发者对软硬件协同优化的精妙把控,才让这一切成为可能!
今天咱们就来拆解这个“不可能的任务”,从芯片底层讲到系统顶层,看看如何把一块普通的MCU变成一台真正可用的测量仪器 💪
微控制器核心架构:不只是72MHz那么简单
STM32F103之所以能在众多MCU中脱颖而出,靠的绝不仅仅是标称的72MHz主频这么简单。它的秘密武器藏在内部总线矩阵的设计里——I-Bus、D-Bus、S-Bus三者分工明确,各司其职。
- I-Bus(指令总线) :专供Cortex-M3取指令使用,直连Flash存储器
- D-Bus(数据总线) :负责所有数据读写操作
- S-Bus(系统总线) :连接外设寄存器,实现控制配置
这种哈佛架构的变种设计,使得CPU可以在执行当前指令的同时,预取下一条指令并准备数据访问,形成真正的三级流水线作业。这就像是你在做饭时,左手切菜、右手开火、眼睛还盯着锅里的汤——多任务并行处理的能力直接拉满!
// 系统时钟初始化伪代码
RCC->CR |= RCC_CR_HSEON; // 启动外部高速晶振
while(!(RCC->CR & RCC_CR_HSERDY)); // 等待HSE稳定
RCC->CFGR |= RCC_CFGR_PLLMULL9; // PLL倍频至72MHz
RCC->CFGR |= RCC_CFGR_SW_PLL; // 切换系统时钟源为PLL
上面这段看似简单的代码背后,其实是一场精密的时序协奏曲。当你打开HSE晶振那一刻,系统并不会立刻切换时钟源,而是耐心等待 HSERDY 标志位被硬件自动置起。这个小小的轮询过程,确保了整个系统的启动稳定性,避免因时钟抖动导致不可预测的行为。
而更有趣的是PLL倍频设置——为什么是x9?因为我们的外部晶振通常是8MHz,8×9=72MHz,刚好达到APB2总线的最大支持频率。如果你换成12MHz晶振还想跑72MHz主频,那就要改成x6了。所以说,每一个参数都不是随便写的,都是经过精确计算的结果 ✨
内存资源的真实可用性
很多人看到STM32F103有64KB SRAM就以为可以随便用,殊不知这片内存要同时服务于堆栈、全局变量、DMA缓冲区、LCD帧缓存等多个角色。一旦规划不当,轻则程序卡顿,重则直接崩溃。
举个例子:你想采集1000个ADC样本并通过DMA存入内存。每个样本是16位(2字节),那就是2000字节。如果再加个双缓冲机制,就是4KB。看起来不多对吧?但如果还要给LCD分配显存(哪怕只是部分区域)、保留足够的堆空间用于动态分配、再加上中断嵌套时的堆栈消耗……你会发现64KB其实非常紧张!
所以高手是怎么做的?他们会做精细的内存布局:
// 自定义链接脚本片段(.ld文件)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
/* 分区管理 */
_adc_buffer (rwx) : { KEEP(*(.adc_buf)) } > SRAM
_lcd_refresh (rwx) : { KEEP(*(.lcd_buf)) } > SRAM
_stack (rwx) : { KEEP(*(.stack)) } > SRAM
通过这种方式,你可以明确指定哪些变量放在哪里,防止不同模块之间抢夺资源。这才是专业级嵌入式开发该有的样子 👨💻
Cortex-M3内核的三大法宝:寄存器、指令集与中断
如果说STM32F103是战士,那么Cortex-M3就是他的大脑🧠。而这颗大脑最厉害的地方,在于它用极简的设计实现了极高的效率。
寄存器组的智慧设计
Cortex-M3提供了16个通用寄存器(R0-R15),但它们并不是平级的。其中几个关键角色特别值得我们关注:
| 寄存器 | 特殊身份 | 实际用途 |
|---|---|---|
| R13 (SP) | 堆栈指针 | 控制函数调用和中断上下文保存 |
| R14 (LR) | 链接寄存器 | 存储返回地址,免去压栈开销 |
| R15 (PC) | 程序计数器 | 指向下一条要执行的指令 |
尤其是 LR寄存器 ,它是Thumb-2指令集高效性的关键之一。每次执行 BL (Branch with Link)指令时,硬件会自动把返回地址写进LR,省去了手动 PUSH PC 的操作。等函数结束时,只要一句 BX LR 就能跳回去,整个过程快如闪电 ⚡
PUSH {R4, R5, LR} ; 将R4,R5和LR入栈
BL delay_ms ; 调用延时函数,LR自动更新
POP {R4, R5, PC} ; 弹出并恢复现场,PC设置使自动返回
注意最后一句 POP {R4, R5, PC} ——这里有个巧妙的设计:当我们把值弹回到PC时,处理器会自动识别这是函数返回操作,并清除流水线中的预取指令,避免误执行。
双堆栈模式的实际应用
更进一步,Cortex-M3支持两种堆栈指针:主堆栈指针(MSP)和进程堆栈指针(PSP)。这意味着你可以在特权模式下使用MSP,在用户任务中切换到PSP,实现类似操作系统的隔离效果。
__set_CONTROL(0x02); // 使用PSP,进入线程模式用户态
__ISB(); // 插入指令同步屏障,确保设置生效
在示波器项目中,我建议这样安排:
- 主循环(LCD刷新) → 使用PSP,运行在用户态
- ADC中断服务程序 → 强制使用MSP,保证响应速度
这样即使主循环出了问题导致堆栈溢出,也不会影响到关键的数据采集路径。安全性和稳定性瞬间提升一个档次 🔐
不过话说回来,虽然Cortex-M3没有硬件堆栈溢出检测,但我们可以通过一些“土办法”来监控风险:
#define STACK_MAGIC_WORD 0xDEADBEEF
uint32_t stack_buffer[256];
stack_buffer[0] = STACK_MAGIC_WORD;
// 初始化完成后检查是否被覆盖
if (stack_buffer[0] != STACK_MAGIC_WORD) {
/* 堆栈溢出警告 */
}
虽然这不是实时防护,但在调试阶段足以帮你发现潜在的大麻烦。
graph TD
A[任务启动] --> B{使用PSP?}
B -- 是 --> C[设置CONTROL[1]=1]
B -- 否 --> D[保持MSP]
C --> E[分配独立堆栈区]
D --> F[共享系统堆栈]
E --> G[执行任务代码]
F --> G
G --> H[中断发生]
H --> I[自动压栈 xPSR,PC,LR,R12,R3,R2,R1,R0]
I --> J[手动PUSH其他寄存器]
J --> K[执行ISR]
K --> L[恢复寄存器]
L --> M[中断返回]
这张流程图清楚地展示了从正常运行到中断响应的全过程。你会注意到,硬件只负责压入8个核心寄存器,剩下的需要软件自己补全。这也是为什么优秀的ISR都要尽量短小精悍的原因——少用局部变量,减少PUSH/POP次数,才能做到极致响应 ⏱️
Thumb-2指令集:代码密度与性能的完美平衡
很多人以为ARM处理器只能跑复杂的32位指令,其实不然。Cortex-M3采用的是 Thumb-2混合指令集 ,既能用16位短指令节省空间,又能用32位长指令处理复杂运算。
| 类型 | 长度 | 特点 | 示例 |
|---|---|---|---|
| 16 位指令 | 半字对齐 | 编码紧凑,适用于简单操作 | MOV R0, #1 |
| 32 位指令 | 字对齐 | 支持复杂寻址与条件执行 | LDR.W R1, [R2, #1024] |
最惊艳的莫过于 IT (If-Then)指令了:
IT EQ ; If-Then 指令,实现条件执行而不分支
MOVEQ R0, #1
ADDEQ R0, R0, #2
这几行代码的意思是:“仅当Z标志位为1时,才执行后面两条指令”。它不需要跳转,也就不会冲刷流水线,对于高频执行的小判断来说,简直是神器!
我们拿数组求和来做个对比实验:
uint32_t sum_array(uint32_t *arr, int len) {
uint32_t sum = 0;
for (int i = 0; i < len; i++) {
sum += arr[i];
}
return sum;
}
编译器生成的汇编可能是这样的:
sum_array PROC
MOV R2, #0 ; sum = 0
SUBS R3, R1, #1 ; len - 1
BEQ end_loop
loop
LDR R12, [R0] ; 加载 arr[i]
ADD R2, R2, R12 ; 累加
ADDS R0, R0, #4 ; 指针前进
SUBS R3, R3, #1
BPL loop
end_loop
MOV R0, R2
BX LR
ENDP
别看短短几行,里面全是门道:
- SUBS 会影响条件标志,所以后面可以直接跟 BEQ
- ADDS 也会更新N/Z/C/V标志,为后续判断做准备
- 所有基本操作都是单周期指令,在零等待Flash下能达到接近1 cycle/insn的理想状态
开启-O2优化后,GCC甚至会尝试循环展开或使用 LDMIA 批量加载,进一步榨干性能潜力。
| 实现方式 | 代码大小(字节) | 执行周期(@72MHz, n=100) | 平均每元素耗时(cycles) |
|---|---|---|---|
| C (-O0) | 48 | ~320 | 3.2 |
| C (-O2) | 36 | ~180 | 1.8 |
| 汇编优化 | 28 | ~140 | 1.4 |
差距明显吧?特别是在示波器这种需要频繁处理采样数据的系统中,每一点性能提升都意味着更高的采样率或更低的延迟。
NVIC中断控制器:实时性的守护神
说到嵌入式系统的灵魂,非中断莫属。而Cortex-M3内置的 嵌套向量中断控制器(NVIC) ,可以说是所有实时应用的基石。
它支持最多240个外部中断线(IRQ),加上16个系统异常,构成了完整的异常处理体系。而且每个中断都可以独立设置优先级,支持抢占和嵌套,真正做到“急事先办”。
void Enable_ADC_Interrupt(void) {
NVIC_SetPriority(ADC1_2_IRQn, 1); // 设置优先级为1
NVIC_EnableIRQ(ADC1_2_IRQn); // 使能中断
}
void ADC1_2_IRQHandler(void) {
if (ADC1->SR & ADC_SR_EOC) { // 检查是否转换完成
uint16_t adc_val = ADC1->DR; // 读取数据寄存器
process_sample(adc_val); // 处理采样值
ADC1->SR &= ~ADC_SR_EOC; // 清除标志位
}
}
这里有几个细节要注意:
- 必须先检查EOC标志,否则可能读到旧数据
- 一定要清除状态标志,否则会反复进入中断
- ISR要尽可能短,避免阻塞其他高优先级事件
为了更好地管理中断,我们可以划分优先级组:
NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
// 4bit 抢占,0bit 子优先级 → 最大支持 16 级抢占
在示波器系统中,我的推荐配置如下:
| 中断源 | 抢占优先级 | 应用场景 |
|---|---|---|
| SysTick | 0 | 高精度定时采样基准 |
| ADC | 1 | 数据采集,需快速响应 |
| USART | 2 | 波形上传,容忍一定延迟 |
| TIM_UIR | 3 | LCD刷新定时器 |
这样一来,哪怕正在刷新屏幕,一旦ADC准备好新数据,也能立即打断当前任务进行处理,确保采样时间的一致性。
flowchart LR
A[中断请求到达] --> B{当前中断正在执行?}
B -- 否 --> C[直接响应]
B -- 是 --> D{新中断优先级 > 当前?}
D -- 是 --> E[抢占当前中断]
D -- 否 --> F[排队等待]
E --> G[压栈当前上下文]
G --> H[执行高优先级ISR]
H --> I[恢复原上下文继续]
这张图揭示了NVIC的核心工作机制。你会发现,只要有更高优先级的中断到来,哪怕当前ISR还没执行完,也会被立刻暂停并保存现场——这就是所谓的“中断嵌套”。正是这种能力,让STM32能够在毫秒级时间内响应各种突发事件。
ADC采集系统:模拟世界的数字之窗
如果说GPIO是MCU的眼睛和耳朵,那ADC就是它的“感官神经元”🧠。STM32F103集成的12位SAR型ADC,虽然不是顶级配置,但对于构建简易示波器已经绰绰有余。
12位分辨率的真实意义
12位ADC意味着它可以将参考电压范围划分为 $2^{12}=4096$ 个离散电平。以常见的3.3V供电为例:
$$
\text{LSB} = \frac{3.3V}{4096} \approx 0.806\,\text{mV}
$$
也就是说,理论上它能分辨出比一节AA电池电压小4000倍的电压变化!👏 这已经足够看清大多数传感器信号的细微波动了。
但它的工作原理你真的了解吗?
ADC采用“采样-保持”电路,分两个阶段工作:
1. 采样阶段 :内部开关导通,让输入信号对采样电容充电
2. 保持阶段 :开关断开,电容维持电压不变,供SAR逻辑逐次比较
如果采样时间太短,电容没充够电就开始转换,结果就会偏低;反之,时间太长又会降低吞吐率。因此必须根据信号源特性合理设置采样周期。
| ADC Clock (MHz) | Sampling Time (cycles) | Total Conversion Cycles | Max Sample Rate (ksps) |
|---|---|---|---|
| 14 MHz | 1.5 | 12 + 1.5 + 2.5* | ~1.18 Msps |
| 14 MHz | 7.5 | 12 + 7.5 + 2.5 | ~600 ksps |
| 14 MHz | 23.5 | 12 + 23.5 + 2.5 | ~230 ksps |
| 14 MHz | 48.5 | 12 + 48.5 + 2.5 | ~120 ksps |
注:额外2.5周期用于同步和启动开销
// 示例:通过HAL库设置ADC1通道5的采样时间为23.5周期
ADC_ChannelConfTypeDef sConfig = {0};
sConfig.Channel = ADC_CHANNEL_5;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_23CYCLES_5; // 设置为23.5周期
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK)
{
Error_Handler();
}
什么时候该用长采样时间?当你面对高输出阻抗的信号源(比如某些温度传感器)时,就需要更长时间让电容充分充电。否则测出来的值会严重偏离真实情况。
工作模式的选择艺术
STM32F103 ADC支持多种工作模式,不同的组合会产生截然不同的行为特征:
- 单次模式 :启动一次就停下来,适合低频偶发采样
- 连续模式 :不停转换,适合高速流式采集
- 扫描模式 :按顺序遍历多个通道
- 间断模式 :分批次扫描,配合外部触发使用
stateDiagram-v2
[*] --> Idle
Idle --> Single_Conversion: 软件/外部触发
Single_Conversion --> Idle: 完成转换
Idle --> Continuous_Start: 连续模式使能 + 触发
Continuous_Start --> Ongoing_Conversion: 开始首次转换
Ongoing_Conversion --> Ongoing_Conversion: 自动重启
Ongoing_Conversion --> Idle: ADON=0关闭
Idle --> Scan_Init: 扫描模式使能 + 触发
Scan_Init --> Channel_Sequence: 按RANK顺序采集
Channel_Sequence --> Scan_Complete: 最后一通道完成
Scan_Complete --> Idle: 等待下次触发(非连续)
Scan_Complete --> Scan_Init: 若启用连续扫描
对于双通道示波器,最佳选择是“连续+扫描”模式,再配上DMA传输,真正做到CPU零干预。
| 模式 | CPU干预频率 | 数据吞吐能力 | 典型应用场景 |
|---|---|---|---|
| 单次模式 | 高 | 低 | 按键电压读取 |
| 连续模式 | 中(配合DMA) | 高 | 实时音频采集 |
| 扫描模式 | 中~高 | 中 | 多传感器监测 |
| 连续+扫描 | 低(需DMA) | 高 | 多通道同步示波器前端 |
外部触发联动:让定时器为你打工
想要实现严格等间隔采样?千万别依赖软件延时!最好的方法是让定时器自动触发ADC转换。
// 配置TIM2生成周期性触发信号(假设系统时钟72MHz)
TIM_MasterConfigTypeDef sMasterConfig = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 72 - 1; // 分频至1MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 100 - 1; // 周期100μs → 10kHz采样率
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Base_Init(&htim2);
sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; // 更新事件触发
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig);
HAL_TIM_Base_Start(&htim2); // 启动定时器
然后在ADC配置中指定触发源:
hadc1.Init.ContinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO;
从此以后,ADC就会像 clockwork 一样准时工作,完全不用CPU插手。而且由于是由同一个时钟源驱动,不存在累积误差,长期稳定性极高 🕰️
高精度采集的软硬件协同之道
你以为有了好的ADC就能拿到干净的信号?Too young too simple!实际工程中,噪声、干扰、温漂等问题会让你怀疑人生 😵💫
GPIO配置的坑你踩过几个?
最常见的错误就是忘记把引脚设为 ANALOG 模式!如果你不小心把它配成推挽输出……
GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; // 必须设为ANALOG!
后果有多严重?轻则引入额外漏电流,重则烧毁内部ESD保护二极管。曾经有工程师就是因为这一行代码写错,导致整批产品返修 💔
另外提醒几点:
- 不要在模拟输入端加外部上下拉电阻
- PCB走线尽量短且远离数字信号
- 使用独立模拟地并与数字地单点连接
- VREF+引脚外接低ESR陶瓷电容(1~10μF)
这些细节看似琐碎,却是决定成败的关键。
噪声抑制的四板斧
第一招:延长采样时间
特别是前级驱动能力弱的时候,适当增加采样周期能让电容充得更充分。
第二招:RC低通滤波
在ADC输入端串联100Ω电阻并并联0.1μF电容,构成截止频率约16kHz的硬件滤波器,有效抑制高频噪声。
第三招:软件平均法
对同一信号多次采样取均值,可显著提升ENOB(有效位数):
uint32_t adc_sum = 0;
for(int i = 0; i < 16; i++) {
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 10);
adc_sum += HAL_ADC_GetValue(&hadc1);
HAL_Delay(1);
}
uint16_t avg_value = adc_sum >> 4; // 相当于除以16
注意:这种方法不适合快速变化的信号,容易造成失真。
第四招:校准补偿
每次上电都执行一次ADC自校准,消除偏移误差:
HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);
这一步千万不能省!尤其是在温度变化较大的环境中,校准能带来质的飞跃。
构建你的第一台数字示波器
现在终于到了激动人心的时刻——把前面所有知识串起来,打造一台真正可用的示波器!
带宽与采样率的博弈
根据奈奎斯特准则,采样率至少要是信号最高频率的两倍。但现实中建议做到5~10倍以上:
| 参数 | 理论值 | 实际可用范围 | 备注 |
|---|---|---|---|
| ADC 时钟 | 14 MHz | ≤14 MHz | 来自 APB2/2 |
| 最大采样率 | ~1 MSPS | 100k–500k SPS | 受系统负载影响 |
| 可测带宽 | 500 kHz | 10–50 kHz | 模拟前端限制 |
看到了吗?理论值很美,现实很骨感。所以我们需要通过前端调理电路来弥补短板。
时间轴与电压轴的标定
要把原始ADC值变成有意义的物理量,必须做好两件事:
时间轴
由采样间隔决定。例如50kHz采样率,每点代表20μs。屏幕上每像素对应N个采样点,就可以实现“秒/格”的缩放功能。
电压轴
结合参考电压和探头衰减比换算:
float adc_to_voltage(uint16_t adc_val, float vref, uint8_t attenuation) {
return ((float)adc_val / 4095.0f) * vref * attenuation;
}
将来还可以加入自动量程切换,就像真正的示波器那样智能。
触发机制的灵魂作用
没有触发的波形就像海浪一样漂浮不定。我们需要一个稳定的起点:
#define TRIG_LEVEL 2048 // 中间电平触发(对应1.65V)
int detect_trigger(uint16_t* buffer, int len) {
for (int i = 1; i < len; ++i) {
if (buffer[i] > TRIG_LEVEL && buffer[i-1] <= TRIG_LEVEL) {
return i; // 返回上升沿位置
}
}
return -1;
}
配合状态机使用,就能实现“等待→捕获→显示→重置”的完整流程。
Keil开发环境搭建:工欲善其事,必先利其器
最后聊聊工具链。Keil μVision5至今仍是许多工程师的首选IDE,不仅因为它的编译效率高,更因为它对STM32的支持极为完善。
安装时记得:
- 用管理员权限运行
- 路径不要含中文或空格
- 安装STM32F1xx_DFP设备包
创建工程后,务必检查:
- 时钟配置是否正确(72MHz)
- 包含路径是否完整
- 宏定义是否齐全(如STM32F103xB)
编译选项建议:
- 调试时用-O0
- 发布时用-O2
烧录推荐ST-Link + SWD接口,速度快又稳定。
系统测试与持续优化
上线前一定要做全面测试:
- 输入标准信号验证幅值精度
- 改变频率观察混叠现象
- 长时间运行检查内存泄漏
发现问题及时优化:
- 优化LCD刷新算法
- 引入滑动滤波
- 调整中断优先级
最终目标是:2小时连续运行无死机,DMA传输成功率100%,波形显示稳定清晰 ✅
你看,一块小小的MCU,经过精心设计和层层优化,真的可以变成一台实用的测量工具。这不仅是技术的胜利,更是工程思维的体现。下次当你拿到一块开发板时,别再说“就这么点资源能干啥”,想想今天我们聊的内容——创造力才是真正的硬件 💡
简介:本项目以STM32F103微控制器为核心,设计并实现了一款简易示波器,涵盖信号采集、处理与显示全过程。STM32F103基于ARM Cortex-M3内核,具备高性能、低功耗及丰富外设,通过其内置ADC完成模拟信号到数字信号的转换,并利用Keil开发环境进行软件编程与调试。系统由硬件电路、系统初始化、用户功能代码和显示模块组成,支持实时波形显示,适用于基础电子测量场景。该项目融合了嵌入式开发与信号处理技术,是掌握STM32应用开发的典型实践案例。
4700

被折叠的 条评论
为什么被折叠?



