简介:面向STM32与MATLAB联合开发的嵌入式实战资料包,围绕数模转换模块连续输出正弦波、模数转换同步采集以及上位机实时绘图展开,适合需要学习ARM单片机模拟外设、直接存储器访问与串口通信的开发者与硬件工程师。资源共261个文件,压缩包约10.53MB,包含C与H源码文件、Keil工程文件、hex/axf/o编译产物、MATLAB脚本、文本说明及调试配置文件;不同格式对应源码阅读、固件烧写、数据分析和二次移植等不同使用场景。已有1045人学习下载,整套内容可串联起正弦波查表生成、直接存储器访问循环发送、模数采样配置、串口或串行总线通信和MATLAB实时绘图整个实验链路,对理解嵌入式模拟信号处理和数据可视化有直接帮助。文件类型虽多但目录结构清晰,适合按模块逐层拆解,是配套课程设计或竞赛作品时的可复用参考工程。
1. STM32 DA 模块连续正弦波与 AD 采集、Matlab 实时绘图到底难在哪
多数人第一次看到“STM32 DA模块连续输出正弦波信号与AD采集并配合Matlab实时绘图”这个题目,会以为难点是正弦波生成。真做起来恰恰相反:正弦波用查表加 DMA 很快就能跑通,真正的坑在后面——DA 输出更新频率、AD 采集触发时刻、串口数据帧和 Matlab 绘图循环必须串成一条确定性的数据链。时序稍微错位,屏幕上看到的就是一条会“漂”的乱波。
这篇文章把链路按 DA 输出、AD 同步采集、串口上传、Matlab 实时绘图四段拆开,每段给出必调参数和可复现代码。适合正在做波形发生器、阻抗测量或电机控制前级激励的工程师,也适合拿这块板子做毕业设计的人。后面所有配置以 STM32F1/F4 的通用外设和 STM32Cube HAL 为例,其他系列只需对照时钟树和 DMA 请求号平移。
2. STM32 DA 模块连续输出正弦波信号:查表、定时器与 DMA 参数
2.1 为什么首选“正弦查表 + 定时器触发 + DMA 循环”
直接在主循环里调用HAL_DAC_SetValue扫表,CPU 占用和时间抖动都会让波形变脏;定时器中断里逐点写 DAC 也一样,中断响应延迟会导致更新沿不整齐。常见做法是用一个定时器的 TRGO 事件触发 DAC 转换,再用 DMA 把正弦查找表按循环模式搬进 DAC 数据寄存器。
这种方案有两个优点:一是 CPU 完全不参与每个点的搬运,DAC 更新频率只由定时器决定;二是「连续」有硬件保证,DMA 循环模式搬完表最后一个点后会自动回到表头,中间没有一条指令缝隙。正弦表点数 N 直接决定波形质量:N 越大,DAC 输出的阶梯越小,但同一触发频率下正弦波频率会按 1/N 下降。比如定时器触发频率 1MHz,N=128 时正弦波只有 7.8125kHz,N=256 时降到 3.90625kHz。
2.2 用 CubeMX 配置 DA 模块、定时器和 DMA 的最小参数
下面是一份能跑通的最小配置,不是在列“唯一答案”,而是照这些值填基本能避开“DAC 不输出”的问题。定时器选择上没有硬性要求,F1/F4 上挑一个没被占用的通用定时器即可,我这里用 TIM2。
| 配置位置 | 推荐值 | 说明 |
|---|---|---|
| DAC 输出缓冲 | Enable | 驱动能力更强,外接高阻负载或示波器不会明显塌陷 |
| DAC 触发源 | Timer 2 Trigger Out event | 由 TIM2 更新事件触发 DAC 取下一个点 |
| DMA Mode | Circular | 循环搬运,保证波形不中断 |
| DMA Direction | Memory To Peripheral | 从正弦表搬到 DAC 数据寄存器 |
| DMA Memory Address Increment | Enable | 每搬完一个点,内存地址前进一个元素 |
| DMA Peripheral Address Increment | Disable | DAC 寄存器地址固定 |
| DMA Data Width | Half Word | 12 位 DAC 数据用 uint16_t 承载 |
| TIM2 Clock Source | Internal Clock | 直接使用 APB 定时器时钟 |
| TIM2 Prescaler | 0 | 不分频,方便算频率 |
| TIM2 Period | 71 | 72MHz / (0+1) / (71+1) = 1MHz 触发 |
| TIM2 Trigger Output (TRGO) | Update Event | 供 DAC 做外部触发 |
其中最容易漏的是 TIM2 的 TRGO 没改成 Update Event。CubeMX 里 Timer 配置页默认 TRGO 可能是 Disabled,不改的话 DAC 得不到触发脉冲,输出一直停在 0V 附近。
2.3 生成正弦查找表与 STM32 启动代码
正弦表不需要手算,下面这段 Python 生成 128 点、12 位右对齐的表:
import math N = 128 # 每周期点数 mid = 2047.5 # 12位DAC中点,范围0~4095 amp = 2047.5 # 峰值幅度,不顶到0/4095 for i in range(N): v = int(round(mid + amp * math.sin(2 * math.pi * i / N))) print(f"0x{v:04X},", end="") if (i + 1) % 8 == 0: print()逻辑不复杂:DAC 是 12 位,输出范围 0~4095,中心点取 2048 附近,正弦波从 0 到峰值再回到 0,不会出现负值。幅度如果想留余量,把amp改成比如 1500,后续 AD 采集时也不容易削顶。
STM32 端启动代码只有三行是核心:
/* sine_table[128] 由上方脚本生成 */ static uint16_t sine_table[128] = { 0x0800, 0x0863, 0x08C6, /* ... */ }; HAL_TIM_Base_Start(&htim2); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, sizeof(sine_table) / sizeof(sine_table[0]), DAC_ALIGN_12B_R);参数说明:DAC_ALIGN_12B_R表示把 uint16_t 的低 12 位写到 DAC 右对齐数据寄存器;DMA 外设地址固定为 DAC 的数据保持寄存器,内存地址自动递增。sizeof表达式只是为了让你不用手动写点数,实际等价于 128。如果你的 MCU 带 D-Cache,比如 STM32H7,sine_table放在 D-Cache 管理的内存区域时还要做 Cache 维护,F1/F4 不需要。
频率计算公式是:
f_trigger = TIM_CLK / ((PSC+1) * (ARR+1)) f_sine = f_trigger / N72MHz 主频、PSC=0、ARR=71 时,f_trigger=1MHz,N=128,正弦波正好 7812.5Hz。想做其他频率,先定 N,再用公式反推 ARR。N 做波形观察取 128 就够,如果后续要观察谐波,再提到 256 或 512。
3. 让 AD 采集与 DA 输出共用一个定时器:触发同步、采样时间和 DMA 缓冲
3.1 为什么 AD 采集要跟 DA 输出共用同一个触发源
如果让 ADC 自由运行,也就是 Continuous Conversion 一直开,采集点相对正弦波的相位是随机的,Matlab 里画出来会看到波形左右“游动”。正确做法是让 ADC 也由同一个定时器 TRGO 触发更新。这样每个定时器事件同时给 DAC 和 ADC 一个同步信号,DAC 输出表里的第 i 个点,ADC 在同一个时间基点上采集,后续每一帧数据都能一一对应起来。
在 CubeMX 里,DAC 触发源选 Timer 2 Trigger Out event,ADC 的 External Trigger Conversion Source 也选 Timer 2 Trigger Out event。不是用两个定时器去凑时间,而是两个外设直接共用一条触发线。这样 MCU 内部硬件省去了“软件对齐”这一步,波形起始相位、AD 采到的点序都是确定的。
3.2 ADC 配置参数与采样时间计算
AD 采集这里有一组推荐参数,和自由跑 ADC 的习惯完全不同:
| ADC 参数 | 推荐值 | 说明 |
|---|---|---|
| Resolution | 12-bit | 与 DAC 位数对齐,直接看原始码值 |
| Continuous Conversion | Disabled | 每次由定时器触发,不连续自跑 |
| Scan Conversion | Disabled | 只采一个通道,不需要扫描 |
| External Trigger Source | Timer 2 Trigger Out event | 与 DAC 共用触发源 |
| Sampling Time | 13.5 Cycles | 对低频正弦足够稳定,余量也大 |
| DMA Mode | Circular | ADC 结果不断搬进内存,不占用 CPU |
| DMA Direction | Peripheral To Memory | 外设是 ADC 数据寄存器 |
| DMA Data Width | Half Word | ADC 12 位结果放到 uint16_t |
采样时间不能随便拉满,要算一下单次转换时间。STM32 的 12 位 ADC,单次转换时间大约是:
t_conv = (采样周期 + 12.5) / ADC时钟频率假设 ADC 时钟 12MHz,采样时间 13.5 Cycles,那么 t_conv = 26 / 12MHz ≈ 2.17us。只要定时器触发周期比它长,就不会出现上一次转换还没完成、下一次触发已经到来的情况。把定时器触发频率控制在 50kHz,也就是周期 20us,余量很足。相比之下,前面 DAC 示波场景里的 1MHz 触发对普通 ADC 来说太快,做完整 AD 采集链路时我一般先降回 50kHz。
3.3 AD 的 DMA 缓冲与起始相位对齐
下面这段代码把 ADC DMA 缓冲设为 256 个点,对应 N=128 的两个完整正弦周期:
#define DAC_N 128 #define ADC_FRAME_LEN (DAC_N * 2) /* 256个点,两个整周期 */ static uint16_t adc_buf[ADC_FRAME_LEN]; void start_sine_and_adc(void) { /* 先停定时器,避免配置过程中产生乱触发 */ HAL_TIM_Base_Stop(&htim2); HAL_ADC_Start_DMA(&hadc, (uint32_t *)adc_buf, ADC_FRAME_LEN); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, DAC_N, DAC_ALIGN_12B_R); /* 再启动定时器,DAC 和 ADC 同时开始工作 */ HAL_TIM_Base_Start(&htim2); }顺序是关键:先停定时器,再启动 ADC DMA 和 DAC DMA,最后开定时器。如果反过来,定时器已经跑了,ADC DMA 还没准备好,前几个触发会丢失,Matlab 首帧会有一段残缺波。adc_buf长度取正弦表点数的整数倍,是为了让每一帧数据都从同一个相位开始,Matlab 拼接时不会出现周期跳变。
ADC DMA 搬完一整帧后会触发HAL_ADC_ConvCpltCallback。实际工程里应该在这里置一个标志位,主循环看到标志再发串口,不要去读还没有写完的adc_buf。如果想连续发送不丢帧,就用双缓冲:一个缓冲给 DMA,另一个缓冲在串口发送,两个角色轮流切换。
4. STM32 到 Matlab 实时绘图的数据通路:二进制数据帧、校验和串口波特率
4.1 为什么不用文本行传输,而用二进制帧
不少人习惯用printf("%d\n", adc_val)打印,Matlab 里再用str2num或sscanf解析。几百个点做演示没问题,但 ADC 一帧 256 点、一秒 195 帧时,文本转换会浪费大量时间,而且十进制字符串比原始字节多占一倍空间。
常见做法是发二进制帧:ADC 值按 uint16_t 小端字节序发出去,帧头固定,长度明确,Matlab 直接按字节读,不需要做字符串拆分。二进制帧还能顺便带校验,检测到 USB 转串口丢字节时能主动丢弃坏帧,而不是把错数据画成跳变尖峰。
4.2 AD 数据帧格式与 STM32 发送实现
我建议的帧格式很简单,一共四段:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定 0xAA 0x55,用于对同步 |
| 样本数 n | 2 字节 | uint16 小端,本次 ADC 数据点数 |
| ADC 数据 | 2*n 字节 | 每个样本 16 位,小端 |
| 校验 | 1 字节 | 帧头到最后一个数据字节的逐字节异或 |
STM32 端发送代码可以写成这样:
#define ADC_FRAME_LEN (128 * 2) static uint8_t txbuf[2 + 2 + 2 * ADC_FRAME_LEN + 1]; static volatile uint8_t uart_tx_busy = 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_tx_busy = 0; } } void send_adc_frame(uint16_t *data, uint16_t n) { while (uart_tx_busy) {} /* 工程中建议加超时 */ uint8_t csum = 0; uint16_t idx = 0; txbuf[idx++] = 0xAA; txbuf[idx++] = 0x55; txbuf[idx++] = n & 0xFF; txbuf[idx++] = (n >> 8) & 0xFF; csum = 0xAA ^ 0x55 ^ txbuf[2] ^ txbuf[3]; for (uint16_t i = 0; i < n; i++) { uint8_t lo = data[i] & 0xFF; uint8_t hi = data[i] >> 8; txbuf[idx++] = lo; txbuf[idx++] = hi; csum ^= lo ^ hi; } txbuf[idx++] = csum; uart_tx_busy = 1; HAL_UART_Transmit_DMA(&huart, txbuf, idx); }逻辑说明:txbuf[3]存长度的高字节,csum的初值已经包含帧头和长度两个字节,逐字节异或整个数据区后作为帧尾。接受方如果算出来的异或和帧尾不一致,说明串口丢字节,这一帧应该直接丢弃。while (uart_tx_busy)是确保上一次 DMA 没有发送完之前不覆盖txbuf,实际工程要加超时,否则 DMA 异常时可能死等。
波特率选得多大取决于采样率。用 50kHz 采样率、每点 2 字节、帧头每 256 点占 4 字节,实际数据量约 100kByte/s。921600 波特率按 8N1 换算约 115200 Byte/s,刚好留出余量。如果采样率翻倍到 100k,就必须换 2M 以上波特率,或者改用 USB 虚拟串口。
4.3 Matlab 端串口的 3 个关键参数
Matlab 端用serialport打开串口后,先改三个地方:
s = serialport("COM8", 921600); % 波特率必须和 STM32 一致 s.ByteOrder = "little-endian"; % 匹配 STM32 小端字节序 s.InputBufferSize = 8192; % 大于一帧长度,避免读取时溢出 s.Timeout = 2;ByteOrder是文档里容易忽略的参数。STM32 默认小端,Matlab 的serialport默认也是 little-endian,但如果你改过ByteOrder,read(...,"uint16")会把字节顺序读反,波形变成乱跳。InputBufferSize只需要大于最大帧长,不必设得过大,8192 对 517 字节的帧已经足够。Timeout设成 2 秒,既能容忍串口分段到达,又不会在断开时卡死脚本。
读取单帧的函数可以这样写:
function data = read_adc_frame(s) while true b1 = read(s, 1, "uint8"); if b1 ~= 0xAA continue; end b2 = read(s, 1, "uint8"); if b2 == 0x55 break; end end len = read(s, 2, "uint8"); n = double(len(1)) + 256 * double(len(2)); data = read(s, n, "uint16"); chk = read(s, 1, "uint8"); % 实际使用时可把chk与计算值比对 end这段代码先找帧头 0xAA 0x55,再读长度和 n 个 uint16 数据。chk变量读出来后可以根据帧格式里的异或规则验证,校验失败就返回空数组或继续读下一帧。演示场景不做状态机也能跑,生产级建议把字节流组装改成逐字节状态机,避免一帧解析失败后整条流卡死。
5. Matlab 实时绘图的三个进阶设置:环形缓冲、刷新频率与自检
5.1 环形缓冲区加只更新 YData
不要在实时绘图循环里反复调用plot重建曲线。正确做法是循环外先建一条线,循环里只改YData。显示缓冲区用一维环形数组,ST 端来多少数据就写多少,画图时只取最后 2048 个点:
BUF = 8192; M = 2048; buf = zeros(1, BUF); ax = gca; h = plot(ax, 1:M, zeros(1, M)); ylim(ax, [0 4095]); grid(ax, "on"); wptr = 1; while true d = read_adc_frame(s); m = numel(d); idx = mod(wptr:wptr+m-1, BUF); idx(idx == 0) = BUF; buf(idx) = d; wptr = mod(wptr + m - 1, BUF) + 1; sIdx = mod(wptr-M:wptr-1, BUF); sIdx(sIdx == 0) = BUF; set(h, "YData", buf(sIdx)); drawnow limitrate; endset(h,"YData",...)只更新曲线数据,不重建图形对象,是实时绘图提升帧率最有效的一招。drawnow limitrate会丢弃来不及刷新的中间帧,把刷新率限制在 20fps 左右,观察低频正弦足够。如果想更顺滑,可以去掉limitrate,但 CPU 占用会明显上升。
5.2 显示点数与刷新频率怎么配
显示段 M 最好取每周期点数 N 的整数倍。N=128,M=2048 就是 16 个完整周期,Matlab 端每次看到的相位起点固定,做 FFT 时频谱泄漏也更小。M 在 1024 到 4096 之间比较合理,再大只会让曲线密集到看不清,同时拖慢刷新率。
实时绘图里,ylim、grid这些命令放在循环外。循环内每帧调用axis auto会触发坐标范围重算,开销非常大。如果需要显示 ADC 原始码值,直接把YData范围固定在 0~4095,观察 DAC 输出加直流偏置后的完整波形时不用反复缩放坐标轴。
5.3 用采样率换算时间轴并做 FFT 自检
定时器触发频率决定了 ADC 采样率。前面配置里 PSC=0、ARR=1439、TIM_CLK=72MHz,那么采样率是 50kHz,每两个采样点间隔 20us。Matlab 画时间轴时直接乘以这个值,不要用xlim里的点数假装是时间。
验证整个链路是否同步,最有效的办法是对当前显示段做一次 FFT:
fs = 50000; seg = double(buf(sIdx)); seg = seg - mean(seg); % 去直流,只看信号频率 Y = fft(seg); f_axis = (0:numel(seg)-1) / numel(seg) * fs; [~, k] = max(abs(Y(2:numel(seg)/2))); fprintf("实时频率: %.2f Hz\n", f_axis(k));如果定时器配置是 50kHz 采样、每周期 128 点,这里应读到约 390.625Hz。偏了几个 Hz,通常不是代码问题,而是开发板的 HSE 晶振不是精确 8MHz,导致整个定时器时钟和 ADC 采样率一起偏。幅度上,ADC 原始码值换算电压是code / 4095 * 3.3,直接测 DAC 输出时中点约 1.65V,峰峰值接近 3.3V;外接负载后波形塌陷,就要查 DAC 输出缓冲和负载阻抗了。把 PSC、ARR、N、fs 都定义成一组宏,Matlab 端只读配置头,以后换采样率不用逐行改绘图程序。
本文还有配套的精品资源,点击获取