
最近在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_supportfpu32 --tmu_supporttmu0第一个选项告诉编译器目标芯片支持C28x FPU浮点单元第二个选项告诉编译器目标芯片支持TMU单元。如果只开fpu32而没开tmu_support即使代码里写了TMU内建函数编译器也可能报错或者fallback到普通库函数实现。反之如果开了tmu_support但芯片型号没有TMU生成的目标代码会在不支持指令的芯片上触发异常。我在CCS工程里的做法是在Project Properties里找到Build → C2000 Compiler → Processor Options把浮点支持选择为“FPU single precision (--float_supportfpu32)”把TMU支持选择为“TMU0 (--tmu_supporttmu0)”。如果用的是命令行编译就在编译参数里显式加上这两个选项。3. 测试环境和完整计时代码3.1 硬件与工程配置测试用板是我自己画的一块以F28377D为核心的电机/逆变器控制板外部晶振20MHz内部锁相环倍频到200MHz。调试器用的是XDS110IDE是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算一次。最终得到的结果换算成周期如下运算组合sinfcosfatanfatan2f 平均周期math.h Flash运行6162103186math.h RAM运行424174138TMU内建 Flash运行12111931TMU内建 RAM运行881324看到这个表的时候说实话挺惊讶的。math.h版本无论放Flash还是RAM都要比TMU版本慢不少尤其atan2f这种函数math.h版本在Flash里一晃要186个周期而TMU在RAM里只要24个周期差距接近8倍。哪怕最保守地看sinfmath.h在RAM里的42周期也比TMU在Flash里的12周期慢2.5倍。这里要提醒一下不同优化等级会影响数据。我是用CCS默认的-O2优化级别测的如果开-O3math.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对同一套锁相算法的负载分配如果能跑出有参考价值的对比数据我再整理成一篇新的测试记录。