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

波形发生识别系统开发日志 —— G431 波形生成
Kanwuqing本文只写 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 | TIM2 DAC1 PA4 |
这样安排有几个好处:
- CPU 几乎不参与. 表建好之后 DMA 自己循环搬, CPU 想干嘛干嘛
- 换波形代价为零. 只是把表内容原地改掉, 外设一行都不用重配
- 频率准. 节拍来自硬件定时器, 不受程序跑得快慢影响
四个外设的分工
TIM2, 节拍器
它不产生波形, 只负责打拍子
1 | /* Core/Src/tim.c —— MX_TIM2_Init() */ |
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 | 0 → 0.000 V |
它只能保持一个数, 所以需要有人不停给它新数, 这是 DMA 负责的内容
触发源在 dac.c 里配:
1 | /* Core/Src/dac.c —— MX_DAC1_Init() */ |
于是就有了这个换算:
1 | DAC 更新率 = TIM2 频率 = 100 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 | /* Core/Src/wavegen.c —— WaveGen_FillTable() 中的核心一行 */ |
WAVE_DAC_MID 是 2048, 12 位中点, 对应 1.65 V.
amp 是幅度, 单位是码值. s 是形状函数,
范围 -1.0 ~ +1.0.
每种波形的区别仅仅是 s 怎么算.
加中点、限幅、写表这些完全共用. 想加新波形, 在 switch
里加个 case 写一行 s 就够了.
完整函数长这样:
不使用fmin, fmax单纯因为懒得引用了
1 | /* Core/Src/wavegen.c —— WaveGen_FillTable() */ |
幅度上限取 2047 而不是 2048, 是因为 v = 2048 + amp*s,
如果 amp 取 2048 而 s 取到 +1, 会算出 4096
超出 12 位范围, 顶部被削平. 取 2047 正好让最高点落在 4095.
正弦波
直接套正弦函数. 这是最标准的波形, 主要用来检查有没有失真、削顶
// 由于对于精度要求不高, 使用float类型即可
1 | /* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SINE */ |
| 部分 | 含义 |
|---|---|
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 | /* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SQUARE */ |
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 | /* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_TRIANGLE */ |
这个写法有点绕, 跟一遍就清楚了:
| 步骤 | 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 | /* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_SAW */ |
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 | 锯齿波, 最高点只到 i=99, s = 0.98 → 2048 + 1800×0.98 = 3812 |
实测 Vpp 是 2.872 V, 比正弦、方波、三角的 2.901 V 少约 0.03 V. 这并不是 bug
这个小差异后来在识别端变成了一个关键设计. 正因为各种波形 Vpp 不完全一致, 识别算法必须按实测 Vpp 归一化, 不能用固定码值. 这件事在 F401 那边是踩过坑的
阶梯波
把整个周期分成 8 级台阶, 每级保持一小段再跳.
1 | /* Core/Src/wavegen.c —— WaveGen_FillTable(), case WAVE_STAIR */ |
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 | /* Core/Src/tim.c —— MX_TIM3_Init(), 保留但未启用 */ |
但问题是, 如果方波从 PA6 出, 其他波形从 PA4 出, 采集端就得跟着换线或换通道.
成品上这难以接受, 总不能切换波形时让人去拔插头, 接成线路也略显复杂. 所以最后决定:
所有波形统一由 DAC 建表生成, 只用一个引脚 PA4 切换波形对采集端完全透明, 因为引脚从来没变过
代价是方波边沿由 DAC 更新率决定, 10 µs 一个台阶, 不如硬件 PWM 干净. 但这个代价可以接受
波形怎么切换
改表不改外设
1 | /* Core/Src/wavegen.c —— WaveGen_Next() */ |
不用重启 DMA, 是因为 DAC 的 DMA 是循环模式, 它记的是内存地址不是数据内容:
1 | 读 s_table[0] → s_table[1] → ... → s_table[99] → 回到 s_table[0] → ... |
我们把 s_table 的内容原地改掉, DMA
下一圈读到自然就是新波形. 所以切换是零代价的, 不用停 DMA, 不用重配 DAC,
也不会出现切换瞬间波形断裂, 因为 DMA 指针从没停过, 只是在读不同的数.
三种入口
1 | /* Core/Src/wavegen.h —— 对外接口 */ |
WaveGen_SetKind 里有个越界保护:
1 | /* Core/Src/wavegen.c —— WaveGen_SetKind() */ |
这个保护是有意义的. 如果传进来非法值, switch 会走到
default 输出全 0, 波形就没了. 宁可忽略这次设置,
也不要把当前正常的输出搞坏.
调幅度
1 | /* Core/Src/wavegen.c —— WaveGen_SetAmp() */ |
和切波形完全一样, 还是改表. 因为表里的值是 2048 + amp*s,
改了 amp 重新填一遍就行.
amp = 0 会输出一条恒定的 1.65 V 直流. 测试时有用,
可以验证没信号时识别端会不会乱猜.
把波形发给电脑
数据从哪来
G431 不只是发波形, 它自己也会采一份, PA1 = ADC1_IN2 回采 PA4 的输出:
1 | /* Core/Src/main.c —— 采集缓冲定义 */ |
一帧 500 点, 采样率也是 100 kHz, 所以:
1 | 一帧时长 = 500 ÷ 100 kHz = 5 ms = 正好 5 个信号周期(1 kHz) |
每帧都是完整的整数个周期, 画出来不会半截.
justfloat 协议
justfloat 是 VOFA+ 支持的二进制协议, VOFA+ 是一个上位机波形软件. 规则很简单:
一个 float(4 字节小端)后面跟一个固定的帧尾
00 00 80 7F
1 | /* Core/Src/main.c —— justfloat 帧尾 */ |
这个帧尾其实是浮点数正无穷的 IEEE754 编码(刚好最近写过相关的笔记, 详见数据类型), VOFA+ 靠它判断一帧到哪结束.
帧的语义
这是最容易搞错的地方. justfloat 的语义是:
1 | 一帧 = 一个时间切片 |
| 想画的 | 应该怎么发 |
|---|---|
| 1 个通道, 500 个时间点 | 发 500 帧, 每帧里只有 1 个 float(此处所使用的) |
| 4 个通道, 1 个时间点 | 发 1 帧, 帧里放 4 个 float |
千万不要把 500 个 float 塞进一帧. 那样 VOFA+ 会理解成这是 500 个通道,
采样 1 次, 界面上冒出 I0 到 I499 五百条通道,
卡得一批, 而不是一条曲线.
正确做法是每点一帧:
1 | /* Core/Src/main.c —— 打包循环 */ |
500 点 × 8 字节 = 4000 字节.
为什么先打包再发
不能逐点调用 HAL_UART_Transmit. 每次调用都有固定开销,
检查状态、算超时、进出临界区, 逐点发要调 500 次, 光开销就吃掉 1~2 ms,
而且每次只发 8 字节, 串口根本喂不满.
所以先打包到一整块缓冲:
1 | /* Core/Src/main.c —— 打包缓冲 */ |
1 | /* Core/Src/main.c —— 发送 */ |
这里阻塞是故意的, 必须保证一帧完整发出去. 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 | /* Core/Src/main.c —— 文本上报开关 */ |
要看文本时按 'T' 打开, 看完记得关掉,
或者切到纯文本模式用串口助手看.
主循环
1 | /* Core/Src/main.c —— while(1) */ |
大致算一下帧率:
1 | 发送时间, 4000 字节 × 10 bit/字节 ÷ 921600 = 43.4 ms |
20 Hz 看波形形状完全够用, 肉眼看是流畅的.
踩过的坑
坑一, DAC 的 DMA 一个点都不搬
现象是配置看起来全对, 但 DAC 输出一直是 0, 连一个点都没搬出去.
查出来是两个原因叠加你妹的坑死我了
坑 A, 外设对齐必须是 WORD
1 | /* Core/Src/dac.c —— hdma_dac1_ch1.Init */ |
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 | /* Core/Src/wavegen.c —— WaveGen_Init() */ |
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 | /* Core/Src/main.c —— Serial_Poll() 中读一个字节 */ |
顺带记一下, F4 和 G4 的寄存器名不一样. G4 叫 RDR, F4 叫
DR. 跨芯片移植容易在这里卡住.
*坑三, 时钟配置的假矛盾
system_stm32g4xx.c 里写着:
1 | /* Core/Src/system_stm32g4xx.c —— 模板默认值, 实际不生效 */ |
而 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 | G431 USB-TTL 模块 电脑 |
| 线 | 说明 |
|---|---|
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 | HSE 8 MHz (板载晶振, PF0/PF1) |
| 外设 | 时钟来源 | 频率 | 关键参数 | 结果 |
|---|---|---|---|---|
| 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, 不重配外设, 所以零代价, 不断波形.
