简介:本资源是一份围绕DSP寻址方式与汇编指令编写的Word学习笔记,适合正在学习TMS320C2000系列汇编编程的初学者与进阶开发者查阅。文档从汇编语言指令集概述入手,重点梳理了直接寻址、寄存器间接寻址、寄存器相对寻址、基址相对寻址、堆栈寻址等常用寻址方式,并结合典型指令助记符示例展开说明。同时,内容还分类介绍了算术指令、逻辑指令、移位指令、跳转指令等汇编指令形式,并通过操作数符号说明和条件判断标志位表,帮助读者理解指令书写规则与运算结果保存方式。资料共1个docx文件,压缩包大小约96KB,内容精炼、结构清晰,便于随时检索与对照学习。已有439人浏览学习,适合作为DSP汇编入门笔记或课程复习的参考资料,能够帮助读者快速掌握寻址方式与汇编指令的核心概念。
1. DSP 寻址方式和汇编指令:先从一段 FIR 滤波代码说起
我接手过某音频处理项目,采样率 48kHz,要求在 1ms 内完成一组 16 阶 FIR 滤波。同事先用 C 语言加优化选项写完,跑起来 CPU 占用接近 40%,后续再叠加降噪算法就明显吃紧。我花了两天时间,把核心循环改成汇编,CPU 占用直接降到 12%。这个差距不是编译器不行,而是 C 生成的代码根本没用上 DSP 内核特有的寻址方式和乘累加指令——循环缓冲还在用下标取模,系数还在用普通的内存读取。
这篇文章就围绕标题里这两个词展开:寻址方式解决的是“数据从哪拿、怎么拿最快”,汇编指令解决的是“一个周期内能同时做什么”。我会以一颗常见 DSP 内核为例,拆解直接寻址、间接寻址、循环寻址和位反转寻址,再把数据搬运、乘累加、并行指令和零开销循环讲透,最后用一个完整的 FIR 汇编实例串起来,并列出几个我在调试中翻过车的真实场景。适合正在从 MCU 转向 DSP 开发的嵌入式工程师,也适合被 DSP 汇编读得一头雾水的初学者。
2. 寻址方式怎么选:从直接寻址到循环寻址,寄存器与内存之间的门道
DSP 的寻址方式比通用 MCU 多,原因是它要服务信号处理里两个高频操作:遍历数组和读写环形缓冲区。选错寻址方式,哪怕指令写得再漂亮,数据通路也会成为瓶颈。
2.1 直接寻址:用数据页寄存器缩小指令字
直接寻址在 DSP 里不等于绝对地址。常见内核的做法是把内存分成若干数据页,每条访问指令只携带页内偏移,页号由专门的 DP 寄存器提供。比如 DP 指向第 0x20 页,偏移 0x10,实际访问地址是 0x20 乘以每页大小再加 0x10。单条指令字里少了十几位地址位,指令密度更高,代码体积更小。
MOV DP, #0x20 ; 设置数据页为第32页 MOV A, @0x10 ; 加载数据页第16个单元到累加器A第一行把 DP 寄存器指向一个固定数据页,第二行的 @0x10 是页内偏移。实际访问的内存地址按公式DP * page_size + offset计算,page_size 通常是 128 或 256,取决于内核设计。这种寻址适合访问全局变量和状态标志,比如滤波器系数表的长度、中断标志位、控制寄存器映射。好处是单条指令短,坏处是 DP 是全局的,改一次 DP 要额外花周期,频繁跨页访问不划算。
我一般只在程序开头设好 DP,把频繁访问的全局变量集中放在同一个数据页里。要注意链接脚本里变量布局,否则编译器把变量散到多个页,DP 切换开销就藏到你看不见的地方。
2.2 间接寻址:辅助寄存器与增量修饰符
间接寻址是遍历数组的主力。DSP 内核通常内置多个辅助寄存器,通常叫 ARn,比如 AR1 到 AR7,它们专门用于地址计算。指令里用*ARn表示取该寄存器指向的内存单元,还可以加修饰符控制访问后指针怎么变。
MOV AR1, #coeff ; AR1指向系数表首地址 MOV A, *AR1+ ; 加载当前系数,AR1自增指向下一个 MOV B, *AR1- ; 加载当前系数,AR1自减指向前一个 MOV C, *AR1+0 ; 加载当前系数,AR1按上次的增量步进*AR1+是后递增,*AR1-是后递减,*AR1+0是按 AR0 里的值做变址。变址模式很有用,比如隔点采样的多相滤波。需要注意的是,每条指令里 ARn 只能有一个增量修饰符,不能在一条指令里同时递增两个指针,除非内核支持双数据总线并行访问。
间接寻址是乘累加循环的骨架。执行 MAC(乘加)时,两个操作数地址分别由两个辅助寄存器提供,配合后递增修饰,一个周期完成“取数、乘法、累加、指针移动”四件事。这也是 DSP 和 MCU 拉开差距的地方。
2.3 循环寻址:环形缓冲区的硬件级支持
FIR 滤波器需要一片延迟线,新采样写入,旧采样移出,天然是个环形缓冲区。如果写代码判断边界再回绕,每个采样要多花好几个周期。DSP 内核提供循环寻址,硬件自动在缓冲区尾部回绕到头部。
MOV AR1, #delaybuf ; AR1指向环形缓冲区基址 MOV BK, #16 ; 缓冲区长度设为16 MOV A, *AR1+o ; 读取当前地址,指针到达尾部后回绕这里+o的 o 表示“循环回绕”修饰符,常见内核里也有用%符号表示。BK 寄存器存储缓冲区长度,硬件要求缓冲区大小为 2 的幂,起始地址必须按这个大小对齐。规则是:缓冲区地址的低 N 位作为环内偏移,超出部分被硬件强制截断到环内。
使用循环寻址的关键是基址对齐。比如 BK 设为 16,缓冲区首地址必须是 16 的整数倍,通常用汇编的.align 16指令或者链接脚本保证。我踩过最狠的一次坑就是把缓冲区定义在结构体中间,地址天然不对齐,回绕时直接飞到结构体其他字段上,见第 5 章。
2.4 位反转寻址:给 FFT 准备的专用指令
FFT 的蝶形运算要求输出按位反转顺序排列。软件做位反转排序要额外循环,DSP 内核提供位反转寻址,地址生成时先把索引位反转再加到基址上。
MOV AR1, #fft_base ; AR1指向FFT数据缓冲区基址 MOV AR0, #1 ; AR0为索引步长 SET bit_reverse ; 开启位反转寻址模式 MOV A, *AR1+0 ; 地址按位反转后的索引生成位反转寻址内部做的事情是:取当前索引值,把二进制位倒序,再与基址相加。比如索引 0、1、2、3 的二进制位反转后变成 0、2、1、3,硬件自动完成这个映射。这个功能对固定点数 FFT 很有用,比如 16 点时索引只用 4 位,基址按 16 对齐即可。
位反转寻址只能在特定模式下工作,并且要求数据点数固定为 2 的幂。如果你做的是变点数 FFT,或者数据本身不是按基址对齐分布,用这个寻址反而会让数据顺序乱掉。我的习惯是只在固定点数的 FFT 模块里用它,其他场景宁可写显式的位反转排序。
2.5 寻址方式选型:一张表从场景反推
| 寻址方式 | 访问对象 | 典型场景 | 性能特征 | 常用指令形态 |
|---|---|---|---|---|
| 直接寻址 | 全局变量、寄存器映射 | 控制标志、状态寄存器 | 指令短但受 DP 页限制 | MOV A, @offset |
| 间接寻址 | 数组、线性表 | 系数遍历、采样流 | 可自增自减,指针自动维护 | MOV A, *ARn+ |
| 循环寻址 | 环形缓冲区 | FIR 延迟线、波形表 | 硬件回绕,省边界判断 | MOV A, *ARn+o |
| 位反转寻址 | FFT 数据缓冲区 | 蝶形运算输入输出 | 硬件索引反转,省排序 | MOV A, *ARn+0 |
选型时我先看数据访问模式:线性遍历用间接寻址,定长环形缓冲用循环寻址,固定点数 FFT 用位反转寻址,零散的全局量用直接寻址。同一段代码里尽量减少寻址方式切换,因为模式切换往往要写控制寄存器,增加指令周期。
3. 汇编指令怎么读:从数据搬运到乘累加,一条指令做了什么
汇编指令是寻址方式的载体。看 DSP 汇编不要逐条去查助记符,先看它属于哪一类。数据搬运、算术逻辑、乘累加、并行与流程控制,每一类都有固定的操作数规则,掌握规则后读指令就像读表格。
3.1 数据传送指令:寄存器到内存的唯一通路
DSP 引脚上的数据要进入运算单元,必须先在寄存器里落脚。数据传送指令解决“寄存器与内存之间搬数据”,通常有立即数加载、寄存器互搬、内存读写三种操作数形态。
MOV A, #0x1234 ; 立即数0x1234加载到累加器A MOV B, A ; A的值复制到B MOV *AR2+, B ; B写入内存,AR2后递增立即数加载要注意位宽限制,常见内核支持 16 位立即数,超过范围就得拆两条指令。寄存器互搬是零等待的,内存读写则取决于总线占用。MAC 指令之所以快,是因为它在取操作数的同时完成了运算,而不是先 MOV 再 MUL。
我见过有人在乘法前用 MOV 把两个操作数分别搬到寄存器,再执行乘法,白白浪费两个周期。正确做法是让乘法指令直接从内存取一个操作数,另一个放在寄存器里。
3.2 算术与逻辑指令:标志位和条件执行
加减、逻辑与、或、异或、移位都属于这一类。它们共同维护一组状态标志:进位 C、溢出 V、符号 N、零 Z。DSP 的算术指令与 MCU 类似,但多了一个饱和模式位,开启后溢出不会卷绕,而是直接钳到最大值或最小值。
ADD A, B ; A = A + B SUB A, #1 ; A = A - 1 SAT A ; 开启累加器饱和保护 SHL A, #3 ; A算术左移3位饱和保护对音频处理极其重要。定点运算一旦溢出,波形会从正最大跳到负最大,产生爆音。开启 SAT 模式后,溢出值自动饱和到边界值,听起来只是一次削波,至少不会产生刺耳的突变。注意饱和位会影响后续所有算术指令,不需要时及时关闭。
条件执行在 DSP 汇编里很常见,指令带上条件后缀,只有标志位满足时才真正执行。比如ADD A, B, EQ表示零标志 Z 置位时才相加。这比跳转指令省钱,因为跳转要破坏流水线。
3.3 乘累加指令:单周期完成乘加的核心武器
DSP 叫数字信号处理器,核心就是乘累加指令。一条 MAC 指令在单个周期内完成两个数据的乘法并把结果加到累加器,市面上大部分 MCU 做不到这一点。
MAC A, *AR2+, *AR3+ ; A += (*AR2++) * (*AR3++)这条指令隐含三件事。第一,两个源操作数同时从内存读取,这要求内核至少有两套数据总线,一套读系数,一套读采样。第二,乘法结果在硬件乘法器里产生,不需要软件介入。第三,累加在专用累加器中进行,累加器宽度通常比数据位宽大很多,比如 16 位数据配 40 位累加器,防止中间结果溢出。
使用 MAC 时要注意流水线延迟。虽然每条 MAC 的吞吐量是一个周期,但乘法结果可能延迟数个周期才出现在累加器里。如果你紧接着用累加器做其他运算,拿到的可能是上一个乘法的结果。后面避坑章节我会细说,这里先记住一个经验:连续 MAC 没问题,中间隔乘法结果食用要小心。
3.4 并行指令与零开销循环:榨干每个周期
DSP 的另一大特色是并行指令。一条指令里的两个操作用||分隔,分别由不同的执行单元完成,比如一条指令同时做 MAC 和内存搬运。
MAC A, *AR2+, *AR3+ || MOV B, *AR4+左侧执行乘累加,右侧执行数据搬运,两个操作互不干扰。再配合零开销循环:一条循环指令让内核重复执行后续指令指定的次数,循环计数由硬件管理,不占额外周期。
RPT #15 ; 重复下一条指令16次 MAC A, *AR2+, *AR3+ ; 这一条被重复执行零开销循环消除了循环跳转和指针维护的开销。对比 MCU 上常见的 DECFSZ 跳转指令,两级跳转预测加上循环体,周期数立刻拉开差距。关键是 RPT 的范围有限制,一般只重复紧跟的一条指令,少数内核支持重复一个指令块。循环体一旦超过限制,就只能用普通跳转,性能立刻缩水。
3.5 指令格式:一位定寻址,立即数定长度
读汇编手册时你还会看到指令的二进制编码格式。会看格式不是让你写机器码,而是明白为什么有些指令长、有些指令短。常见内核的指令字有单字和双字两种,单字指令把寻址模式编码进操作数字段,比如一位决定操作数是寄存器还是内存,另一位决定内存访问是直接还是间接。
双字指令通常携带 16 位立即数或长偏移,比如加载一个大常数到寄存器。这类指令占用两个取指周期,放在循环里会拖慢指令预取。所以我在循环里尽量避免大立即数,实在要用的把常数预先放到内存里,用间接寻址去读。
理解了指令格式,你就能在写汇编时提前估算代码体积和取指带宽。比如一个 MAC 循环体是单字指令,预取队列不会断流;如果循环体里混入双字指令,流水线可能停顿,这时手动展开循环反而更慢。
4. 用汇编写一个 16 阶 FIR:最小可运行代码与参数对照
寻址方式和指令是零件,FIR 滤波器是把零件装成整机。FIR 恰好同时用到循环寻址、间接寻址、MAC 和零开销循环,串起来一遍,你对标题的理解就立体了。
4.1 为什么 FIR 是练手的最佳载体
FIR 滤波器的公式是 y[n] = sum(x[n-k] * h[k]),k 从 0 到 N-1。每个输出采样要做 N 次乘法和 N-1 次加法,采样数据按时间顺序移入延迟线,系数表固定不变。延迟线天然是环形缓冲区,用循环寻址;系数表遍历用间接寻址;乘加用 MAC;N 次循环用 RPT。一个 16 阶 FIR 覆盖了本文所有关键词,没有比这更合适的例子。
4.2 完整汇编代码与逐段说明
; 16阶FIR滤波器,输入输出均为16位定点数(Q15格式) ; 寄存器约定: ; AR1 -> 环形延迟缓冲区(长度16,基址按16对齐) ; AR2 -> 系数表coeff首地址 ; AR3 -> 输入序列首地址 ; A = 累加器,输出前截取高16位 FIR16: MOV AR1, #delaybuf ; 延迟缓冲区基址 MOV AR2, #coeff ; 系数表基址 MOV BK, #16 ; 环形缓冲区大小=16,硬件回绕 MOV A, #0 ; 清零累加器 MOV *AR1+o, *AR3+ ; 新采样写入环形头部,指针回绕 RPT #15 ; 下一条指令重复16次 MAC A, *AR2+, *AR1+o ; A += 系数 * 延迟线采样,延迟线指针循环回绕 MOV *AR3-, A ; 写入输出(示例中借用输入指针回退) RET代码第一段初始化三个指针和累加器。AR1 指向延迟缓冲区,AR2 指向系数表,BK 设置循环缓冲长度。注意延迟缓冲区基址必须按 16 对齐,稍后会讲原因。
第二段把新采样从输入序列读入,写入延迟缓冲区当前头部位置。*AR1+o中的+o让指针在缓冲区尾部自动回绕到头。写入后 AR1 停留在新头部,后续 MAC 从这个位置开始读取。
第三段是核心循环。RPT #15 让 MAC 指令重复执行 16 次,每次完成一次系数和采样的乘加。AR2 每次后递增,顺序遍历系数表;AR1 带+o修饰,从当前头部开始向前读延迟线,到尾部后自动回绕。16 次乘加后,累加器 A 里的值就是当前输出采样。
最后一段把累加器值写到输出位置。实际项目中这里通常接一个 DAC 或音频输出接口,示例里简化处理。
4.3 参数设置与定点格式注意事项
| 参数 | 值 | 说明 |
|---|---|---|
| BK | 16 | 必须是 2 的幂,与延迟线长度一致 |
| 延迟线基址 | 16 字节对齐 | 低 4 位地址必须为 0 |
| 累加器截取 | 高 16 位 | Q15 乘法结果需要左移一位补偿 |
| RPT 次数 | N-1 | 第 N-1 次为正的:0 到 15 共 16 次 |
重点是定点格式。系数和采样都用 Q15 格式,即整数部分 1 位,小数部分 15 位,数值范围在 -1 到 1 之间。两个 Q15 数相乘,结果是 Q30 格式,需要左移一位再截取高 16 位才能回到 Q15。MAC 指令本身不帮你左移,常见的做法是让累加器在 40 位内累加 16 次不溢出,输出时左移一位再取高 16 位。
SHL A, #1 ; Q30转Q15:左移1位 MOV *AR3-, A ; 写回输出这一段要在循环结束后执行。如果你忘了移位,滤波后的增益会低 6dB,听起来像是音量变小了。这种错很难肉眼发现,只能对比 C 参考实现。
4.4 用模拟器和验证数据确认正确性
代码写完不要直接上板,先在模拟器里单步执行。主流 DSP 开发环境都提供模拟器,能观察每个寄存器的值变化。我建议喂一个单位脉冲序列:第一次输入 0x7FFF(最大正值),后续输入全为 0。理论输出应该是系数表本身乘以 0x7FFF 再缩放。
模拟器里检查每个周期后累加器和 AR1 的值。看 AR1 是否在延迟线范围内按预期回绕,防止指针飞出去。通过与 C 参考实现对比,能快速发现左移位、符号扩展这类问题。
5. DSP 汇编避坑:延迟槽、并行冲突与循址对齐的 5 个现场
这部分是血泪经验。每个问题我都亲手遇到过,现象、原因、解决一条条说清楚。
5.1 并行指令里两个操作数同时读同一地址,结果张冠李戴
有一次我在 MAC 后面用|| MOV B, *AR1+想顺便把延迟线数据备份到 B,运行后发现 B 里的值和 AR1 指向的单元对不上。
原因是内核虽然有多条数据总线,但两个并行操作同时读同一个地址时,硬件只保证一条指令拿到正确数据,另一条可能读到旧值或不确定值。MAC 本身要读取 AR1 指向的采样,并行 MOV 又去读同一个 AR1,冲突发生了。解决方法是让并行指令读不同的地址,或者干脆把备份动作安排在下一个周期。遇到这种问题,性能再好看也得先保证数据正确。
5.2 循环缓冲区基址没对齐,指针回绕直接飞出去
我第一次用循环寻址写延迟线,把 delaybuf 定义在结构体中间,BK 设为 16。运行后延迟线中的采样值隔几次就出现一个巨大的尖峰,数据全乱。
原因是不对齐。循环寻址的硬件回绕原理是把地址的低 N 位作为环内偏移,高位置为缓冲区基址。如果基址本身不是 BK 的整数倍,回绕计算会把高位的进位带到错误位置。解决方法是给 delaybuf 加.align 16伪指令,或在内核里把它单独放到一个段里,由链接器保证对齐。从那以后,我写任何环形缓冲区都会先检查基址最低位是否为零。
5.3 MAC 后立刻取累加器,拿到的是上一个周期的旧值
某次优化循环时,我在 MAC 指令后紧接着用MOV *AR3+, A把累加器写入输出,结果输出比预期晚了两个采样,波形整体平移了。
原因是指令流水线。MAC 的乘法结果需要经过数级流水段才写入累加器,紧随其后的指令读取累加器时,写回还没发生。解决方法是把输出写入放在两个 MAC 之后,或者用并行指令在 MAC 执行期间同时做 MOV。我一般会调整循环结构,让读取累加器的指令距离计算指令至少隔开 2 个周期,这样既不损失性能也不会拿错数据。
5.4 条件执行依赖刚更新的标志位,判断结果总是落后一拍
我曾经在比较指令之后立即用条件执行判断跳转,结果分支方向偶尔错误,程序时而正常时而翻车。
原因是标志位更新同样有流水线延迟。比较指令执行完,Z、N、V 这些标志位要过一个周期才稳定,马上用条件指令会读到旧值。解决方法是隔一条无关指令再用条件指令,或者无条件执行一条空操作 NOP。不要心疼这一个周期,条件判断错误带来的定位成本远高于一个 NOP。
5.5 中断服务程序里改了辅助寄存器,返回后主循环指针全乱
一个中断驱动的采样程序,采样率稍高就出现偶发噪音。我花了很久定位,最后发现是中断服务程序里顺手用了 AR1,返回后主循环的 AR1 指向了完全不同的地址。
原因是中断上下文默认不保存 ARn 寄存器。DSP 的辅助寄存器数量有限,ISR 里一旦使用就必须自己压栈和恢复。解决方法是 ISR 入口处把用到的 ARn 全部压入内存栈,退出前恢复。后来我还做了一条规定:ISR 里只用固定的某几个 ARn,主循环用另外几个,从寄存器分配层面规避冲突。
6. 验证与进阶:单步追踪、周期计数与手工替换 C 的边界
写完汇编只是开始,验证它真的正确而且更快,才算落地。验证方法分三层:功能正确性看模拟器单步结果,性能看周期计数寄存器,稳定性看长期老化运行。
功能上,我最常用的是“已知输入对已知输出”。把一组固定序列存入内存,手工算好预期 FIR 结果,然后在模拟器里跑完对比。这一步能抓出绝大部分移位、截断、符号扩展问题。再拿同一组数据跑 C 参考实现,两边输出完全一致才敢上板。
性能上,DSP 内核一般都带周期计数寄存器,跑一段代码前后各读一次,差值就是这个函数消耗的周期数。C 版本和汇编版本各测一遍,对比才有说服力。我见过有人优化后周期数降了 40%,却忽略中断禁用时间变长导致实时性下降,性能指标要看最坏情况而不是平均情况。
进阶优化我会做两件事。第一,手动展开循环。RPT 一次只重复一条指令,如果把两阶 FIR 合并成一条循环体,用两次 MAC 处理两个采样,循环控制次数减半,代价是代码体积增加。展开倍率不是越高越好,指令缓存小的内核展开太多反而取指停顿。第二,用双缓冲区。DMA 把下一块数据搬到内存的同时,CPU 在处理当前块,把搬数据和算数据重叠起来。这两个技巧足够把 FIR 这类核心循环推到接近理论极限。
最后说一条我一直保留的习惯:每次改完汇编,把编译生成的反汇编和手写源码一行行对照。伪指令、对齐、重定位都可能让最终二进制偏离预期,对照反汇编能保证你写的东西和实际跑的东西一致。这个习惯帮我避开过不止一次“链接器悄悄挪了缓冲区”的坑。希望帮到你。
本文还有配套的精品资源,点击获取