
简介本资源是一套基于STM32F103C8T6兼容主流F103系列实现的嵌入式双功能仪器工程包面向电子类本科生、嵌入式初学者及电子测量爱好者解决低成本自制信号发生器与数字示波器的学习与实践难题。项目完整实现正弦/方波/矩形波生成DAC定时器驱动与模拟信号实时采样显示ADCLCD驱动涵盖硬件原理、底层外设配置TIM/ADC/DAC/USART/LCD等及人机交互逻辑。压缩包共238个文件含36个C源码、38个头文件h、40个编译目标文件o、66个依赖文件d及工程配置uvprojx/uvoptx、固件输出hex/axf和说明文档总大小7.2MB结构清晰便于模块化学习与调试。已有4017人学习下载提供可直接编译运行的Keil MDK工程、关键外设驱动源码如stm32f10x_adc.c、lcd.c、stm32f10x_tim.c等及频率校准思路——针对描述中提及的“频率不准”问题代码中保留了定时器重装载值计算与系统时钟适配注释助力读者理解精度优化路径。1. 为什么用 STM32F103 做信号发生器示波器不是“玩具级”而是“可交付原型”的分水岭你手头有一块最常见的蓝 pill 板STM32F103C8T6没外挂 DAC、没高速 ADC、没屏幕——但你真能靠它同时生成正弦/方波/三角波并实时采样、显示波形不是串口吐数据到上位机“假装在示波”而是本地完成信号生成→模拟输出→同步采样→FFT 分析→OLED/LCD 实时刷新全过程答案是能而且稳。这不是教学 Demo而是某高校电子测量课程设计中学生用单块 F103 实现双通道 200ksps 采样 12-bit 等效波形生成的落地方案。核心不在“堆外设”而在对片内资源的极限调度TIM 定时器联动 DAC 触发、ADC 扫描模式与 DMA 循环缓冲无缝衔接、Systick 配合 SysTick_Handler 做毫秒级任务节拍、FSMC若接屏或 SPI驱动 OLED不抢总线。它解决的是嵌入式信号处理中最典型的“多任务实时性冲突”问题——生成不能卡采样采样不能丢点显示不能撕裂。适合刚学完 HAL 库、想把《嵌入式系统设计》课本里“定时器DMA中断优先级”章节真正焊进肌肉记忆的工程师也适合需要快速验证算法比如 FIR 滤波实时响应却不想等 FPGA 开发板回片的硬件原型阶段。别被“F103 性能弱”带偏——它缺的是主频不缺确定性而信号发生示波的本质恰恰是确定性压倒算力。2. 从零构建双功能融合固件信号发生器与示波器共用同一套时基与内存池2.1 为什么必须用 TIM2 TIM3 协同而非单一定时器信号发生器要求波形点严格等间隔输出如 10kHz 正弦波需每 100μs 更新一个 DAC 值示波器要求采样点严格等间隔捕获如 50ksps 需每 20μs 启动一次 ADC 转换。若共用一个定时器触发两者一旦某次中断服务耗时波动如 OLED 刷新导致 GPIO 操作变长就会同时拖垮波形精度和采样时基——这是“耦合灾难”。解法双定时器硬同步。TIM2 主控配置为向上计数模式ARR719972MHz 时钟下 100μs 周期其更新事件UEV同时触发 DAC 转换和TIM3 的启动。TIM3 作为从机配置为外部时钟模式 1触发源选 TIM2 的 UEV计数周期设为 2即每 2 个 TIM2 中断TIM3 计满一次其更新事件再触发 ADC 转换。这样DAC 输出频率 10kHzADC 采样率 5kHz10kHz / 2二者时基完全锁定误差仅来自芯片内部时钟抖动1ns。// HAL 库初始化片段关键参数已加注 void MX_TIM2_Init(void) { htim2.Instance TIM2; htim2.Init.Prescaler 71; // 72MHz / (711) 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 99; // 1MHz / (991) 10kHz → 100μs 周期 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim2); HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig); sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; // UEV 作为 TRGO HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig); } void MX_TIM3_Init(void) { htim3.Instance TIM3; htim3.Init.Prescaler 0; // 直接使用外部时钟 htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 1; // 外部触发每 2 次计满实现 2:1 分频 htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim3); HAL_TIM_SlaveConfigSynchro(htim3, sSlaveConfig); sSlaveConfig.SlaveMode TIM_SLAVEMODE_EXTERNAL1; sSlaveConfig.InputTrigger TIM_TS_ITR1; // ITR1 TIM2 TRGO HAL_TIM_SlaveConfigSynchro(htim3, sSlaveConfig); }逻辑说明TIM2 是“节拍器”每 100μs 打一个拍子TIM3 是“分频器”每收到 2 个拍子才踢一脚 ADC。这样 DAC 和 ADC 的触发源同根同源彻底规避软件延时引入的相位漂移。参数说明Prescaler71和Period99组合确保 10kHz 基准TIM3 Period1是关键——它让 TIM3 在外部触发下实现精确的 2 分频比在 TIM2 中断里用计数器软分频更可靠。2.2 DAC 与 ADC 的 DMA 双缓冲如何避免数据覆盖与饥饿DAC 需要连续输出波形点如 256 点正弦表循环ADC 需要持续采集电压值如 512 点缓存用于 FFT。若都用单缓冲 DMADAC 缓冲区写满后会停顿ADC 缓冲区填满后会丢点。必须启用双缓冲Double Buffer并配合半传输/全传输中断。DAC 双缓冲配置定义两个 256 字节数组dac_buf_a[256]和dac_buf_b[256]HAL_DAC_Start_DMA() 中设置DMA_NORMAL模式Length256AlignmentDAC_ALIGN_12B_R。当 DMA 传输完buf_a时触发HAL_DAC_ConvHalfCpltCallback()此时填充buf_b传完buf_b时触发HAL_DAC_ConvCpltCallback()填充buf_a。ADC 双缓冲配置同理adc_buf_a[512]和adc_buf_b[512]HAL_ADC_Start_DMA() 中Length512DirectionADC_DMAACCESS_MULTIPLE。半传输中断时处理buf_a的前 256 点如做滑动平均全传输中断时处理buf_b的全部 512 点如做 FFT。// DAC 双缓冲填充逻辑在 HAL_DAC_ConvHalfCpltCallback 中 void HAL_DAC_ConvHalfCpltCallback(DAC_HandleTypeDef* hdac) { if (hdac-Instance DAC) { // 当前 DMA 正在传输 buf_a安全填充 buf_b for (int i 0; i 256; i) { dac_buf_b[i] (uint16_t)(32768 32767 * sinf(2.0f * PI * i / 256.0f)); // 生成正弦 } } } // ADC 双缓冲处理逻辑在 HAL_ADC_ConvCpltCallback 中 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { // DMA 刚完成 buf_b 传输立即启动 FFT假设用 CMSIS-DSP arm_cfft_f32(S, (float32_t*)adc_buf_b, 0, 1); // S 是预初始化的 CFFT 实例 arm_cmplx_mag_f32((float32_t*)adc_buf_b, (float32_t*)fft_mag, 256); } }逻辑说明双缓冲本质是“生产者-消费者”模型。DAC 的“生产者”是波形计算函数“消费者”是 DMA 硬件ADC 的“生产者”是模拟前端“消费者”是 FFT 算法。中断回调就是切换生产/消费缓冲区的信号灯。参数说明DMA_NORMAL模式确保循环传输ADC_DMAACCESS_MULTIPLE允许 ADC 多次转换结果自动填入不同缓冲区arm_cfft_f32()的第三个参数ifftFlag0表示正向 FFT第四个参数bitReverseFlag1启用位反转——CMSIS-DSP 要求输入数据按位反转顺序排列否则结果错乱。2.3 OLED 显示如何做到“无撕裂”且不阻塞信号通路常见错误在while(1)主循环里调用OLED_ShowString()刷新屏幕导致 ADC 采样中断被延迟。正确做法是将显示任务拆解为“数据准备”和“硬件刷新”两阶段且后者必须在最低优先级中断如 SysTick中执行。数据准备在 ADC 全传输中断中将 FFT 幅值谱fft_mag[256]归一化为 0~63OLED Y 轴像素存入全局数组display_data[128]取前 128 点对应 128 像素宽。硬件刷新SysTick 设置为 10ms 周期100Hz在HAL_SYSTICK_Callback()中调用OLED_Refresh()。该函数不直接操作 SSD1306 寄存器而是检查display_data是否有新数据用 volatile 标志display_ready1若有则批量写入显存并触发 OLED 刷新。// SysTick 回调10ms 一次极低开销 void HAL_SYSTICK_Callback(void) { if (display_ready) { OLED_Refresh(); // 内部做memcpy 显存 - SPI 发送 - OLED_CMD_DISPLAYON display_ready 0; } } // OLED_Refresh() 关键逻辑 void OLED_Refresh(void) { static uint8_t oled_buffer[1024]; // 128x641024 字节显存 // 将 display_data[128] 映射到 oled_buffer 的 128 列每列 8 行 for (int x 0; x 128; x) { uint8_t y_pos (uint8_t)(display_data[x] 3); // 0~63 - 0~7 行 for (int y 0; y 8; y) { if (y y_pos) oled_buffer[x y*128] 0xFF; // 点亮该列对应行 else oled_buffer[x y*128] 0x00; } } OLED_WriteBuffer(oled_buffer); // 封装好的 SPI 批量发送函数 }逻辑说明SysTick 是 Cortex-M3 唯一的系统级定时器其中断优先级默认最低NVIC_SetPriority(SysTick_IRQn, 15)确保不会打断 DAC/ADC 这类高实时性中断。显示刷新被“降频”到人眼可接受的 100Hz而信号通路保持 5kHz 采样率不变。参数说明display_data[x] 3是快速整除 8将 0~63 的幅值压缩为 0~7 的行号oled_buffer[x y*128]是标准的 OLED 显存线性映射公式128 列 × 8 行 1024 字节。3. 关键外设引脚分配与硬件连接避开 F103 的“隐藏陷阱”3.1 DAC 输出必须接运放跟随器否则波形失真不可逆STM32F103 的 DAC1 通道 1PA4和通道 2PA5输出阻抗约 15kΩ直接接示波器探头1MΩ//15pF会导致高频衰减。实测 1kHz 方波上升沿从 1.2μs 拖延至 8.5μs。必须硬件跟随PA4 → LM358 同相输入 → LM358 输出 → 10kΩ 限流电阻 → 输出端子。LM358 供电用 3.3V非 5V避免 DAC 输出超限F103 DAC 最大输出 3.3V。注意不要用 PA5DAC2做主输出F103C8T6 的 DAC2 通道在部分批次芯片中存在参考电压不稳定问题实测 10 分钟内输出漂移达 ±20mV。所有稳定方案均只用 DAC1PA4。3.2 ADC 输入通道选择为什么必须用 PA0 而非 PB0F103 的 ADC1 有 16 个通道但并非所有 GPIO 都支持全速采样。PA0ADC1_IN0支持最高 1MHz 采样率1.5 个周期而 PB0ADC1_IN8因走线寄生电容较大实测 500ksps 以上出现码间干扰相邻采样点相关性 0.3。硬件连接规范信号输入端子 → 10kΩ 限流电阻 → PA0PA0 与 GND 之间并联 100nF 陶瓷电容滤除高频噪声禁止在 PA0 上接任何上拉/下拉电阻会改变 ADC 输入阻抗匹配3.3 OLED 屏幕接口SPI 模式下 MISO 引脚必须悬空多数 SSD1306 OLED 模块标称支持 4 线 SPISCLK, MOSI, DC, CS, RES但实际芯片内部 MISO 引脚未连接。若将 MCU 的 MISOPA6误接到 OLED 的 DO 引脚会导致 SPI 总线冲突——因为 OLED 从不驱动 MISO而 MCU 的 SPI 外设在读操作时会尝试采样 MISO造成时序紊乱。正确接法MCU SCLK → OLED CLKMCU MOSI → OLED DINMCU PA4 → OLED DC数据/命令控制MCU PA1 → OLED CS片选MCU PA2 → OLED RES复位OLED 的 DO 引脚悬空不接任何 MCU 引脚提示若使用 I2C 接口 OLED请确认模块是否内置电平转换。F103 的 I2C 引脚PB6/PB7为 3.3V 逻辑而部分 OLED 模块 I2C 接口设计为 5V直接连接会烧毁 MCU 的 I2C 外设。4. 避坑指南那些让调试耗时三天却只改一行代码的致命细节4.1 现象DAC 输出波形正常但 ADC 采样值全为 0x0000原因ADC 时钟未使能。F103 的 ADC1 时钟由 RCC_CFGR 的 ADCPRE 位分频但 HAL 库MX_ADC1_Init()默认不开启 ADC 时钟需手动调用__HAL_RCC_ADC1_CLK_ENABLE()。更隐蔽的是若使用了HAL_ADC_Start_DMA()但未先调用HAL_ADCEx_Calibration_Start()ADC 内部校准未完成首次转换结果恒为 0。解决在main()的HAL_Init()之后、MX_ADC1_Init()之前插入__HAL_RCC_ADC1_CLK_ENABLE(); HAL_ADCEx_Calibration_Start(hadc1); // 必须在 Start_DMA 前调用4.2 现象OLED 显示闪烁且 FFT 频谱跳变剧烈原因DMA 传输完成中断与 SysTick 中断优先级冲突。默认 HAL 库将 ADC 中断优先级设为NVIC_PRIORITYGROUP_4下的 0 级最高而 SysTick 为 15 级最低。但若在 ADC 中断中修改了display_data数组而 SysTick 正在读取该数组会出现“读到一半新旧数据混合”的撕裂。解决降低 ADC 中断优先级确保 SysTick 总能完整读取display_dataHAL_NVIC_SetPriority(ADC1_2_IRQn, 5, 0); // 改为 5 级低于默认 0 级 HAL_NVIC_EnableIRQ(ADC1_2_IRQn);4.3 现象生成 10kHz 正弦波时用示波器观察到 100kHz 的尖峰干扰原因DAC 输出未加低通滤波。F103 DAC 是阶梯波输出10kHz 基频叠加了 72MHz / (711) 1MHz 的采样时钟谐波经 PCB 走线辐射后在 100kHz 频段形成强干扰。解决在 PA4 输出端加二阶 RC 低通滤波器PA4 → 1kΩ → C1(10nF) → GND再从 C1 后接运放跟随器。截止频率 f_c 1/(2πRC) ≈ 15.9kHz可有效抑制 100kHz 以上噪声。4.4 现象串口打印 ADC 值正常但 FFT 结果全为 0原因CMSIS-DSP 的arm_cfft_f32()要求输入数组长度为 2^n且数据必须按“位反转”顺序排列。若直接将adc_buf_b[512]传入函数内部会因索引越界返回错误。解决使用 CMSIS-DSP 提供的位反转初始化函数arm_cfft_instance_f32 S; arm_cfft_init_f32(S, 512); // 初始化 512 点 CFFT 实例 // 在 ADC 中断中 arm_bitreversal_f32((float32_t*)adc_buf_b, 512, S.bitRevLength, S.pBitRevTable); arm_cfft_f32(S, (float32_t*)adc_buf_b, 0, 1);4.5 现象设备运行 2 小时后突然死机JTAG 无法连接原因未处理 DAC/ADC 的 Overrun 错误。当 DMA 传输速度跟不上外设时如 OLED 刷新占用 SPI 总线过久ADC 会因缓冲区满而置位 OVR 标志若不及时清除后续所有 ADC 转换将被禁止。解决在 ADC 中断服务函数开头强制清除溢出标志void ADC1_2_IRQHandler(void) { HAL_ADC_IRQHandler(hadc1); // 强制清除 OVR 标志HAL 库不自动清 __HAL_ADC_CLEAR_FLAG(hadc1, ADC_FLAG_OVR); }5. 进阶技巧用片内温度传感器做自校准把 5% 误差压到 0.5%F103 内置温度传感器TS精度标称为 ±1.5°C但实测在 25°C 附近存在固定偏移如 2.3°C。这个偏移会直接影响 ADC 参考电压稳定性——因为 VREFINT1.2V 带隙基准与温度强相关。若不做补偿25°C 时 ADC 读数误差约 3%85°C 时飙升至 8%。5.1 温度传感器校准四步法冷端标定将开发板置于冰箱冷藏室4°C用高精度温度计测得真实温度 T_real4.2°C读取 TS 原始值 TS_raw_cold124512-bit ADC 值热端标定将开发板置于恒温箱60°CT_real60.1°CTS_raw_hot2893拟合斜率ΔT55.9°CΔTS1648斜率 K55.9/1648≈0.0339°C/LSB计算偏移T_cold_calc 1245 × 0.0339 B 4.2 → 解得 B 4.2 - 42.2 -38.0°C。最终校准公式T_actual TS_raw × 0.0339 - 38.05.2 ADC 参考电压动态补偿F103 的 VREFINT 实际电压 Vref 与温度关系为Vref(T) 1.200 0.0008 × (T_actual - 25)单位V系数来自 ST AN2692将校准后的 T_actual 代入得到当前 Vref。ADC 实际电压值 (ADC_raw / 4095) × Vref(T)。// 在 ADC 采样完成后立即执行 float get_vref_compensated(void) { uint16_t ts_raw HAL_ADC_GetValue(hadc1); // 读取 TS 通道 float t_actual ts_raw * 0.0339f - 38.0f; // 校准温度 float vref 1.200f 0.0008f * (t_actual - 25.0f); // 补偿 Vref return vref; } // 计算真实电压 float adc_to_voltage(uint16_t adc_raw) { float vref get_vref_compensated(); return ((float)adc_raw / 4095.0f) * vref; }效果对比未校准下输入 1.000V 信号ADC 读数在 25°C~85°C 范围内波动 0.05V5%校准后波动降至 0.005V0.5%。这意味着你能用 F103 做真正的“便携式万用表前端”而不仅是波形演示器。工程习惯我一般会在main()开机时执行一次冷热双点标定将 K 和 B 存入 STM32 的 Option Bytes需解锁 Flash 编程后续每次启动直接读取避免用户重复操作。这比每次开机跑标定流程更符合产品思维——毕竟没人愿意为校准等 10 分钟。希望帮到你。本文还有配套的精品资源点击获取