波形发生识别系统开发日志 —— G431 波形生成

本文只写 G431 这一半的事. 波形怎么造出来, 怎么发出去

F401 那边的采集和识别另开一篇

工程叫 Wave1, 代码都在 Core/Src 下

G431功能综述

G431 在这套系统里是个”信号源”. 它的活儿很单纯: 吐波形, 而且要用一种接收端能对得上的方式吐.

最后定了 5 种: 正弦、方波、三角、锯齿、阶梯. 前四种是常规操作, 阶梯波是特意加的, 后面会说为什么.

最初的方案不是这样. 一开始正弦走 DAC, 方波走 TIM3 的硬件 PWM 从 PA6 出来, 两种波形两个引脚, 各走各的. 这个方案被我自己毙掉了, 原因在下面「所有波形都挤到一个引脚上」那一节.

文件清单

文件 角色 关键内容
wavegen.c 波形表生成 5 种波形, 全走 PA4, 改表即换波形
main.c 主入口 按键切波、串口命令、justfloat 打包发送
dac.c DAC1 配置 G4 特有, 外设侧必须 WORD 对齐
adc.c ADC1 配置 单通道 PA1, T2_TRGO 触发, DMA 循环
tim.c TIM2 / TIM3 TIM2 = 100 kHz TRGO, TIM3 = 1 kHz PWM(保留未用)
usart.c USART1 921600 8N1, PC4 = TX / PA10 = RX
gpio.c GPIO PB0 按键上拉输入
dma.c DMA + NVIC DMAMUX1 + DMA1, 两个通道中断
stm32g4xx_it.c 中断向量 DMA1_CH1 / CH2 处理

整体思路

先做一张表, 然后让 DMA 按节拍把表里的数一个个喂给 DAC.

1
2
3
4
5
     TIM2                     DAC1                  PA4
每 100 µs 报一次 ──TRGO──▶ 取一个数 ──DMA──▶ 输出电压
▲
│
s_table[100] ← 只改这里

这样安排有几个好处:

  • CPU 几乎不参与. 表建好之后 DMA 自己循环搬, CPU 想干嘛干嘛
  • 换波形代价为零. 只是把表内容原地改掉, 外设一行都不用重配
  • 频率准. 节拍来自硬件定时器, 不受程序跑得快慢影响

四个外设的分工

TIM2, 节拍器

它不产生波形, 只负责打拍子

1
2
3
4
5
6
7
/* Core/Src/tim.c —— MX_TIM2_Init() */
htim2.Instance = TIM2;
htim2.Init.Prescaler = 0; /* 不分频, 直接用 170 MHz */
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 1699; /* 数到 1699 归零, 所以是 1700 个计数一轮, 计数周期=1700/1.7e8=1e-5s */
...
sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; /* 关键 */

TIM_TRGO_UPDATE 的意思是: 每次计数器溢出时, 往 TRGO 这根内部信号线上发一个脉冲.

算一下:

1
170 MHz ÷ (0+1) ÷ (1699+1) = 170 000 000 ÷ 1700 = 100 000 Hz = 100 kHz

也就是每 10 µs 一个脉冲. DAC 和 ADC 都需要依赖其节拍

DAC, 数模转换器

负责把一个数字变成对应电压

用的是 DAC1 的通道 1, 输出引脚固定是 PA4(芯片内部定死)

12 位的意思是写进去 0~4095, 输出 0~3.3 V:

1
2
3
   0  →  0.000 V
2048 → 1.650 V ← 中点
4095 → 3.300 V

它只能保持一个数, 所以需要有人不停给它新数, 这是 DMA 负责的内容

触发源在 dac.c 里配:

1
2
3
/* Core/Src/dac.c —— MX_DAC1_Init() */
sConfig.DAC_Trigger = DAC_TRIGGER_T2_TRGO; /* 由 TIM2 的 TRGO 触发 */
sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; /* 必须开, 否则带不动负载 */

于是就有了这个换算:

1
2
DAC 更新率 = TIM2 频率 = 100 kHz
输出波形频率 = 100 kHz ÷ 表长 100 = 1 kHz

5 种波形全是 1 kHz, 因为共用同一个 TIM2, 表长都是 100. 换波形只换表内容不改表长, 所以频率恒定

DMA, 自动搬运工

不用 CPU 插手, 自动把数据从内存搬到外设

传统笨办法是 CPU 每隔 10 µs 醒一次写寄存器, 这样 CPU 就被绑死了. 用 DMA 之后只要告诉它三件事

  • 从哪搬: s_table 数组
  • 搬到哪: DAC1 通道 1 的数据寄存器
  • 搬多少怎么搬: 搬 100 个, 搬完从头再来(循环模式)

USART1, 把波形发出去

引脚 PC4 = TX, PA10 = RX, 921600 8N1, 协议用 justfloat. 这块单独讲, 见后文

波形表生成

统一公式

5 种波形没有各写一套代码, 而是统一成这个形式:

1
2
/* Core/Src/wavegen.c —— WaveGen_FillTable() 中的核心一行 */
v = WAVE_DAC_MID + amp * s;

WAVE_DAC_MID 是 2048, 12 位中点, 对应 1.65 V. amp 是幅度, 单位是码值. s 是形状函数, 范围 -1.0 ~ +1.0.

每种波形的区别仅仅是 s 怎么算. 加中点、限幅、写表这些完全共用. 想加新波形, 在 switch 里加个 case 写一行 s 就够了.

完整函数长这样:

不使用fmin, fmax单纯因为懒得引用了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* Core/Src/wavegen.c —— WaveGen_FillTable() */
static void WaveGen_FillTable(WaveKind_t kind, uint16_t amp)
{
uint32_t i;

/* 幅度限幅, 留一点余量, 避免撞到 0/4095 后顶部被削平 */
if (amp > 2047U)
{
amp = 2047U;
}

for (i = 0U; i < WAVE_LEN; i++) /* WAVE_LEN = 100 */
{
float s; /* 形状函数, 范围 -1.0 ~ +1.0 */
float v;

switch (kind)
{
/* ... 各波形的 s, 见下面各小节 ... */
default:
s = 0.0f;
break;
}

/* 统一公式, 中点 + 幅度 × 形状 */
v = (float)WAVE_DAC_MID + (float)amp * s + 0.5f;
/* ↑ +0.5 是为了四舍五入 */

/* 夹到 12 位范围, 防止浮点误差越界 */
if (v < 0.0f) { v = 0.0f; }
if (v > 4095.0f) { v = 4095.0f; }

s_table[i] = (uint16_t)v;
}
}

幅度上限取 2047 而不是 2048, 是因为 v = 2048 + amp*s, 如果 amp 取 2048 而 s 取到 +1, 会算出 4096 超出 12 位范围, 顶部被削平. 取 2047 正好让最高点落在 4095.

正弦波

直接套正弦函数. 这是最标准的波形, 主要用来检查有没有失真、削顶

// 由于对于精度要求不高, 使用float类型即可

1
2
3
4
/* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SINE */
case WAVE_SINE:
s = sinf(2.0f * 3.14159265f * (float)i / (float)WAVE_LEN);
break;
部分 含义
2.0f * 3.14159265f 一个完整周期是 2π
(float)i / (float)WAVE_LEN 把 i 从 0~99 归一化成 0.0~0.99
两者相乘 得到 0 ~ 2π 均匀分布的相位
sinf(...) 求正弦, 结果正好 -1 ~ +1

i 从 0 走到 99, 相位走完一个完整周期. 第 100 个点就是下一周期的第 0 个点, 所以表长 100 是自洽的.

实测表内容 248 ~ 3848(上下各248留白), 跨度 3600 码值, 也就是 2.901 V.

方波

前半个周期给 +1, 后半个周期给 -1.

1
2
3
4
/* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SQUARE */
case WAVE_SQUARE:
s = (i < (WAVE_LEN / 2U)) ? 1.0f : -1.0f;
break;

WAVE_LEN / 2 是 50, 所以 i = 0~49 输出 3848, i = 50~99 输出 248.

边沿是在一个采样点之内完成的跳变. 表里第 49 个点是 3848, 第 50 个点是 248, DAC 在 10 µs 内直接从高切到低.

如果在采集端看到方波边沿附近有过冲或振铃, 不要以为电路坏了. 原因有两个: DAC 输出后没接任何滤波器, 直接连到 ADC; 另外示波器探头或杜邦线本身有寄生电感电容. 快速边沿处出现振铃是正常现象.

实测 248 ~ 3848, 跨度 3600, 也就是 2.901 V.

三角波

先线性升到顶, 再线性降到谷, 上升下降各占一半.

1
2
3
4
5
6
7
8
9
/* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_TRIANGLE */
case WAVE_TRIANGLE:
{
float x = (float)i / (float)WAVE_LEN; /* 0.0 ~ 1.0 */
float d = x - 0.5f; /* -0.5 ~ +0.5 */
if (d < 0.0f) { d = -d; } /* 0.0 ~ 0.5, V 形 */
s = 4.0f * d - 1.0f; /* 翻成 -1 ~ +1 的三角 */
break;
}

这个写法有点绕, 跟一遍就清楚了:

步骤 i=0 i=25 i=50 i=75 i=99
x = i/100 0.00 0.25 0.50 0.75 0.99
d = x - 0.5 -0.50 -0.25 0.00 +0.25 +0.49
\|d\| 0.50 0.25 0.00 0.25 0.49
s = 4\|d\| - 1 +1.00 0.00 -1.00 0.00 +0.96

s 的轨迹是 +1 → 0 → -1 → 0 → +1, 正好一个三角波.

取绝对值能得到三角波, 是因为 x - 0.5 是一条从 -0.5 升到 +0.5 的斜线, 取绝对值之后负的半边被翻上来, 就成了 V 字形. 再乘 4 减 1, 范围从 0~0.5 拉伸成 -1~+1.

和锯齿波的本质区别在于, 三角波上升下降各占一半, 左右对称, 锯齿波只有上升, 回扫垂直. 这个区别在识别端就是靠 down 特征抓的, 三角波的 down 是 0.000, 锯齿波是 1.000

实测 248 ~ 3848, 跨度 3600, 也就是 2.901 V.

锯齿波

从谷底线性升到顶, 然后瞬间回到底. 只有上升, 没有下降.

1
2
3
4
/* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SAW */
case WAVE_SAW:
s = 2.0f * (float)i / (float)WAVE_LEN - 1.0f; /* -1 ~ +1 线性 */
break;

2 * (i/100) - 1:

i i/100 2x - 1
0 0.00 -1.00
25 0.25 -0.50
50 0.50 0.00
75 0.75 +0.50
99 0.99 +0.98

一条从 -1 到 +1 的纯直线.

回扫是这么来的: i = 99 时 s = 0.98, 还没到 +1. 下一个点是下一圈的第 0 个点, s = -1.00. 这一跳就是垂直回扫, DAC 在 10 µs 内从最高点直接掉回最低点.

这也解释了锯齿波的 Vpp 为什么比别的波形略小:

1
2
锯齿波, 最高点只到 i=99, s = 0.98  →  2048 + 1800×0.98 = 3812
其他波, 最高点能到 s = 1.00 → 2048 + 1800×1.00 = 3848

实测 Vpp 是 2.872 V, 比正弦、方波、三角的 2.901 V 少约 0.03 V. 这并不是 bug

这个小差异后来在识别端变成了一个关键设计. 正因为各种波形 Vpp 不完全一致, 识别算法必须按实测 Vpp 归一化, 不能用固定码值. 这件事在 F401 那边是踩过坑的

阶梯波

把整个周期分成 8 级台阶, 每级保持一小段再跳.

1
2
3
4
5
6
7
/* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_STAIR */
case WAVE_STAIR:
{
uint32_t step = (i * WAVE_STEPS) / WAVE_LEN; /* 0 .. WAVE_STEPS-1, 整数除法是生成阶梯波形的关键 */
s = 2.0f * (float)step / (float)WAVE_STEPS - 1.0f;
break;
}

WAVE_STEPS 是 8, 所以 step = (i × 8) / 100, 整数除法.

i 范围 点数 step 输出码值
0 ~ 12 13 0 248
13 ~ 24 12 1 698
25 ~ 37 13 2 1148
38 ~ 49 12 3 1598
50 ~ 62 13 4 2048
63 ~ 74 12 5 2498
75 ~ 87 13 6 2948
88 ~ 99 12 7 3398

实测去重后正好 8 个电平, 相邻台阶差全是 450, 完全均匀.

100 / 8 除不尽也没关系, 点数是 13、12、13、12 这样交替, 只是最后一级略窄一点, 肉眼看不出来. 台阶的电平值是精确的, 这才是关键.

阶梯波最有诊断价值的地方在于台阶数是可以数的. 在采集端数出 8 级, 就说明 DAC、导线、ADC 整条链路都通, 采样率也对. 如果台阶数少了, 可能是采样率不够把两级并成一级; 如果台阶糊成斜坡, 可能是 DAC 更新率不对或者线太长有容性负载

它 Vpp 明显偏小, 最高一级只到 3398, 所以 Vpp = 3150 码值 = 2.538 V, 比正弦少约 12%. 这是阶梯波的定义决定的, 只有 8 级, 最高一级到不了满幅

五种波形对照

波形 形状函数 s 关键特征 Vpp(amp=1800)
正弦 sin(2πi/100) 连续平滑, 斜率一直在变 2.901 V
方波 i<50 ? +1 : -1 长时间停在两个电平上 2.901 V
三角 4·\|i/100 − 0.5\| − 1 左右对称, 匀速直线 2.901 V
锯齿 2·i/100 − 1 只升不降, 垂直回扫 2.872 V
阶梯 2·step/8 − 1 8 级离散台阶 2.538 V

所有波形都挤到一个引脚上

这部分是设计取舍, 值得单独说.

方波本来有个更正统的做法, 用 TIM3 的 PWM 直接从 PA6 出, 硬件自动翻转, 边沿极干净. 工程里 TIM3 的配置确实还留着:

1
2
3
4
5
/* Core/Src/tim.c —— MX_TIM3_Init(), 保留但未启用 */
htim3.Init.Prescaler = 170-1; /* 170MHz/170 = 1MHz */
htim3.Init.Period = 999; /* 1MHz/1000 = 1kHz */
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; /* 50% 占空比 */

但问题是, 如果方波从 PA6 出, 其他波形从 PA4 出, 采集端就得跟着换线或换通道.

成品上这难以接受, 总不能切换波形时让人去拔插头, 接成线路也略显复杂. 所以最后决定:

所有波形统一由 DAC 建表生成, 只用一个引脚 PA4 切换波形对采集端完全透明, 因为引脚从来没变过

代价是方波边沿由 DAC 更新率决定, 10 µs 一个台阶, 不如硬件 PWM 干净. 但这个代价可以接受

波形怎么切换

改表不改外设

1
2
3
4
5
6
/* Core/Src/wavegen.c —— WaveGen_Next() */
void WaveGen_Next(void)
{
s_kind = (WaveKind_t)(((uint32_t)s_kind + 1U) % (uint32_t)WAVE_KIND_COUNT);
WaveGen_FillTable(s_kind, s_amp); /* ★ 只改了一下表内容 */
}

不用重启 DMA, 是因为 DAC 的 DMA 是循环模式, 它记的是内存地址不是数据内容:

1
读 s_table[0] → s_table[1] → ... → s_table[99] → 回到 s_table[0] → ...

我们把 s_table 的内容原地改掉, DMA 下一圈读到自然就是新波形. 所以切换是零代价的, 不用停 DMA, 不用重配 DAC, 也不会出现切换瞬间波形断裂, 因为 DMA 指针从没停过, 只是在读不同的数.

三种入口

1
2
3
4
/* Core/Src/wavegen.h —— 对外接口 */
void WaveGen_Next(void); /* 按键循环, 正弦→方波→三角→锯齿→阶梯→正弦 */
void WaveGen_SetKind(WaveKind_t kind); /* 串口 '1'~'5' 直接指定 */
void WaveGen_SetAmp(uint16_t amp); /* 调幅度, 0 = 一条直线 */

WaveGen_SetKind 里有个越界保护:

1
2
3
4
5
6
7
8
9
10
/* Core/Src/wavegen.c —— WaveGen_SetKind() */
void WaveGen_SetKind(WaveKind_t kind)
{
if ((uint32_t)kind >= (uint32_t)WAVE_KIND_COUNT)
{
return; /* 越界忽略, 不破坏当前输出 */
}
s_kind = kind;
WaveGen_FillTable(s_kind, s_amp);
}

这个保护是有意义的. 如果传进来非法值, switch 会走到 default 输出全 0, 波形就没了. 宁可忽略这次设置, 也不要把当前正常的输出搞坏.

调幅度

1
2
3
4
5
6
/* Core/Src/wavegen.c —— WaveGen_SetAmp() */
void WaveGen_SetAmp(uint16_t amp)
{
s_amp = (amp > 2047U) ? 2047U : amp;
WaveGen_FillTable(s_kind, s_amp);
}

和切波形完全一样, 还是改表. 因为表里的值是 2048 + amp*s, 改了 amp 重新填一遍就行.

amp = 0 会输出一条恒定的 1.65 V 直流. 测试时有用, 可以验证没信号时识别端会不会乱猜.

把波形发给电脑

数据从哪来

G431 不只是发波形, 它自己也会采一份, PA1 = ADC1_IN2 回采 PA4 的输出:

1
2
3
4
5
6
/* Core/Src/main.c —— 采集缓冲定义 */
#define ADC_BUF_LEN 500U /* 采集点数(单通道, 一帧 500 点) */

static uint16_t adc_buf[ADC_BUF_LEN]; /* ADC 原始数据 */
static float tx_buf[ADC_BUF_LEN]; /* 转成电压后的数据 */
static volatile uint8_t adc_ready = 0U; /* 一帧采完的标志 */

一帧 500 点, 采样率也是 100 kHz, 所以:

1
一帧时长 = 500 ÷ 100 kHz = 5 ms = 正好 5 个信号周期(1 kHz)

每帧都是完整的整数个周期, 画出来不会半截.

justfloat 协议

justfloat 是 VOFA+ 支持的二进制协议, VOFA+ 是一个上位机波形软件. 规则很简单:

一个 float(4 字节小端)后面跟一个固定的帧尾 00 00 80 7F

1
2
/* Core/Src/main.c —— justfloat 帧尾 */
static const uint8_t jf_tail[4] = {0x00, 0x00, 0x80, 0x7F}; /* justfloat 帧尾 */

这个帧尾其实是浮点数正无穷的 IEEE754 编码(刚好最近写过相关的笔记, 详见数据类型), VOFA+ 靠它判断一帧到哪结束.

帧的语义

这是最容易搞错的地方. justfloat 的语义是:

1
2
一帧 = 一个时间切片
帧里的每个 float = 一个[通道]的值
想画的 应该怎么发
1 个通道, 500 个时间点 发 500 帧, 每帧里只有 1 个 float(此处所使用的)
4 个通道, 1 个时间点 发 1 帧, 帧里放 4 个 float

千万不要把 500 个 float 塞进一帧. 那样 VOFA+ 会理解成这是 500 个通道, 采样 1 次, 界面上冒出 I0 到 I499 五百条通道, 卡得一批, 而不是一条曲线.

正确做法是每点一帧:

1
2
3
4
5
6
/* Core/Src/main.c —— 打包循环 */
for (i = 0U; i < ADC_BUF_LEN; i++)
{
memcpy(&jf_buf[i * 8U], &tx_buf[i], 4); /* 4 字节, 数据 */
memcpy(&jf_buf[i * 8U + 4U], jf_tail, 4); /* 4 字节, 帧尾 */
}

500 点 × 8 字节 = 4000 字节.

为什么先打包再发

不能逐点调用 HAL_UART_Transmit. 每次调用都有固定开销, 检查状态、算超时、进出临界区, 逐点发要调 500 次, 光开销就吃掉 1~2 ms, 而且每次只发 8 字节, 串口根本喂不满.

所以先打包到一整块缓冲:

1
2
3
/* Core/Src/main.c —— 打包缓冲 */
/* justfloat 打包缓冲, 500 点 x 8 字节 = 4000 字节 */
static uint8_t jf_buf[ADC_BUF_LEN * 8U];
1
2
3
4
/* Core/Src/main.c —— 发送 */
/* 一次性发完 4000 字节
超时给 200ms, 按 921600 波特率算, 4000 字节约需 43ms, 留足余量 */
HAL_UART_Transmit(&huart1, jf_buf, ADC_BUF_LEN * 8U, 200U);

这里阻塞是故意的, 必须保证一帧完整发出去. justfloat 是字节流协议靠帧尾定界, 中途插进别的字节就会解析错位.

文本和二进制不能混发

Report_Poll() 会打印一行状态文本:

1
wave=SQUARE  amp=1800  Vpp=3.601  min=0.200  max=3.801  avg=1.648

但这行是纯文本, 而波形是二进制 justfloat, 两者共用同一个串口.

如果这行插进波形流中间, VOFA+ 会把 w、a 这些字符的 ASCII 码当成 float 的字节去解析, 整条曲线立刻报废.

所以代码里把它默认关闭:

1
2
3
4
5
6
/* Core/Src/main.c —— 文本上报开关 */
/* 是否输出【文本状态行】
!!默认关闭, 这是故意的!!
波形走的是二进制 justfloat, 文本行一旦插进这个流里,
VOFA+ 的解析器会把文本字符当成 float 的字节, 整条曲线就毁了 */
static volatile uint8_t s_text_report_on = 0U;

要看文本时按 'T' 打开, 看完记得关掉, 或者切到纯文本模式用串口助手看.

主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Core/Src/main.c —— while(1) */
while (1)
{
Button_Poll(); /* 0) 按键切波形, 放最前面保证响应不被阻塞 */
Serial_Poll(); /* 0.5) 串口命令 */
Report_Poll(); /* 0.8) 文本上报(默认关闭, 直接 return) */

/* 1) 采满一帧就发出去 */
if (adc_ready)
{
uint32_t i;
adc_ready = 0U;

for (i = 0U; i < ADC_BUF_LEN; i++)
{
memcpy(&jf_buf[i * 8U], &tx_buf[i], 4);
memcpy(&jf_buf[i * 8U + 4U], jf_tail, 4);
}

HAL_UART_Transmit(&huart1, jf_buf, ADC_BUF_LEN * 8U, 200U);
}
}

大致算一下帧率:

1
2
3
4
发送时间, 4000 字节 × 10 bit/字节 ÷ 921600 = 43.4 ms
采集时间, 5 ms
──────────────────────────────────
一帧总耗时 ≈ 48 ms → 帧率约 20 Hz

20 Hz 看波形形状完全够用, 肉眼看是流畅的.

踩过的坑

坑一, DAC 的 DMA 一个点都不搬

现象是配置看起来全对, 但 DAC 输出一直是 0, 连一个点都没搬出去.

查出来是两个原因叠加你妹的坑死我了

坑 A, 外设对齐必须是 WORD

1
2
/* Core/Src/dac.c —— hdma_dac1_ch1.Init */
hdma_dac1_ch1.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; /* ★ 必须 32 位 */

STM32G4 的 DAC 挂在 AHB2 上, 不是 APB, 它的寄存器必须用 32 位字访问. 配成 HALFWORD 的话, DMA 一启动就产生传输错误, TEIF 置位, EN 被清 0, CNDTR 永远不动.

可能不是很像人话, 翻译一下, STM32G4 的 DAC 不是接在慢速的 APB 总线上, 而是接在高速的 AHB2 总线上. AHB2 总线有个硬性条件: 访问 DAC 的寄存器必须一次性写满 32 位. 如果在 DMA 里把外设数据宽度配成 16 位(HALFWORD), DMA 一启动就会报传输错误, 硬件会把错误标志置起来, 同时自动把 DMA 通道的使能位清零, 结果就是传输计数器永远不动, 数据一个也搬不到 DAC 里

再翻译一下, DAC 的门卫只收 32 位的大箱子, 如果拿 16 位的小盒子去, 他直接拒收, 还把门关上, 后面所有货都进不去了

这条是 G4 特有的. F4、L1、L4 的 DAC 挂 APB 上, APB 会自动把字节拼成 32 位, 那些系列反而不能用 WORD

坑 B, DMAUDR1 必须手动清

1
2
/* Core/Src/wavegen.c —— WaveGen_Init() */
__HAL_DAC_CLEAR_FLAG(&hdac1, DAC_FLAG_DMAUDR1);

DMAUDR1 是 DMA 欠载标志(罢工开关), 属于 rc_w1 类型, 读清. 一旦它置起来(开关打开), DAC 会吞掉后续所有 DMA 请求, 表现就是 CNDTR 装好了却永远不减, DHR12R1 一动不动(数据寄存器不动)

这个开关很特殊, 读它只能看到它有没有被按下, 必须专门写一个 1 进去才能使其复位, 写 0 没用

而 HAL 库的 HAL_DAC_Start_DMA() 在启动 DMA 时, 只会允许这个开关去触发中断, 并不会帮你把它复位. 偏偏该工程又没有开 DAC1 的 NVIC 中断, 所以这个开关一旦被按下, 就没人管它了, 造成上述现象

坑二, HAL_UART_Receive 超时设 0 读不到数据

现象是想不等待地读一个字节, 把超时设成 0, 结果永远读不到东西.

原因是 HAL 库把 Timeout = 0 理解成已经超时了, 函数立刻返回 HAL_TIMEOUT, 一个字节都不读.

绕开 HAL 直接轮询状态寄存器就行:

1
2
3
4
5
6
/* Core/Src/main.c —— Serial_Poll() 中读一个字节 */
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) == RESET)
{
return; /* 没有数据, 直接走人 */
}
ch = (uint8_t)(huart1.Instance->RDR & 0xFFU); /* 读数据寄存器, 顺便清标志 */

顺带记一下, F4 和 G4 的寄存器名不一样. G4 叫 RDR, F4 叫 DR. 跨芯片移植容易在这里卡住.

*坑三, 时钟配置的假矛盾

system_stm32g4xx.c 里写着:

1
2
3
4
/* Core/Src/system_stm32g4xx.c —— 模板默认值, 实际不生效 */
#if !defined (HSE_VALUE)
#define HSE_VALUE 24000000U
#endif

而 Core/Inc/stm32g4xx_hal_conf.h 里写的是 8 MHz.

一开始以为配置冲突了, 后来发现不是. 前者在 #if !defined 保护下, 只有 HSE_VALUE 从没被定义过时才生效. 编译时 system_stm32g4xx.c 会 #include "stm32g4xx.h", 这条链最终包含 stm32g4xx_hal_conf.h, 此时 HSE_VALUE 已被定义成 8 MHz, 所以那个 24000000U 根本不参与编译.

24 MHz 只是 CubeMX 模板的默认占位值, 不要去动!

上位机怎么设

原理图内接线就两根:

1
2
3
4
5
G431                    USB-TTL 模块              电脑
┌────────┐ ┌──────────┐
│ PC4 ●─┼──────────────┼─● RX │
│ GND ●─┼──────────────┼─● GND │─────────▶ USB ──▶ VOFA+
└────────┘ └──────────┘
线 说明
PC4(TX)→ USB-TTL 的 RX 数据
GND → USB-TTL 的 GND 必须接, 不共地数据全是乱的

G431 的 TX 是 PC4, 不是 PA9, 这个很容易搞错. PA10 是 RX, 可以另外接 F401 的 TX 做遥控, 互不干扰.

VOFA+ 设置:

项目 值
波特率 921600
数据格式 8N1
协议 justfloat
Y 轴范围 手动锁定 0 ~ 3.3 V

Y 轴这条很重要. 用自动缩放的话, 一旦把幅度调到 0, 输出一条直流, VOFA+ 会把量表放到极小范围, 之后再看正常波形就糊了. 锁定 0~3.3 V 之后波形大小变化能直观看出来.

当前时钟配置

1
2
3
4
5
HSE 8 MHz (板载晶振, PF0/PF1)
│
├─ /M(2) ──▶ 4 MHz [VCO输入, 合法 2.66~16 MHz]
├─ ×N(85) ─▶ 340 MHz [VCO输出, 合法 96~344 MHz]
└─ /R(2) ──▶ 170 MHz ← SYSCLK
外设 时钟来源 频率 关键参数 结果
TIM2 APB1 170 MHz PSC=0, ARR=1699 100 kHz TRGO
TIM3 APB1 170 MHz PSC=169, ARR=999 1 kHz PWM(未用)
DAC1 无 无 触发 T2_TRGO, 表长 100 1 kHz 波形
ADC1 SYSCLK/4 42.5 MHz 47.5 周期采样 1.412 µs/次
USART1 APB2 170 MHz 921600 8N1 921600

ADC 的时钟源是 SYSCLK 不是 PCLK2, 走 RCC_ADC12CLKSOURCE_SYSCLK 再 DIV4. 本工程 APB2 分频恰好是 1, 两者数值相同. 但哪天把 APB2 改成 /2, PCLK2 变 85 MHz 而 ADC 仍是 42.5 MHz.

关键参数速查

项目 数值 出处
表长 100 点 wavegen.c WAVE_LEN
输出频率 1.000 kHz 100 kHz ÷ 100
幅度上限 2047 码值 WaveGen_FillTable
默认幅度 1800 码值 wavegen.h
DAC 中点 2048(1.65 V) WAVE_DAC_MID
输出引脚 PA4 芯片固定
采集帧长 500 点 main.c ADC_BUF_LEN
串口帧长 4000 字节 500 × 8

串口命令都是单字符, 不需要回车换行:

字符 作用
n 下一种波形
1 ~ 5 直接选, 正弦/方波/三角/锯齿/阶梯
+ / - 幅度 ±100
0 幅度归零, 输出直线
a 幅度拉满 2047
T 开/关文本状态上报

小结

波形就是一张表, 五种波形的区别只是 s 的算法.

节奏来自 TIM2, 硬件打拍子, DMA 自动搬, CPU 不参与

切换就是改表, 不重启 DMA, 不重配外设, 所以零代价, 不断波形.

todo