尧图精选

基于STM32G474与FreeRTOS的信号发生器实现:定时器触发DAC与DMA波形输出

🕒 发布时间:2026/9/16 11:49:32 📁 来源:尧图网络
简介面向嵌入式开发者的 STM32G474 FreeRTOS 信号发生器完整工程整合 ARM Cortex-M4 浮点运算能力与实时操作系统调度机制可用于学习波形生成、外设驱动和多任务协作。工程以 HAL 库为基础覆盖 DAC 模拟信号输出、定时器触发控制、中断处理以及 FreeRTOS 队列通信和任务划分并涉及正弦波、方波、三角波等常见波形的软件实现思路适合自动化、电子类学生或嵌入式工程师对照调试。资源共 139 个文件主体为 85 个 h 头文件和 42 个 c 源文件另含 CubeMX 配置文件、链接脚本、项目构建文件与简要说明文档压缩包仅 1.05MB可快速导入 IDE 进行编译验证。项目源码也考虑了电源管理和信号调理环节可作为完整信号发生器设计的起步参考已有 150 人学习下载通过研读其定时器与 DAC 配合、任务间数据传递和中断响应等典型实现能加深对 STM32G4 系列高性能微控制器及 FreeRTOS 实战应用的理解。1. STM32G474 配合 FreeRTOS 做信号发生器很多人第一反应是外置 DAC 更省事但 G474 内置的 12 位 DAC 在高级定时器触发和 DMA 循环搬运的加持下能稳定输出几 kHz 到几百 kHz 的扫频波形而 FreeRTOS 则负责把界面控制、波形刷新、输出更新拆成三个独立任务避免一个逻辑卡死拖垮整个输出。这套方案适合手上有 G474 开发板、想在几天内跑通正弦/方波/三角波输出同时又想保留后续扩展能力的嵌入式工程师。下文所有代码基于 HAL 库和 CMSIS-OS2 封装工程里涉及的stm32g4xx_hal_tim.c、stm32g4xx_hal_dac.c、tasks.c、queue.c都是绕不开的关键文件。2. 硬件链路TIM1、DAC、DMA 的握手关系与 FreeRTOS 任务划分2.1 为何必须用 TIM1 触发而不是定时器中断很多裸机教程会建议在 TIM 更新中断里向 DAC 写数据。这在低频1kHz时没有明显问题但随着输出频率提高CPU 会频繁进入中断波形开始抖动。G474 的高级定时器 TIM1 支持将更新事件作为 TRGO 输出DAC 也支持直接接收该触发源。这意味着每次定时器计数到 ARR 时DAC 自动锁存 DMA 送来的数据整个过程 CPU 不参与。只有在波形表需要更换时wavegen_task才介入。配置时需要保持触发源一致。下面这段初始化函数来自工程里的main.c精简注意sConfig.DAC_Trigger DAC_TRIGGER_T1_TRGO这一行如果改成软件触发DMA 将无法知道何时采样新数据。static void MX_DAC1_Init(void) { DAC_ChannelConfTypeDef sConfig {0}; dac1_handle.Instance DAC1; HAL_DAC_Init(dac1_handle); sConfig.DAC_SampleAndHold DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger DAC_TRIGGER_T1_TRGO; sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(dac1_handle, sConfig, DAC_CHANNEL_1); }对于DAC_SampleAndHoldG474 虽然支持采样保持模式来降低功耗但在连续波形输出时必须关闭否则 DAC 会在保持阶段断开输出波形出现台阶。选择DAC_TRIGGER_T1_TRGO后DAC 的更新节奏完全由定时器控制不需要软件干预。2.2 DMA 的循环搬运与内存对齐当 DAC 配置为定时器触发后DMA 的作用是把wave_table中的数据逐个搬到 DAC 的数据寄存器。工程里通常选择 DMA1 通道 2请求必须匹配DMA_REQUEST_DAC1_CH1。如果请求配置错误HAL_DAC_Start_DMA会一直失败。典型配置如下hdma_dac1_ch1.Init.Request DMA_REQUEST_DAC1_CH1; hdma_dac1_ch1.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_dac1_ch1.Init.PeriphInc DMA_PINC_DISABLE; hdma_dac1_ch1.Init.MemInc DMA_MINC_ENABLE; hdma_dac1_ch1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_dac1_ch1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_dac1_ch1.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma_dac1_ch1);DMA_CIRCULAR是最容易忽略的一项。在波形表大小为 256 点时DMA 搬运完最后一项会重新回到首地址持续输出周期波形。若误用 Normal 模式DAC 第一个周期之后的数据全部为零示波器上看到的是一个阶跃后维持在零的波形。内存对齐方面Cortex-M4 对半字访问没有强制对齐要求但 DMA 的突发传输需要缓冲区起始地址按 16 字节对齐。在 G474 上最好使用__ALIGNED(16)修饰波形表否则某些 DMA 配置下会触发总线错误。__ALIGNED(16) static uint16_t wave_table[WAVE_TABLE_SIZE];2.3 FreeRTOS 任务划分与优先级表裸机信号发生器通常用一个状态机处理按键和波形更新。但状态机最怕某个分支卡死比如等待串口输入时阻塞太久。FreeRTOS 可以按触发频率和实时性要求拆分任务。下面是我在 G474 上常用的配置任务名优先级职责接口wavegen_task7解析命令并更新波形表、配置定时器接收队列ui_task3按键扫描、串口命令解析发送队列monitor_task2周期检查堆栈水位与运行状态无wavegen_task优先级最高但它的阻塞点只在osMessageQueueGet等待命令时。收到命令后的计算和寄存器赋值都不会被抢占。ui_task里不能直接调用wavegen_set_frequency因为那会破坏队列模型。所有参数通过wavegen_queue_handle传递这样如果在调试中发现波形跳变可以首先筛查是否有其他任务擅自调用了 HAL 库的定时器函数。创建任务的 CMSIS-OS2 代码如下注意osPriorityHigh在 FreeRTOS 中是数值 7如果改用osPriorityRealtime数值 9可能会抢占定时器中断关联的处理反而增加调度抖动。osThreadId_t wavegen_task_handle; const osThreadAttr_t wavegen_task_attributes { .name wavegen, .stack_size 1024, .priority (osPriority_t) osPriorityHigh, }; wavegen_task_handle osThreadNew(wavegen_task, NULL, wavegen_task_attributes);.stack_size我通常保留 1024 字节因为sinf和浮点计算分配了不少栈帧。如果使用arm_math.h里的arm_sin_f32栈会小一些但标准库也够用。3. 波形数据生成查表、DDS、频率字与时基预分频3.1 查表法用空间换时间最直观的方式是把波形周期离散化为 N 个采样点每个点对应一个 DAC 数值。G474 的内部 SRAM 足够大一个 1024 点的正弦表也只占 2KB 左右所以查表法是默认选择。但是查表法存在一个隐藏问题在输出频率变化时为了保证波形周期完整DAC 的触发频率必须跟随变化。如果直接修改 TIM1 的自动重载值需要重新计算分频系数。这是 3.2 要讲的。先给出正弦表生成函数void wavegen_generate_sine(uint16_t amplitude) { for (uint32_t i 0; i WAVE_TABLE_SIZE; i) { float phase 2.0f * 3.1415926f * i / WAVE_TABLE_SIZE; float value amplitude * (0.5f 0.5f * sinf(phase)); wave_table[i] (uint16_t)(value 0.5f); } }0.5f 0.5f * sinf(phase)把正弦波从 [-1,1] 映射到 [0,1]这样输出的直流偏置为满量程的一半。如果你希望输出双极性信号后面需要外接加法电路和偏置调整而不是直接改这里。3.2 频率字与自动重载值的联动在信号发生器项目里“频率字”可以定义为 DAC 触发频率与期望输出频率的比值。简化关系为期望输出频率 TIM1 更新频率 / 波形表长度由于波形表长度固定调节频率的唯一手段就是修改 TIM1 更新频率。而更新频率由PSC和ARR共同决定TIM1 更新频率 定时器时钟 / ((PSC 1) * (ARR 1))因此32 位频率字可以这样定义当PSC 0时ARR 1 TIM_CLOCK / (f_out * WAVE_TABLE_SIZE)。显然ARR只有 16 位所以低于一定频率时必须增大PSC。下面这段代码将频率设置封装成无级调整接口void wavegen_set_frequency(uint32_t freq_hz) { uint32_t timer_clock 170000000; uint32_t psc 0; uint32_t arr; while (psc 65535) { arr timer_clock / ((psc 1) * freq_hz * WAVE_TABLE_SIZE) - 1; if (arr 65535) { break; } psc; } __HAL_TIM_SET_PRESCALER(htim1, psc); __HAL_TIM_SET_AUTORELOAD(htim1, arr); }这段代码在freq_hz较低时会从 0 开始增加预分频直到 ARR 落在 16 位范围内。在频率切换时 PSC 和 ARR 同时写入定时器会自动从下一个更新事件开始使用新参数不会产生不可预料的瞬时计数。但要注意如果freq_hz太高比如 1 MHz 且WAVE_TABLE_SIZE 256则PSC必须为 0ARR1只有约 0.66小于 1无法实现。所以最高输出频率受 DAC 触发速度限制。G474 的 DAC 建立时间约 2us建议单点触发频率不要超过 500kHz。也就是说256 点波形表时输出频率上限约为 1.9kHz实际工程中通常会降低表长度来换取高频性能但这会牺牲波形质量。3.3 用 DDS 思想提升频率分辨率如果只靠 PSC 和 ARR 调节频率低频段的频率分辨率并不均匀。更精细的做法是引入 DDS 相位累加器。虽然 G474 没有硬件 DDS但可以在定时器中断或 DMA 中断里做软件相位累加。常见实现是维持一个 32 位相位累加器phase_acc每个采样周期增加一个频率控制字freq_word然后取相位累加器的高位作为查表索引。#define PHASE_BITS 32 static uint32_t phase_acc 0; static uint32_t freq_word 0; void wavegen_dds_spin(void) { phase_acc freq_word; uint32_t index (phase_acc (PHASE_BITS - 10)) 0x3FF; // 1024 点表 DAC1-DHR12R1 wave_table[index]; }频率字计算函数如下这里假设 DAC 触发频率固定为 500kHzvoid wavegen_set_dds_freq(uint32_t freq_hz) { freq_word (uint32_t)(((uint64_t)freq_hz 32) / 500000); }DDS 的好处是频率分辨率远高于直接改 ARR但坏处是如果放在中断里执行会占用 CPU。由于 G474 的 FPU 支持 32 位乘加每次计算约 20 个周期在 500kHz 中断频率下占用 CPU 约 10%仍然可行。工程中是否同时保留查表和 DDS可以。低频时用 DDS 以获得平滑调频高频时用短查表模式减少相位噪声。这个切换正是 FreeRTOS 的用武之地wavegen_task根据目标频率决定进入哪种模式。3.4 方波与三角波的特殊处理方波如果用 DAC 输出边沿会受输出缓冲压摆率限制出现几百纳秒的爬坡。如果项目不要求非常陡的边沿直接用波形表填充是省事方案。要生成方波表void wavegen_generate_square(uint16_t amplitude) { for (uint32_t i 0; i WAVE_TABLE_SIZE; i) { wave_table[i] (i WAVE_TABLE_SIZE / 2) ? amplitude : 0; } }i WAVE_TABLE_SIZE / 2判断前半周期输出高电平后半周期输出低电平占空比固定 50%。如果希望调整占空比将判断条件改为i WAVE_TABLE_SIZE * duty / 100。三角波则可以通过分两段线性插值生成。注意三角波的峰值幅度应小于amplitude否则 DAC 接近饱和时线性度会下降。4. 队列、信号量与堆栈保护FreeRTOS 实时控制的正确姿势4.1 为什么不用全局变量传递参数如果把freq_hz定义为全局变量UI 任务直接写wavegen 任务直接读在单核裸机下没问题但 FreeRTOS 是抢占式调度wavegen 任务可能在读取 32 位变量的高 16 位后被切换走UI 任务随后写完低 16 位最终得到的是一个混合了前后两次写入的坏数据。所以任务间数据传递必须走队列或互斥量。工程中的队列是osMessageQueueNew创建的 CMSIS-OS2 对象它在 FreeRTOS 底层是队列长度可按需设置。以下定义放置在app.h中typedef enum { WAVEGEN_CMD_SET_WAVE 1, WAVEGEN_CMD_SET_FREQ, WAVEGEN_CMD_SET_AMPLITUDE, WAVEGEN_CMD_START, WAVEGEN_CMD_STOP } wavegen_cmd_t; typedef struct { wavegen_cmd_t cmd; uint32_t param; } wavegen_msg_t; osMessageQueueId_t wavegen_queue;创建队列并放入初始化函数wavegen_queue osMessageQueueNew(8, sizeof(wavegen_msg_t), NULL); if (wavegen_queue NULL) { Error_Handler(); }注意sizeof(wavegen_msg_t)要显式传入CMSIS-OS2 不像 FreeRTOS 原生 API 那样自动按指针大小复制必须给定消息大小。队列元素个数这里设为 8取决于 UI 操作频率。对按键应用来说8 足够如果接串口波形命令比如每秒发送几十条建议设为 32 以上否则osMessageQueuePut会返回osErrorResource。4.2 生产者与消费者的代码骨架UI 任务作为生产者负责把命令塞入队列void ui_task(void *argument) { wavegen_msg_t msg; for (;;) { uint32_t btn read_key(); if (btn KEY_FREQ_UP) { msg.cmd WAVEGEN_CMD_SET_FREQ; msg.param current_freq 100; osMessageQueuePut(wavegen_queue, msg, 0, 0); } else if (btn KEY_WAVE_SEL) { msg.cmd WAVEGEN_CMD_SET_WAVE; msg.param WAVE_SINE; osMessageQueuePut(wavegen_queue, msg, 0, 0); } osDelay(10); } }生产者这里使用超时时间 0即不等待队列空位。这样 UI 操作是异步的速度再快也不会阻塞任务。如果某个命令在队列满时被丢弃用户多按一次即可符合信号发生器手动操作的预期。消费者wavegen_task则用osWaitForever阻塞等待只有在队列有消息时才被唤醒保证 CPU 空闲时波形任务不消耗任何时间片。下面是其核心void wavegen_task(void *argument) { wavegen_msg_t msg; for (;;) { if (osMessageQueueGet(wavegen_queue, msg, NULL, osWaitForever) osOK) { switch (msg.cmd) { case WAVEGEN_CMD_SET_FREQ: wavegen_set_frequency(msg.param); break; case WAVEGEN_CMD_SET_WAVE: wavegen_configure_waveform((wave_type_t)msg.param); break; case WAVEGEN_CMD_START: HAL_DAC_Start_DMA(dac1_handle, DAC_CHANNEL_1, (uint32_t *)wave_table, WAVE_TABLE_SIZE); break; case WAVEGEN_CMD_STOP: HAL_DAC_Stop_DMA(dac1_handle, DAC_CHANNEL_1); break; default: break; } } } }这里有个细节HAL_DAC_Start_DMA的第三个参数是uint32_t*类型但波形表是uint16_t*直接转换后 DMA 搬运长度应该按半字计算。HAL 库内部对 G474 的 DMA 传输长度设为采样点数每个点仍按半字搬运。因此在调用时不要写成WAVE_TABLE_SIZE * 2否则 DMA 会搬运两倍数据波形表后半部分成为垃圾。4.3 信号量与互斥量的选型信号量适合做“事件通知”比如一个任务等待另一个任务完成波形表更新后再刷新 LCD。但信号量没有所有权概念低优先级任务可以先持有再释放高优先级任务等待时就会发生优先级反转。在信号源里LCD 和按键扫描任务可能同时访问 I2C 总线互斥量的优先级继承机制能避免这类问题。所以如果只是保护临界区用osMutexNew如果是通知“数据准备好了”用二进制信号量。4.4 堆栈溢出检测在 FreeRTOS 开发中任务栈爆掉不会立刻崩溃而是先覆盖相邻内存可能把 DMA 描述符写坏。开启 FreeRTOS 的堆栈检测#define configCHECK_FOR_STACK_OVERFLOW 2使用vApplicationStackOverflowHook打印出错任务名void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void) xTask; __disable_irq(); for(;;) { } }configCHECK_FOR_STACK_OVERFLOW 2会在任务切换时检查栈指针是否越界并调用钩子函数。调试时还可以用uxTaskGetStackHighWaterMark查看剩余栈空间。如果发现某个任务余量小于 100 字节就把stack_size翻倍。G474 的 SRAM 有 128KB这点消耗无所谓。5. 用示波器验证波形质量抖动、毛刺与频率误差的排查顺序5.1 第一步确认时基参数与触发源把示波器探头夹在 PA4 上DAC1 输出设置示波器为 DC 耦合垂直档位 1V/div水平档位根据目标频率调整。先用默认正弦波验证。若波形上叠加周期性的亮点说明 DMA 与 DAC 的触发时序存在偏移。此时检查 TIM1 的MasterOutputTrigger是否为TIM_TRGO_UPDATE很多 HAL 默认配置没有修改它导致 DAC 一直处于等待触发状态或触发频率异常。sClockSource TIM_CLOCKSOURCE_INTERNAL; sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIM_OC_ConfigChannel(htim1, sConfigOC, TIM_CHANNEL_1);TIM_TRGO_UPDATE对应触发源是定时器更新事件频率等于 TIM1 时钟除以(PSC1)*(ARR1)。如果你用的是 PWM 模式触发触发点会落在某个比较值上频率相同但相位不同也会导致波形起始相位的改变但质量没有区别。5.2 毛刺来源去耦电容与参考电压G474 的 VDDA 和 VREF 引脚必须独立去耦。若只靠开发板上的 100nF输出正弦波会看到明显的 1MHz 噪声。正确做法是在 VREF 对地并联 1uF 陶瓷电容和 10nF 电容并在 DAC 输出引脚到地加 10pF 电容作为简单低通。这个 10pF 不会影响几十 kHz 信号但能滤除采样瞬间的电荷注入毛刺。5.3 频率误差与晶振选择当波形频率比设定值偏高 1% 左右基本可以确定系统时钟源用的是 HSI。G474 的 HSI 出厂校准精度只有 ±1%且随温度漂移。如果对频率精度有要求换上 8MHz 外部晶振并将 PLL 配置为合法频率。使用 HSE 后把代码中的timer_clock 170000000改成从SystemCoreClock读取避免后续更换晶振时还要改宏。5.4 用余辉模式观察抖动示波器调到余辉模式将时间档位放到信号周期的 10 倍左右观察过零点的线条宽度。如果线条宽度小于一个像素说明定时器抖动可以接受。如果线条明显发虚优先降低 FreeRTOS 的系统节拍频率对 TIM1 中断的抢占。configTICK_RATE_HZ默认 1000在高优先级任务频繁使用osDelay(1)时每次 tick 都会触发 PendSV可能干扰 TIM1 的触发时间。可以把configTICK_RATE_HZ调到 100并在 UI 任务中用osDelay(10)代替osDelay(1)。另外在 DMA 中断服务函数中不要调用任何阻塞 API。HAL_DAC_IRQHandler中只做标志位清除和回调波形数据更新交给任务。把实时参数计算全部放到任务里中断只做搬运通知测出来的抖动通常会明显改善。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →