资讯动态

STM32 DAC+ADC+串口+MATLAB实时绘图:正弦波生成与数据采集链路设计

发布时间:2026/9/12 18:36:52 来源:尧图企业网站定制
简介面向STM32嵌入式开发者的完整实验工程利用数模转换模块连续输出正弦波配合模数转换同步采集并通过MATLAB实时绘图适合学习数据采集与处理链路的中级工程师。资源为zip压缩包大小约10.53MB共261个文件。文件类型以c源文件和h头文件为主同时包含较多编译中间文件、固件输出和MATLAB脚本其中源码配置了串口、定时器、模数转换、数模转换及直接存储器访问等外设方便对照学习。资源已有1045人学习浏览。通过分析工程代码与脚本可以掌握正弦波数据生成、连续搬运、采样触发、串口发送和实时波形绘制等实现方法并可从底层初始化一路看到上层显示适合作为嵌入式数据采集与可视化开发的参考模板。这一完整流程对嵌入式数据采集与波形监测类项目有较强借鉴意义。1. STM32 DA连续输出正弦波实时绘图的瓶颈不在DAC很多人第一次拿到「DA输出正弦波 AD采集 MATLAB实时绘图」的需求第一个方案都是让ADC采一个点、串口发一个点、MATLAB画一个点结果波形不是卡顿就是收不到完整数据。问题几乎从来不在DAC和ADC的初始化上而在于正弦波一个周期要出几百个点每个点持续只有几微秒通信链路根本承载不了这个逐点传输速率。这条链路真正值得拆解的是DAC的查表与DMA输出、ADC的定时触发与DMA搬运、串口帧协议设计以及MATLAB端的回调绘图。写这篇文章就是想把这套「DA→AD→UART→MATLAB」的完整闭环讲透给正在用STM32做信号发生器、数据采集卡、传感器仿真通道的工程师也给学生课设和毕设一个可以直接落地的工程化方案。2. 查找表与DMA配合让正弦波从DAC连续输出2.1 为什么不实时调用 sinf()而是用查表和相位累加器DAC输出一个正弦波。如果直接在初始化时生成一张包含一个周期数据的查找表运行过程中通过相位累加器查表输出就完全绕开了运行时的浮点运算。这是嵌入式里生成周期波形最常用的做法。相位累加器的思路很简单uint32_t phase 0; uint16_t step (uint32_t)SINE_TABLE_N * target_freq / dac_update_freq; phase step; if (phase SINE_TABLE_N) { phase - SINE_TABLE_N; } dac_out sine_table[phase];每次DAC更新时phase加上一个固定的步长step越界后回绕查表得到当前输出值。改target_freq只需要重算step不需要重新生成正弦表。工程上更常用的是DMA循环模式把整张表交给DMA去搬CPU完全不参与逐点输出正弦波可以无限循环下去。查表法生成正弦表时需要关注幅度和直流偏置。STM32F4的DAC是12位参考电压3.3V时1个LSB约等于0.806mV。如果直接让正弦波幅度接近满幅波形顶部会贴近3.3V、底部贴近0V边缘有削波风险而且很多ADC的输入范围并不希望被顶到电源轨。我一般会把幅度设为1400 LSB左右、直流偏置设在2048 LSB这样实际输出电压范围大约在0.52V到2.78V之间既有足够动态范围又留有余量。sine_table的元素类型用uint16_t和DAC的12位右对齐格式保持一致。2.2 DAC与TIM6的初始化以及一个周期的点数怎么定一个周期的点数N决定了波形粗糙程度。N取256时每周期256个台阶N取2048时波形更平滑但DAC更新频率随之变成正弦波频率的2048倍。以1kHz正弦波为例N256时DAC更新频率是256kHz每个输出平台持续约3.9μs这个速率对F4的DAC完全没有压力N2048时更新频率超过2MHz虽然DAC本身能跟上但后续ADC采样和串口传输的带宽压力会成倍增加。所以N256是性能和带宽之间的合理折中。DAC连续输出正弦波的关键配置如下配置项典型值说明DAC分辨率12位右对齐输出0~4095对应0~VrefDAC触发源TIM6 TRGO由定时器更新事件触发DAC更新输出缓冲使能降低输出阻抗驱动ADC输入DMA模式循环模式DMA自动循环搬运正弦表DMA数据宽度半字16bit匹配uint16_t正弦表TIM6是基本定时器没有输出比较通道但它的TRGO正好可以用于触发DAC和ADC是这类应用最合适的时基源。F407上TIM6时钟来自APB1通常为84MHz。我的配置是PSC3、ARR81定时器频率为84MHz/(4×82)≈256.1kHz和理论值256kHz误差不到0.05%。很多人习惯先设一个很大的PSC再凑ARR这样频率离散误差会变大在需要精确频率的场景下并不合适。htim6.Init.Prescaler 3; htim6.Init.Period 81; htim6.Init.CounterMode TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(htim6); sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfigSynchronization(htim6, sMasterConfig);TIM_TRGO_UPDATE表示每个定时器更新事件产生一个触发脉冲这个脉冲同时送给DAC和ADC。接下来配置DAC通道然后启动DMADAC_ChannelConfTypeDef sConfig {0}; sConfig.DAC_SampleAndHold DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger DAC_TRIGGER_T6_TRGO; sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1); HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, SINE_TABLE_N, DAC_ALIGN_12B_R);HAL_DAC_Start_DMA是核心调用传入正弦表地址、点数和对齐方式后DMA会在每次TIM6触发时把一个点的数据从sine_table搬到DAC的数据寄存器搬完自动循环。CPU只需要启动这一次之后正弦波就持续输出了。如果你在F407上查DMA映射DAC1的DMA请求对应DMA1 Stream5、Channel7不同型号要对照参考手册DMA请求映射表确认。2.3 让DAC和ADC共用一个TIM6触发源保证每一拍对齐DAC已经由TIM6的TRGO触发ADC采集同样可以用TIM6的TRGO触发。这样做的意义在于DAC输出更新和ADC采样在同一个定时器事件下发生波形每个周期的采样点数和相位都是严格对齐的。如果让ADC自由连续采样ADC转换完成时刻相对DAC更新时刻是随机抖动的采出来的信号会有额外相位噪声波形看起来会有毛刺。hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T6_TRGO; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.ContinuousConvMode DISABLE;ExternalTrigConv选择外部触发源为TIM6 TRGOExternalTrigConvEdge选择上升沿触发。ContinuousConvMode要关掉因为ADC不需要自由运行完全由定时器节拍驱动。这里有一个时序细节DAC在更新事件发生时把新值输出到引脚ADC在相同触发源下经过内部采样保持阶段后再开始转换实际采到的是DAC当前输出的电压。整体存在一个DAC更新周期的延迟但对波形显示没有实际影响MATLAB端绘制时不需要做额外补偿。如果不想让ADC和DAC严格同步也可以给ADC单独配一个更高频率的定时器做过采样但那样做会引入相位不确定性。在只做波形监视的场景下DAC、ADC共用TIM6是最简单也最可控的方案。3. AD采集的触发时机、采样周期与DMA搬运3.1 ADC转换时间算一下确认不会拖垮整条链路ADC触发频率和DAC更新频率一致每3.9μs要完成一次转换。F4的ADC时钟来自APB2APB2为84MHz时必须分频到42MHz或更低才能满足ADC最大36MHz的时钟限制。常用的分频系数是4得到21MHz的ADC时钟。ADC转换时间由采样周期和转换位数共同决定。12位分辨率下转换周期固定为12个ADC时钟周期加上配置的采样周期总转换时间为(采样周期 12) / ADC时钟。我把采样周期设为3个周期总转换时间为15个ADC时钟周期即15/21MHz≈714ns。这个时间远小于DAC输出平台的3.9μs余量接近5倍不会出现ADC还没转换完就被下一次触发覆盖的情况。ADC配置项参数说明分辨率12位数据范围0~4095触发源TIM6 TRGO与DAC同源采样周期3周期转换时间约714ns数据对齐右对齐HAL库默认DMA模式循环模式持续搬运到内存缓冲区如果ADC转换时间接近甚至超过DAC更新周期每次触发到来时ADC还在忙触发事件会被丢弃波形上会出现随机缺点和毛刺。判断方法是在转换完成中断里加一个计数器比较中断频率和TIM6触发频率是否一致。慢了就要调大ADC时钟分频或减小采样周期而不是去动DAC。3.2 循环DMA模式与「采满一帧」的判断ADC转换结果通过DMA搬运到内存同样用循环模式源地址是ADC数据寄存器目标地址是一块uint16_t数组。数组长度取一个周期点数的整数倍比如256点时直接分配256个元素DMA采满256点后自动回到头部覆盖写。这个覆盖行为既是优点也是风险优点是不用手动管理缓冲区缺点是CPU处理速度跟不上DMA写入速度时正在处理的数据会被新数据覆盖。处理方法是在DMA完成中断里立刻把数据打包发出去不要在主循环里轮询。#define ADC_BUF_N 256 uint16_t adc_buf_12b[ADC_BUF_N]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { pack_and_send(adc_buf_12b, ADC_BUF_N / 2); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { pack_and_send(adc_buf_12b[ADC_BUF_N / 2], ADC_BUF_N / 2); } }HAL_ADC_ConvHalfCpltCallback在DMA传输一半时触发HAL_ADC_ConvCpltCallback在传满整块时触发。这样每采满128个点就发一帧数据帧率被提高一倍MATLAB端图形刷新也更平滑。启动ADC DMA只需要一行HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf_12b, ADC_BUF_N);配置DMA时外设数据宽度设为半字内存数据宽度也设为半字方向是外设到内存。ADC1在F407上使用的DMA是DMA2 Stream0、Channel0。如果和串口发送的DMA通道冲突现象通常是启动后DMA不工作或数据错乱所以动手前先查手册里的DMA请求映射表。3.3 12位数据如何换算成电压以及有符号数据的处理ADC采集回来的是12位无符号码值范围0到4095对应0V到3.3V。换算关系为v code × 3.3 / 4095。如果DAC输出带直流偏置正弦波在1.65V上下摆动ADC读到的是这个叠加直流后的电压不是以0为中心的波形。这里有一个和信号极性相关的细节。如果采集的是经过偏置的双极性信号需要把12位无符号数按有符号数处理后再换算电压。MATLAB里把无符号数转有符号数不能直接做减法而应该用typecastraw_u16 uint16(0xE000); % 示例高位置1代表负数 signed_val typecast(raw_u16, int16);typecast不改变底层字节只改变解释方式这是MATLAB里处理有符号ADC数据最规范的做法。直接调用int16(raw_u16)会把数值截断结果完全不对。另外串口传输时为了省带宽会把12位数据右移4位变成高8位再发送。这样损失的是低4位的精度量化噪声约1/256对波形显示来说肉眼几乎分辨不出来。MATLAB端收到8位码值后直接除以255再乘以3.3即可得到电压不需要关心原始12位到底丢了什么。4. 串口链路帧协议、位宽压缩与丢帧判断4.1 为什么不能每个采样点都上传回到开头说的那个问题。DAC更新频率是256kHz也就是每秒钟要处理256000个点。一个点按2字节计算就是512KB/s的数据量而115200bps的串口有效负载只有大约11.5KB/s差了四十多倍。哪怕把波特率拉到921600有效负载约115KB/s仍然远远不够逐点传输。所以「ADC采一个点、串口发一个点」的方案在数学上就不成立。正确的思路是以帧为单位传输。每个DMA半满中断或满中断触发一次打包把128个采样点拼成一个帧发出去MATLAB每收到一帧就刷新一次波形。这种方式下的「实时」是准实时帧和帧之间有几十毫秒的间隔但只要图形刷新率不低于30fps视觉上就是流畅的。如果还要保留12位精度每帧字节数翻倍帧率会明显下降波形刷新变慢所以我习惯用8位量化换刷新率。4.2 一个适合实时绘图的帧结构与组包实现帧协议要解决三个问题帧头识别、数据长度判定、错误丢弃。直接发二进制最怕数据里出现0xAA或0x55被误判为帧头所以协议里要加一个校验和来兜底。我用的帧格式如下字节偏移内容说明00xAA帧头110x55帧头22~3点数N小端低字节在前4~(4N-1)数据区每组1字节8位量化4N校验和数据区每字节累加取低8位不选择在数据区用换行符做终止符是因为二进制数据流里可能出现0x0A会被MATLAB的readline或串口终端误认为换行导致帧错位。用固定长度加校验和的方式对错位有很强的恢复能力。static uint8_t tx_frame[FRAME_MAX_LEN]; void pack_and_send(uint16_t *buf, uint16_t n) { if (huart1.gState ! HAL_UART_STATE_READY) { return; /* 上一次发送还没结束直接丢弃这一帧 */ } uint8_t ck 0; tx_frame[0] 0xAA; tx_frame[1] 0x55; tx_frame[2] (uint8_t)(n 0xFF); tx_frame[3] (uint8_t)((n 8) 0xFF); for (uint16_t i 0; i n; i) { tx_frame[4 i] (uint8_t)(buf[i] 4); /* 12bit去低4位 */ ck tx_frame[4 i]; } tx_frame[4 n] ck; HAL_UART_Transmit(huart1, tx_frame, n 5, 10); }先用gState判断串口是否空闲避免和上一次发送重叠。发送方式这里用了阻塞式的HAL_UART_Transmit因为每帧260字节、波特率921600时传输时间约2.3ms和DMA半满中断的间隔足够匹配。如果换成DMA发送需要在发送完成回调里维护一个忙标志复杂度更高帧率收益却不大。buf[i] 4这个操作等价于buf[i] / 16把12位ADC码值压缩成高8位。发送端和接收端约定好这个量化规则MATLAB端就能还原出精度足够的波形。组包时校验和的计算范围只覆盖数据区不包括帧头这样帧头出错时直接丢弃校验和错误也能独立判断数据区是否完整。4.3 如何快速判断串口有没有丢帧丢帧在串口链路里是常态问题是要快速定位丢在哪一环。MATLAB端收到一帧后先检查帧头再检查校验和任何一个不匹配都算丢帧。统计丢帧率的方法是在回调里维护两个计数器一个记录总帧数一个记录有效帧数。if mod(sum(data), 256) raw(end) validCount validCount 1; end如果丢帧率超过5%优先检查STM32端是不是在HAL_UART_Transmit还没结束时又调用了一次。gState判断能挡住这种情况但如果中断优先级配置不当ADC DMA中断会抢占串口发送流程数据照样会乱。UBRR值算错是另一个常见原因921600波特率在84MHz的APB2时钟下要确认分频后误差在合理范围内误差超过2%就会出持续误码。如果丢帧只发生在刚启动时多半是MATLAB端serialport的缓存里残留了上一次运行的旧数据在打开串口后先执行一次flush清空缓存。串口线超过20厘米、又没有共地时高速波特率下丢帧是必然的这时候先换短线并确认两边GND连通比调代码更快。5. MATLAB 实时绘图最后一步serialport 回调与帧率验证5.1 用serialport的字节回调对上一帧曲线做增量更新MATLAB从R2019b开始推荐用serialport对象代替旧版serial时序更稳定也支持字节数触发的回调。和旧版相比serialport不会因为串口缓存里字节不够就卡住配合configureCallback的字节计数模式可以做到每收满一帧调用一次回调。这个机制和STM32的DMA半满中断思路是一致的。N 256; frameLen N 5; s serialport(COM10, 921600, Timeout, 2); s.BufferSize frameLen * 8; flush(s); fig figure(Color, w); ax axes(Parent, fig); plotHandle plot(ax, 1:N, zeros(1, N), LineWidth, 1.2); ylim(ax, [0 3.3]); grid(ax, on); configureCallback(s, byte, frameLen, (src, ~) onFrame(src, plotHandle, N));回调函数里每帧做四件事读一帧、校验帧头、校验长度和校验和、更新绘图数据。绘图用set(plotHandle, YData, ...)而不是再次调用plot否则图形对象句柄不断重建性能会快速恶化。drawnow limitrate把画面刷新速率限制在合理范围不会因为绘图阻塞串口回调。function onFrame(src, plotHandle, N) raw read(src, src.BytesAvailableFcnCount, uint8); if raw(1) ~ 0xAA || raw(2) ~ 0x55 return; end len double(raw(3)) double(raw(4)) * 256; if len ~ N return; end data double(raw(5 : 5 N - 1)); if mod(sum(data), 256) ~ double(raw(end)) return; end set(plotHandle, YData, data / 255 * 3.3); drawnow limitrate; end这里read的字节数用的是src.BytesAvailableFcnCount在回调触发时这个值至少等于frameLen保证能一次读出一整帧。帧头或校验和不对时直接丢弃不做重同步。串口链路上偶尔丢一帧对波形显示几乎没有影响重同步逻辑带来的复杂度远大于收益。5.2 帧率和真实刷新率的关系以及验证方法判断整条链路是否正常最简单的办法是把每秒有效帧数显示出来。在回调里给基础工作区的计数器加一再开一个定时器每秒读一次如果计数稳定在30以上说明链路吞吐是够的如果只有个位数先看是不是USB转串口芯片在921600下不稳定降到460800再对比一次。这个降速测试能快速区分是上位机瓶颈还是物理链路瓶颈。如果换用APM32这类国产兼容芯片HAL层的代码基本不用改重点要核对DMA请求映射和ADC触发源枚举这两个字段在不同型号上最容易出现差异。串口发送和ADC采集都依赖DMA时还要检查两个DMA通道是否属于同一个DMA控制器、有没有请求冲突。最后有一个值得注意的相位问题。DAC在TIM6更新事件发生时才把新值输出到引脚ADC几乎同时触发采样但ADC实际采到的是当前这一拍DAC输出的值所以整帧数据相对正弦表原始相位有1个DAC周期的延迟。肉眼观察波形完全看不出来但如果要拿这个数据和理论正弦波做逐点比对需要把MATLAB里的X轴左移一个采样点或者STM32组包时从正弦表第1点开始发送。把每秒帧数显示在figure窗口标题上观察刷新率稳定在30fps左右且CPU占用不高这条DA→AD→UART→MATLAB链路就可以放心用了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价