最近在TMS320F28377D上做并网逆变器的控制算法,主控中断周期定在50微秒,里面要过三相锁相环、park变换、反park变换以及SVPWM的角度计算。这些环节看起来不复杂,但三角函数调用次数一多,中断就明显顶不住。一开始我以为是算法流程问题,后来用定时器抓周期一看,光sinf、cosf、atan2f这些库函数就吃掉了中断里35%以上的时间。
F28377D这颗芯片我用了两年,TMU这个外设其实一直知道存在,但总觉得是编译器帮我自动处理了,没必要手动折腾。真到瓶颈的时候才意识到,编译器不会平白无故把math.h里的函数变成TMU指令,得明确开编译选项、明确用内建函数、或者换DSP优化库,性能差距才会真正体现出来。这周我把三种情况拉通测了一遍:math.h里的浮点三角函数走普通FPU路径、TMU硬件加速指令、以及同样的代码分别放在Flash和RAM里执行。测出来的结果比我预想的大很多,尤其代码位于Flash和RAM之间的差距,被不少项目忽视了。
文章后面会给出可以直接抄走的计时框架、编译选项和linker配置,方便你在自己的板子上复现一遍。本文所有数据基于我手上这块自制控制板,CCS版本和编译器升级后数字会有些浮动,但结论趋势不会变。
1. 为什么要把TMU翻出来重测一遍
1.1 控制算法踩到性能墙
我先交代一下项目背景。做三相并网逆变器,数字控制全部跑在F28377D上,CPU主频锁定200MHz。控制环路拆成三层:最外层是电网电压锁相,中间是电压环和电流环,最底层是PWM更新和ADC触发。最容易被CPU时间吃满的往往是锁相和坐标变换,因为每个控制周期都要算角度、算正余弦,而且这些三角函数不是算一次就结束的。
拿我现在的软件架构举例,电网电压锁相用了基于双二阶广义积分器的锁频锁相环,也就是SOGI-FLL + PLL。每个控制周期里至少需要计算如下运算:
- 两路SOGI的旋转积分要调sin和cos,各算2次,一共4次;
- park变换要算sin和cos,各1次,一共2次;
- 陷波器、低通滤波如果做成自适应频率版本,可能还要再算atan2去校正频率;
- SVPWM过调制和角度归一化处理里还可能用到atan2和除法。
折合下来,一个控制周期仅三角函数相关运算就超过10次。我最初用math.h直接算,在50微秒中断里占用率大概在50%到60%,加上其他环路计算,整个中断差不多要跑到45微秒,几乎到了临界值。硬件上F28377D其实有很好的数学加速资源,只是过去一直没有把它落实到具体代码里。
1.2 TMU、FPU、RAM三个概念要分开
很多刚接触C2000的工程师容易把TMU和FPU混为一谈,实际上这两者解决的是完全不同的两类问题。FPU是浮点运算单元,负责让单精度浮点加减乘除、开方等基本运算不用编译器模拟,一条指令直接出结果。F28377D内部集成的是C28x + FPU32架构,也就是说它原生支持单精度浮点运算。
TMU则是专门针对三角函数和除法设计的硬件加速单元,它的英文全称是Trigonometric Math Unit,主要用来硬件加速正弦、余弦、反正切、反正切二象限等运算。也就是说,没有TMU时sinf和cosf是通过软件算法用多项式近似算出来的,几十条指令起步;有了TMU后,角度换算、查表、多项式展开这些运算都有专门硬件电路参与,周期数会大幅下降。
至于RAM,这里的重点不是把变量放RAM,而是把程序代码放到RAM里执行。F28377D的Flash读取是有等待状态的,而RAM是零等待访问。代码在Flash执行和RAM执行,同一段运算的耗时会有明显差别。我这次测试把“FPU库函数+Flash运行”“FPU库函数+RAM运行”“TMU内建指令+Flash运行”“TMU内建指令+RAM运行”四种组合全部覆盖了。
2. 三种运行方式在硬件层面上到底差在哪
2.1 一条正余弦指令的执行路径
先看一条正余弦计算在硬件上经历了什么。如果直接调用math.h里的sinf函数,C28x内核会执行一串用于浮点数和角度转换的指令,然后调用多项式逼近算法。这个过程里虽然FPU能加速浮点加法、乘法和乘加融合运算,但软件算法本身的指令数量摆在那里,比如一个典型的单精度正弦近似至少需要若干个多项式系数,通过多组乘加运算才能得到结果。如果没有FPU,这些运算还得退化成软件浮点,编译器会生成一长串整数指令模拟浮点,那性能更没法看。
换成TMU之后情况完全不同。F28377D的TMU提供了一组专用指令,比如正弦和余弦指令,可以直接拿一个输入角度,硬件内部完成归一化、象限判断、多项式求值,最后输出结果。程序员可以把它当作一条指令来理解,CPU在流水线里执行它只需要几个周期就能拿到结果。这也是TMU最大的价值:把原本几十条指令才能完成的数学运算压缩到几条指令。
需要澄清一点:TMU并不替代FPU,二者是配合关系。三角函数内部依然要做浮点运算,TMU只是在硬件里把这些浮点运算组织好了,最终还是要用FPU来跑浮点数据通路。开TMU加速时通常也要开启FPU支持,否则编译器可能拒绝生成对应指令,或者产生低效的混合代码。
2.2 RAM执行为什么比Flash快
Flash存储器的访问速度比CPU内核慢得多。F28377D在200MHz主频下,Flash读取需要插入等待状态,虽然TI内置了预取缓冲和缓存机制,但总有缓存不命中的情况。一旦代码出现跳转、函数调用、中断切换那么频繁的场景,缓存命中率不稳定,程序执行时间就会出现抖动。
而RAM是零等待访问的,程序代码放在RAM里执行,取指速度可以完全跟得上CPU流水线,这等于给CPU解除了存储瓶颈。
不过RAM容量比Flash小很多,F28377D的RAM总量大约是200KB左右,而Flash有1MB。实际项目中不可能把所有代码都塞进RAM,只能把最敏感、最常执行的热点函数放进去,比如PWM中断服务函数、锁相环核心计算、以及数学库中被反复调用的计算函数。
2.3 编译器选项决定性能上限
这里特别强调,TMU不是打开就能用的。C2000编译器提供了一系列架构相关选项:
--float_support=fpu32 --tmu_support=tmu0第一个选项告诉编译器目标芯片支持C28x FPU浮点单元,第二个选项告诉编译器目标芯片支持TMU单元。如果只开fpu32而没开tmu_support,即使代码里写了TMU内建函数,编译器也可能报错,或者fallback到普通库函数实现。反之,如果开了tmu_support但芯片型号没有TMU,生成的目标代码会在不支持指令的芯片上触发异常。
我在CCS工程里的做法是在Project Properties里找到Build → C2000 Compiler → Processor Options,把浮点支持选择为“FPU single precision (--float_support=fpu32)”,把TMU支持选择为“TMU0 (--tmu_support=tmu0)”。如果用的是命令行编译,就在编译参数里显式加上这两个选项。
3. 测试环境和完整计时代码
3.1 硬件与工程配置
测试用板是我自己画的一块以F28377D为核心的电机/逆变器控制板,外部晶振20MHz,内部锁相环倍频到200MHz。调试器用的是XDS110,IDE是CCS 12.x,编译器版本为TI C2000 Compiler 22.x。工程里把定时器、GPIO、系统时钟全部初始化完毕,然后进入一个主循环,依次执行不同版本的测试函数。
为了尽量减少外设干扰,我关闭了所有可能产生中断的外设,只在空载状态下跑测试。这个动作很关键,如果PWM中断还开着,计时结果会被中断吞掉一部分时间,测出来的周期数偏大且不稳定。
3.2 用CPU Timer1做时间戳计数
测量程序执行周期数最简单的办法是使用CPU定时器。CPU Timer1在裸机环境下可以配成自由运行模式,它内部有一个计数寄存器TIM,每个CPU时钟周期减一。我们在被测代码前后各读一次TIM值,差值就是这段代码消耗的CPU周期数。
初始化代码如下:
#include "F28x_Project.h" volatile uint32_t g_start; volatile uint32_t g_end; void setupT1ForTimestamp(void) { // 使用CPU Timer1,在200MHz下自由运行 CPUTimer1Regs.TCR.bit.TSS = 1; // 停止定时器 CPUTimer1Regs.PRD.all = 0xFFFFFFFF; // 最大计数周期 CPUTimer1Regs.TIM.all = 0xFFFFFFFF; // 从FFFF向下递减 CPUTimer1Regs.TCR.bit.TRB = 1; // 重装载计数寄存器 CPUTimer1Regs.TCR.bit.TSS = 0; // 启动定时器 }注意TIM寄存器是递减计数,所以计算周期数要这么做:
#define TIME_CYCLES() ((uint32_t)(g_start - g_end))因为g_start是执行之前读到的值,g_end是执行之后读到的值,由于递减,g_start必然大于g_end,二者差值就是消耗的周期数。如果开了中断,还有可能发生定时器重载的情况,所以最好保证每次测试时间远小于定时器最大周期,我测试的单次运算最多几百周期,完全没有溢出风险。
3.3 测量math.h与TMU内建函数的代码
为了公平对比,我没有把被测运算只执行一次,而是用8组不同输入连续执行一轮,最后累加结果防止编译器把整个计算优化掉。先看math.h版本:
#include <math.h> volatile float g_in[8] = { 0.1f, 0.2f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f }; volatile float g_out[32]; volatile float g_sum; float test_math_flash(void) { uint32_t s = CPUTimer1Regs.TIM.all; g_sum = 0.0f; for (int i = 0; i < 8; i++) { g_out[i * 4 + 0] = sinf(g_in[i]); g_out[i * 4 + 1] = cosf(g_in[i]); g_out[i * 4 + 2] = atanf(g_in[i]); g_out[i * 4 + 3] = atan2f(g_in[i], 1.0f); } // 防止编译器优化掉函数调用,把结果累加 for (int i = 0; i < 32; i++) g_sum += g_out[i]; uint32_t e = CPUTimer1Regs.TIM.all; return (float)(s - e); }TMU内建函数版本逻辑相同,只是把sinf、cosf、atanf、atan2f替换成编译器提供的TMU intrinsic函数:
float test_tmu_flash(void) { uint32_t s = CPUTimer1Regs.TIM.all; g_sum = 0.0f; for (int i = 0; i < 8; i++) { g_out[i * 4 + 0] = __sin(g_in[i]); g_out[i * 4 + 1] = __cos(g_in[i]); g_out[i * 4 + 2] = __atan(g_in[i]); g_out[i * 4 + 3] = __atan2(g_in[i], 1.0f); } for (int i = 0; i < 32; i++) g_sum += g_out[i]; uint32_t e = CPUTimer1Regs.TIM.all; return (float)(s - e); }这里要额外说明,我用的是TI C2000编译器自带的__sin、__cos、__atan、__atan2这些内建函数。不同编译版本的内建函数名称可能略有差异,以你当前IDE的编译器用户指南为准。另外也可以直接引用C2000Ware里提供的优化数学库,库里已经针对TMU做了封装,效果基本一致。
3.4 把被测函数搬到RAM中执行
测量RAM运行版本时,我没有改写函数内部逻辑,而是利用编译器的代码段属性,把整个函数放到独立的段里,再由链接脚本把这个段定位到RAM。以test_tmu_ram函数为例:
#pragma CODE_SECTION(test_tmu_ram, ".TI.ramfunc") float test_tmu_ram(void) { // 函数体与test_tmu_flash完全一致 }然后在链接器CMD文件中,把.TI.ramfunc段放到RAM空间,同时还要保留Flash作为加载地址。标准做法如下:
SECTIONS { .TI.ramfunc : LOAD = FLASH, RUN = RAMLS5, LOAD_START(_ramfunc_load_start), RUN_START(_ramfunc_run_start), SIZE(_ramfunc_load_size) }TI的C2000编译器在配置了ramfunc段后,启动代码会自动将Flash中的数据拷贝到RAM,并在拷贝完成后跳转到RAM执行。我这里用RAMLS5作为运行段,实际根据芯片RAM分布可以灵活调整,只要不与全局变量等堆叠冲突即可。
4. 实测数据:到底差多少
4.1 单次运算周期数对比
我把四个测试函数每个都循环执行了1000次,取平均值算出单次四种运算组合的耗时。测试时用了单点函数分别统计,数据结构是这样:sinf算一次、cosf算一次、atanf算一次、atan2f算一次。最终得到的结果换算成周期如下:
| 运算组合 | sinf | cosf | atanf | atan2f 平均周期 |
|---|---|---|---|---|
| math.h + Flash运行 | 61 | 62 | 103 | 186 |
| math.h + RAM运行 | 42 | 41 | 74 | 138 |
| TMU内建 + Flash运行 | 12 | 11 | 19 | 31 |
| TMU内建 + RAM运行 | 8 | 8 | 13 | 24 |
看到这个表的时候说实话挺惊讶的。math.h版本无论放Flash还是RAM,都要比TMU版本慢不少,尤其atan2f这种函数,math.h版本在Flash里一晃要186个周期,而TMU在RAM里只要24个周期,差距接近8倍。哪怕最保守地看sinf,math.h在RAM里的42周期也比TMU在Flash里的12周期慢2.5倍。
这里要提醒一下:不同优化等级会影响数据。我是用CCS默认的-O2优化级别测的,如果开-O3,math.h版本可能会稍微好一点,但TMU版本基本不会有什么变化,因为硬件指令已经接近理论下限。你如果在自己板子上测出不同数字,别慌,先把编译选项调到一致再对比。
4.2 Flash与RAM之间的隐藏成本
把视线单独放到Flash和RAM对比上,同样使用math.h函数,把代码从Flash搬到RAM执行后,sinf从61周期降到42周期,atan2f从186周期降到138周期,整体缩短了25%到30%。这个收益不依赖于TMU,纯粹是取指效率的提升。
做控制算法的人对执行时间抖动最头疼。在Flash里跑数学函数,缓存命中与否会导致周期数忽大忽小,这对固定PWM频率的中断来说是种隐患。代码搬进RAM后,取指时间确定,函数执行周期基本稳定,这个优势往往比单纯省时间更重要。我在实际调试中发现,程序从Flash切换到RAM运行后,PWM中断的最大执行时间变平稳了,这对系统实时性是个不小的帮助。
不过RAM空间始终有限。F28377D虽然有大约200KB的RAM,但要分给全局变量、堆栈、DMA缓冲、CLA数据,真正能拨给代码段的往往只有几十KB。把最热的中断服务函数和数学函数搬进去就够了,不需要整个工程都搬。
4.3 放到真实控制中断里的收益
单项测试好看,最终还是得看整个中断能不能省下时间。我把锁相环和坐标变换里的三角函数全部替换成TMU内建函数,同时把中断服务函数几个核心计算子函数放到RAM。整体效果非常直观:
- 之前中断里三角函数部分大约占用12微秒,替换后降到4微秒左右;
- 整个50微秒控制中断的执行时间从45微秒降到30微秒;
- 中断剩余裕量从5微秒增加到20微秒。
省出来的时间我用在了更精细的电网谐波补偿算法上,还在原地加了几个诊断变量做实时监控,完全没增加硬件成本。对一个控制类项目来说,这种通过释放计算资源换来的算法空间,比单纯把主频往上调更有价值。
5. 折腾过程中踩过的坑
5.1 没开编译选项,优化了个寂寞
第一次测TMU版本时,我写完__sin函数编译直接报错,提示无法识别内建函数。检查半天发现工程里只开了fpu32,没开tmu_support。编译器版本默认不会因为芯片型号就自动启用所有硬件扩展,必须显式告诉它。这个操作在图形化IDE里藏得比较深,很容易漏掉。
还有一次是另一个同事把tmu_support写成了tmu1,结果编译过了,但下到板子上跑起来之后某些计算偶尔出错,查了很久才发现是编译器为TMU1生成的指令序列在TMU0上不完全兼容。F28377D用的是TMU0,这个参数对不上就会出隐患。最好的办法是在工程属性里选正确的芯片型号,让编译器去匹配。
如果只开了TMU没开FPU,同样有问题。TMU内部虽然负责三角函数的专用计算,但数据通路依然依赖FPU的单精度浮点寄存器。单独开TMU不开FPU,编译器要么报错,要么生成的代码没法充分利用硬件,测试结果会失去意义。
5.2 编译器把测试优化没了
我在写计时代码时遇到一个非常经典的坑:只做了g_out数组存储,没有把结果累加到一个外部变量时,编译器在-O2优化下认为这些计算毫无用处,直接整段优化掉。最后测出来的“周期数”不是几百,而是几十个周期,明显不对。
解决办法就是我在上面代码里做的:每个计算结果写进volatile数组,最后再做一次累加,并且累加变量也声明为volatile,让编译器没法把运算删除。这样做虽然会多几条存储和加载指令,但公平性仍然成立,因为四种情况承受的额外开销是一样的。
测试时还要避免在计时起始点和终止点之间调用GPIO翻转、串口打印等操作。我最初想通过示波器量GPIO高电平宽度来测时间,确实也能测,但每次打印函数和GPIO操作本身引入的开销会影响分辨率,除非你把这些操作放在计时区间之外。用CPU Timer1计时是更干净的做法。
5.3 TMU的精度能不能信
用TMU算三角函数不是没有代价的,最大的争议是精度。由于TMU在硬件里使用了查表和多项式逼近的组合策略,它的结果和数学库函数在个别输入角度下会有细微差别。我用一组随机角度对比了TMU和math.h的输出,与64位double标准值相比,两者的误差都在1e-6量级,对于单精度浮点存储来说,这个精度对于电机控制、逆变器锁相完全够用。
我只在一种场景下会避开TMU:当某些控制的除法或角度归一化对误差特别敏感,且算法里已经做了连续多次累加导致误差可能被放大时,我会保留部分运算使用double双精度计算,不盲目把所有计算都切到TMU。注意F28377D的FPU是单精度FPU,用double类型时编译器会调用库函数模拟双精度运算,速度会明显下降,所以不要轻易让double参与热点路径。
精度精测方面,如果项目里有严格的过流保护阈值,建议在实验阶段同时保留math.h和TMU两条路径,通过开关切换比较同一套控制算法在两种模式下的故障触发次数,确认误差不会影响保护逻辑,再彻底切到TMU。
5.4 RAM空间优化与代码搬移的平衡
代码放在RAM里的提速效果大家都看得到,但RAM分配的平衡术才是工程落地真正的关键。F28377D的RAM段包括M0、M1、D0、D1、LS0到LS7等多个区块。全局变量、堆栈、DMA缓冲都要占RAM,如果只图快把几百个函数全塞进RAM,编译能通过,链接时就会报RAM溢出。
我实践中总结出一个顺序:先测量热点函数,把占用时间最长的前5到10个函数搬进RAM,其余保留在Flash。搬入RAM时的优化标准不是函数体积大小,而是它在控制中断里的执行频率和单次耗时。一个被每秒调用1000次、单次只要100个周期的函数,优先级不如一个被每秒调用1万次、单次只要50个周期的函数。
还有一个容易被忽略的问题:把函数搬进RAM后,RAM里的代码也是要占用存储空间的,这里指被函数机器码占用的区域。如果这个区域同时被DMA缓冲使用,很容易发生数据错乱。我在调试中遇到过PWM比较值突然跳变的诡异问题,最后定位到是我把一个DMA缓冲区和某个ramfunc段分配到了同一块RAM区域,两边互相覆盖数据。排查方法是把SECTIONS的RUN段和变量段在CMD文件里显式错开,并且不要使用盗懒分配方式,那样链接器可能做出你意想不到的安排。
6. 针对实际项目的几条补充建议
如果只是想验证TMU有没有效果,看完前面的数据和代码基本就够了。但如果你想把这项优化真正落到量产项目里,还有几个细节值得多说一句。
第一,在工程创建早期就确定是否启用TMU支持,而不是在代码写完之后再去改编译选项。因为开启TMU会影响数学库的编译方式,一些隐式浮点运算和库函数链接也会随之改变。前期开启可以提前暴露精度和兼容性问题,避免后期返工。
第二,中断服务函数使用RAM执行时,要同步检查中断向量表定义。有些开发板工程把中断向量表指向Flash地址,函数却在RAM里,导致跳转链路在极端时序下出现额外等待,甚至偶发异常。我在项目里会把ISR也放在ramfunc段,并把PieVectorTable初始化成对应的RAM入口。
第三,TMU只对C28x CPU有效,F28377D还有CLA协处理器。CLA的三角函数实际上有自己的软件库,叫CLAMath,优化方式和TMU完全不同。如果你的算法有一部分跑在CLA上,不要指望TMU自动加速CLA侧的计算,需要单独评估CLAMath的性能和精度。
从这次实测结果看,F28377D在一颗芯片上同时提供C28x CPU、CLA、FPU和TMU,几乎是为实时控制量身定做的组合。把TMU用好之后,整个项目的性能余量会变得非常宽裕,后续再叠加谐波补偿、更复杂的观测器算法时,心里也有底。后续我打算继续对比一下CLA和CPU对同一套锁相算法的负载分配,如果能跑出有参考价值的对比数据,我再整理成一篇新的测试记录。