STM32 GPIO模拟串行时序驱动TM8211 16位DAC实现音频信号发生器

发布时间:2026/10/9 15:57:56
STM32 GPIO模拟串行时序驱动TM8211 16位DAC实现音频信号发生器
1. 项目缘起与方案选型1.1 为什么还要折腾一个老掉牙的DAC芯片手头有一批TM8211SOP-8封装两通道16位分辨率串行接口价格便宜到离谱。这东西在音频圈子里算是老面孔了很多低端USB声卡、电视盒子音频输出都用它。我最初拿到这批芯片的时候想的是能不能用STM32的普通GPIO口把它驱动起来做一个低成本的音频信号发生器这个需求听起来有点奇怪——STM32本身带DAC的型号比如STM32F103RCT6就有两路12位DAC直接输出不就行了但问题在于STM32内置DAC是12位的而且输出范围有限通常只能到VDDA电平。TM8211是16位的动态范围更大而且它是电流输出型DAC配合外部运放可以做双极性输出。更重要的是我手头这批TM8211是白捡的不用白不用。另一个考虑是用GPIO模拟串行时序驱动DAC这个思路可以推广到很多类似的芯片上。比如TDA1543、PCM5102这些I2S DAC本质上都是串行数据接口理解了TM8211的时序换其他芯片也就是改改协议的事。所以这个项目不只是做一个信号发生器更是一个学习GPIO时序模拟的练手项目。1.2 方案对比SPI硬件驱动 vs GPIO软件模拟TM8211的接口是标准的类SPI三线制BCK位时钟、DATA数据、LRCK左右声道时钟。STM32的SPI外设理论上可以驱动它但有几个坑第一TM8211的时序要求比较特殊。它在BCK的下降沿采样数据而STM32的SPI默认是上升沿采样。虽然可以通过设置CPOL/CPHA来调整但TM8211的LRCK信号不是标准的SPI片选而是一个占空比50%的方波用来区分左右声道。用硬件SPI的话LRCK需要额外用一个定时器或者PWM来产生反而更麻烦。第二音频信号发生器的采样率通常需要精确控制。比如我要输出1kHz的正弦波采样率设为48kHz那每秒钟要输出48000个采样点。用硬件SPI的话数据吞吐率受限于SPI时钟和DMA配置灵活性不如GPIO模拟。第三也是最重要的一点用GPIO模拟可以完全掌控时序方便调试。示波器一挂哪个沿不对一眼就能看出来。硬件SPI出问题的时候你只能对着寄存器发呆。所以最终方案是用STM32的普通GPIO口配合定时器中断软件模拟TM8211的时序。定时器负责精确的采样率控制中断服务函数里翻转GPIO输出数据。1.3 核心思路拆解整个系统的数据流是这样的上位机或者STM32内部生成波形数据正弦波、方波、三角波等存在一个缓冲区里。定时器以固定的频率触发中断中断频率 采样率 × 每帧位数 × 2左右声道。每次中断从缓冲区取出一个bit通过GPIO输出到TM8211的DATA引脚同时翻转BCK。每32个BCK周期16位×2声道翻转一次LRCK表示一帧结束。这里的关键是定时器中断频率的计算。假设采样率是48kHz每帧32个BCK周期那中断频率就是48kHz × 32 1.536MHz。STM32F103在72MHz主频下定时器中断频率做到1.5MHz左右是可行的但中断服务函数必须极其精简否则CPU会被中断吃掉。注意1.536MHz的中断频率意味着每651纳秒就要进一次中断。在72MHz的STM32F103上这相当于每46个时钟周期进一次中断。中断入口、现场保护、数据输出、中断返回这些操作加起来很容易超过46个周期。所以实际项目中我建议把采样率降到16kHz或者8kHz或者用DMASPI的方案。但为了演示GPIO模拟的完整过程这里还是按48kHz来设计实际运行时可以降频。2. TM8211时序深度解析与GPIO模式选择2.1 TM8211的串行协议到底长什么样TM8211的数据手册写得很简略但时序图是清晰的。它采用类似I2S的格式但有几个关键区别BCK是位时钟每个BCK周期传输一个bit。TM8211在BCK的下降沿采样DATA线上的数据。LRCK是左右声道时钟低电平表示左声道高电平表示右声道。LRCK的边沿和BCK的边沿有特定的相位关系。数据是MSB优先16位补码格式。左声道先传右声道后传。一个完整的音频帧包含32个BCK周期前16个BCK周期传输左声道数据后16个传输右声道数据。LRCK在第一个BCK周期之前翻转然后在第17个BCK周期之前再翻转。具体时序当LRCK为低电平时第一个BCK下降沿采样左声道的MSB第15位第二个BCK下降沿采样第14位以此类推第16个BCK下降沿采样左声道的LSB第0位。然后LRCK变高第17个BCK下降沿采样右声道的MSB直到第32个BCK下降沿采样右声道的LSB。这里有一个容易踩坑的地方LRCK的翻转和BCK的相位关系。根据手册LRCK应该在BCK的下降沿之后、下一个下降沿之前翻转。实际调试时我建议用示波器同时抓BCK、LRCK、DATA三根线对照时序图一点点调。2.2 GPIO的8种工作模式到底该选哪个STM32的GPIO有8种工作模式输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、推挽复用输出、开漏复用输出。驱动TM8211的BCK、DATA、LRCK三根线应该选哪种答案是推挽输出GPIO_Mode_Out_PP。理由如下TM8211的输入引脚是高阻抗CMOS输入不需要上拉或下拉。推挽输出可以提供足够的驱动电流STM32的GPIO在推挽模式下可以输出20mA左右而且上升沿和下降沿都比较陡峭有利于高速信号完整性。开漏输出需要外部上拉电阻会增加上升时间在1.5MHz的BCK频率下可能导致时序裕量不足。至于复用输出那是给SPI、I2C这些外设用的。我们用的是普通GPIO模拟所以选推挽输出。速度等级方面STM32的GPIO输出速度可以配置为2MHz、10MHz、50MHz。BCK频率是1.536MHz所以选10MHz就够了。选50MHz虽然也可以但会增加EMI和功耗没必要。实操心得如果你用的是STM32F103C8T6这种小容量芯片GPIO翻转速度可能达不到理论值。我实测过在72MHz主频下用库函数直接操作GPIO翻转频率大概能做到8MHz左右。如果用寄存器直接操作BSRR和BRR寄存器可以做到18MHz。所以驱动TM8211绰绰有余。2.3 定时器的选择与配置STM32F103有多个定时器TIM1到TIM8。其中TIM1和TIM8是高级定时器TIM2到TIM5是通用定时器TIM6和TIM7是基本定时器。对于这个项目我选TIM2。原因是TIM2是32位定时器在F103上其实是16位但可以通过预分频器扩展挂在APB1总线上时钟频率是72MHz。基本定时器TIM6和TIM7也可以但它们没有输出比较功能只能产生更新中断。通用定时器TIM2有四个独立通道以后如果要扩展功能比如同时输出多路信号会更方便。定时器的配置参数计算目标中断频率1.536MHz定时器时钟72MHz预分频器PSC0即不分频自动重装载值ARR72MHz / 1.536MHz - 1 46.875 - 1 ≈ 46所以ARR设为46实际中断频率 72MHz / (461) 1.5319MHz和1.536MHz非常接近。这个误差在音频应用中完全可以接受。如果你想把采样率精确设为48kHz可以调整ARR的值。比如ARR46时实际采样率 1.5319MHz / 32 47.87kHz误差0.27%。对于信号发生器来说这个误差可以忽略。3. 完整实现过程与核心代码解析3.1 硬件连接与电路设计TM8211的引脚定义如下引脚名称功能1VDD电源正极3.3V或5V2GND电源地3BCK位时钟输入4DATA串行数据输入5LRCK左右声道时钟输入6VOUTL左声道模拟输出7VOUTR右声道模拟输出8VREF参考电压STM32和TM8211的连接STM32的PA0接TM8211的BCKSTM32的PA1接TM8211的DATASTM32的PA2接TM8211的LRCKTM8211的VDD接3.3VGND接地VREF接一个1uF电容到地再通过两个10k电阻分压到VDD/2TM8211是电流输出型DACVOUTL和VOUTR输出的是电流信号需要外接运放转换成电压信号。我用的是NE5532接成I/V转换电路。具体电路TM8211的VOUTL接NE5532的反相输入端反馈电阻取10k同相输入端接VREF。这样输出电压 IOUT × 10k。注意TM8211的VREF引脚需要接一个稳定的参考电压。如果直接用VDD做参考电源噪声会直接耦合到输出。建议用TL431或者REF3033产生一个干净的3.3V参考。3.2 波形数据生成与缓冲区设计要输出正弦波首先得生成正弦波数据表。我用Python算了一个256点的正弦表16位有符号整数范围从-32768到32767。import math def generate_sine_table(length256, amplitude32767): table [] for i in range(length): value int(amplitude * math.sin(2 * math.pi * i / length)) table.append(value) return table sine_table generate_sine_table() for i in range(0, len(sine_table), 16): print(, .join(f0x{sine_table[j] 0xFFFF:04X} for j in range(i, min(i16, len(sine_table)))))生成的表存在STM32的Flash里用const修饰。输出的时候根据当前的相位累加器从表里查值。相位累加器是一个32位变量每次中断累加一个步进值。步进值 (目标频率 × 2^32) / 采样率。比如目标频率1kHz采样率48kHz步进值 (1000 × 2^32) / 48000 ≈ 89478485。查表的时候取相位累加器的高8位作为索引因为表长256这样就能输出不同频率的正弦波。3.3 中断服务函数的极致优化中断服务函数是整个项目的核心必须写得极其精简。下面是我优化后的版本volatile uint32_t phase_acc 0; volatile uint32_t phase_step 89478485; volatile uint8_t bit_count 0; volatile uint16_t left_sample 0; volatile uint16_t right_sample 0; void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; // 每32个BCK周期更新一次采样值 if (bit_count 0) { phase_acc phase_step; uint8_t index phase_acc 24; int16_t sample sine_table[index]; left_sample (uint16_t)sample; right_sample (uint16_t)sample; } // 输出当前bit if (bit_count 16) { // 左声道 if (left_sample 0x8000) { GPIOA-BSRR GPIO_BSRR_BS1; } else { GPIOA-BRR GPIO_BRR_BR1; } left_sample 1; } else { // 右声道 if (right_sample 0x8000) { GPIOA-BSRR GPIO_BSRR_BS1; } else { GPIOA-BRR GPIO_BRR_BR1; } right_sample 1; } // 翻转BCK GPIOA-ODR ^ GPIO_ODR_ODR0; // 控制LRCK if (bit_count 0) { GPIOA-BRR GPIO_BRR_BR2; // LRCK低左声道 } else if (bit_count 16) { GPIOA-BSRR GPIO_BSRR_BS2; // LRCK高右声道 } bit_count; if (bit_count 32) { bit_count 0; } } }这段代码有几个优化点第一直接用寄存器操作不用库函数。GPIOA-BSRR和GPIOA-BRR是原子操作比GPIO_SetBits和GPIO_ResetBits快得多。第二用位运算代替条件判断。比如left_sample 0x8000判断MSB比if (left_sample 0x8000)快。第三把采样值更新放在bit_count0的时候避免每次中断都查表。第四LRCK的控制也放在中断里保证和BCK的相位关系正确。实操心得中断服务函数里尽量不要调用任何库函数包括GPIO_SetBits这种。我实测过用库函数的话中断频率最高只能做到500kHz左右。用寄存器直接操作可以做到1.5MHz以上。另外中断服务函数要加__attribute__((interrupt))或者用__irq修饰取决于编译器否则现场保护可能不完整。3.4 主函数与初始化流程主函数的流程很简单初始化时钟、GPIO、定时器然后进入死循环。int main(void) { // 使能GPIOA和TIM2时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 配置PA0、PA1、PA2为推挽输出10MHz GPIOA-CRL ~(0xFFF); GPIOA-CRL | 0x333; // 配置TIM2 TIM2-PSC 0; TIM2-ARR 46; TIM2-DIER | TIM_DIER_UIE; TIM2-CR1 | TIM_CR1_CEN; // 使能TIM2中断 NVIC_EnableIRQ(TIM2_IRQn); NVIC_SetPriority(TIM2_IRQn, 0); while (1) { // 主循环可以处理按键、串口命令等 } }初始化的时候要注意GPIO的配置要在定时器使能之前完成否则定时器一开始中断GPIO还没配置好会输出乱码。另外NVIC的优先级要设到最高0因为1.5MHz的中断频率下任何延迟都可能导致时序错乱。4. 调试过程中踩过的坑与排查技巧4.1 没有输出先查这三根线第一次上电示波器一挂三根线全是高电平没有任何波形。排查步骤第一步确认定时器中断有没有进。在中断服务函数里翻转一个空闲GPIO用示波器看有没有方波。如果没有说明定时器配置有问题。检查TIM2的时钟使能、ARR值、UIE位、CR1的CEN位。第二步确认GPIO有没有配置对。用万用表量PA0、PA1、PA2的电压如果是3.3V说明配置成了输出高。检查GPIOA-CRL寄存器的值确保低4位是0x3推挽输出10MHz。第三步确认中断优先级。如果NVIC优先级设得太低或者被其他中断抢占中断服务函数可能进不去。把TIM2的优先级设到0并且确保没有其他中断在跑。我遇到的问题是GPIOA的时钟忘了使能。RCC-APB2ENR的IOPAEN位没置1导致GPIO配置不生效。这个坑很隐蔽因为GPIO配置寄存器写进去读出来是对的但引脚就是没反应。4.2 输出有波形但噪声很大波形出来了但上面叠加了很大的高频噪声。用示波器的FFT功能一看噪声主要集中在1.5MHz附近正好是BCK的频率。原因是BCK、DATA、LRCK三根线在PCB上走得太近产生了串扰。TM8211的模拟输出部分没有做好屏蔽。解决方法第一在PCB布局上把数字信号线和模拟信号线分开走尽量垂直交叉不要平行走线。第二在TM8211的VDD引脚旁边加一个0.1uF的陶瓷电容和一个10uF的钽电容滤除电源噪声。第三在I/V转换运放的输出端加一个简单的RC低通滤波器截止频率设在20kHz左右。R取1kC取10nF截止频率 1/(2πRC) ≈ 15.9kHz。第四如果噪声还是很大可以考虑用光耦或者数字隔离器把STM32和TM8211隔离开。不过这样会增加成本对于信号发生器来说有点过度设计。4.3 频率不准检查相位累加器的步进值我设的目标频率是1kHz但用频率计一量实际输出是987Hz。误差1.3%。排查过程首先确认采样率。用示波器量LRCK的频率应该是48kHz。如果LRCK频率不对说明定时器中断频率不对。检查ARR值72MHz / (461) 1.5319MHz再除以32得到47.87kHz。所以采样率本身就有0.27%的误差。然后检查相位累加器的步进值。步进值 (1000 × 2^32) / 48000 89478485.33。我取的是89478485截断了小数部分导致频率偏低。如果把步进值改成89478486频率会偏高一点点。实际输出频率 (步进值 × 采样率) / 2^32 (89478485 × 48000) / 2^32 ≈ 999.99Hz。误差0.001%完全可以接受。所以问题出在采样率上。如果要精确输出1kHz可以调整ARR值让采样率精确等于48kHz。ARR 72MHz / (48000 × 32) - 1 46.875 - 1 45.875。ARR只能取整数所以取46或者45。取46时采样率47.87kHz取45时采样率48.91kHz。两者都有误差。要精确的话可以换一个晶振频率比如用8MHz晶振PLL倍频到72MHz然后调整PLL倍频系数。实操心得对于信号发生器来说频率误差在1%以内通常是可以接受的。如果要做精确的频率输出建议用外部晶振或者TCXO并且用32位定时器来产生采样时钟。4.4 常见问题速查表现象可能原因排查方法解决方法无输出定时器未使能检查TIM2-CR1的CEN位置位CEN无输出GPIO时钟未使能检查RCC-APB2ENR的IOPAEN位置位IOPAEN无输出中断未使能检查NVIC-ISER调用NVIC_EnableIRQ波形失真中断频率过高示波器量中断服务函数执行时间降低采样率或优化代码噪声大电源滤波不足示波器看VDD纹波加滤波电容噪声大数字模拟串扰示波器看BCK和VOUT的相位关系分开走线加屏蔽频率不准采样率误差示波器量LRCK频率调整ARR值频率不准步进值截断计算步进值用浮点数计算后取整左右声道反了LRCK相位错误示波器看LRCK和DATA的关系调整LRCK翻转时机只有单声道数据位计数错误检查bit_count的范围确保0-15左声道16-31右声道5. 性能优化与功能扩展5.1 用DMA减轻CPU负担1.5MHz的中断频率下CPU有超过50%的时间在中断服务函数里。如果还要在主循环里处理按键、显示、串口通信系统会非常卡顿。优化方案用DMA把波形数据直接搬到GPIO的ODR寄存器。STM32的DMA可以把内存里的数据自动写到外设寄存器不需要CPU干预。具体做法把每个BCK周期的GPIO状态BCK、DATA、LRCK的组合预先算好存在一个缓冲区里。DMA以1.5MHz的频率从缓冲区取数据写到GPIOA-ODR。这样CPU只需要在DMA传输完成中断里更新缓冲区就行了。不过这个方法有个限制STM32F103的DMA不能直接写到GPIO的ODR寄存器因为GPIO的ODR寄存器地址不在DMA可访问的外设地址范围内。解决办法是用定时器的PWM输出功能或者用SPI的MOSI引脚输出数据再用另一个定时器产生BCK和LRCK。5.2 用SPI硬件外设驱动TM8211如果不想用GPIO模拟可以用STM32的SPI外设。SPI的MOSI接TM8211的DATASCK接BCK。LRCK用一个定时器的PWM输出产生。SPI的配置CPOL0CPHA0数据宽度16位MSB优先波特率预分频器设为2SPI时钟 72MHz / 2 36MHz。不过TM8211的BCK最高频率是6.4MHz根据手册所以SPI时钟要降到6.4MHz以下。预分频器设为16SPI时钟 72MHz / 16 4.5MHz。LRCK用TIM3的通道1产生PWM频率 采样率 48kHz占空比50%。SPI每传输16位数据LRCK翻转一次。这个方案需要精确控制SPI和LRCK的相位关系调试起来比GPIO模拟麻烦但CPU占用率低。5.3 扩展功能输出任意波形有了正弦波的基础输出方波、三角波、锯齿波就很简单了。只需要修改查表函数方波根据相位累加器的高位判断输出最大值还是最小值。三角波相位累加器的高位作为索引前半段递增后半段递减。锯齿波相位累加器的高位直接作为输出值。如果要输出任意波形可以做一个波形编辑器把数据通过串口传到STM32的RAM里然后从RAM里查表。这样就能输出任意形状的周期信号。5.4 频率和幅度的实时调节频率调节改变相位累加器的步进值。步进值越大频率越高。步进值 (目标频率 × 2^32) / 采样率。幅度调节在查表之后把采样值乘以一个幅度系数。系数范围0到1对应0%到100%幅度。为了避免浮点运算可以用定点数比如把系数放大256倍然后右移8位。int16_t sample sine_table[index]; sample (sample * amplitude) 8; // amplitude范围0-256这样就能实现频率和幅度的实时调节不需要重新生成波形表。6. 项目总结与个人体会这个项目从开始到基本能用大概花了一个周末的时间。最大的收获不是做出了一个信号发生器而是对STM32的GPIO和定时器有了更深的理解。GPIO的8种工作模式以前都是凭感觉选现在知道每种模式的应用场景了。推挽输出适合驱动数字信号开漏输出适合I2C这种需要线与的总线复用输出适合外设功能。选错了模式要么驱动能力不够要么信号质量差。定时器的中断频率也不是越高越好。1.5MHz的中断频率下CPU几乎被中断吃满主循环里什么都干不了。后来我把采样率降到16kHz中断频率降到512kHzCPU占用率降到20%左右系统就流畅多了。对于音频信号发生器来说16kHz的采样率虽然只能输出8kHz以下的信号但已经覆盖了大部分音频应用。TM8211这个芯片虽然老但性价比确实高。两通道16位串行接口价格便宜适合低成本音频项目。不过它的电流输出特性需要外接运放增加了电路复杂度。如果要做简单的音频输出用STM32内置的DAC或者PWM加RC滤波可能更简单。最后分享一个小技巧调试这种高速时序的时候示波器的触发功能很重要。把触发源设为LRCK的下降沿然后观察BCK和DATA的关系可以快速定位时序问题。如果示波器有协议解码功能直接解码成二进制数据一眼就能看出数据对不对。没有示波器的话用逻辑分析仪也行几十块钱的USB逻辑分析仪就能抓1MHz的信号够用了。