ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CH579中断向量表重定位:RISC-V软件跳板方案实践指南

CH579中断向量表重定位:RISC-V软件跳板方案实践指南 做了大半年CH579的BLE透传产品中间最让我头疼的既不是射频调优也不是协议栈适配反而是个听起来不算复杂的东西——中断向量表重定位。Bootloader能启动、App能烧进去但只要一开中断整个系统就像被人掐住了脖子不是跳进HardFault就是无限复位。CH579用的是青稞V2内核属于RISC-V阵营跟STM32那种带VTOR寄存器、一行代码就能切换向量表的玩法完全不一样网上搜到的大多是ARM平台的经验直接照搬保准踩坑。这篇把我实际调通的一套方案完整拆开讲从原理到代码到坑位一次说清。我先把结论放在前面CH579中断向量表重定位最稳的做法不是指望一个寄存器搞定一切而是用RAM中断处理函数跳转表 统一异常入口的组合拳。这套方案能同时兼顾Boot和App两套固件、不用反复擦写Flash、还不受向量表位置约束调试起来非常舒服。下面从原理开始一层层拆。1. 为什么非要折腾中断向量表重定位1.1 一个来自IAP的典型翻车现场做OTA或者IAP升级的朋友应该都有印象只要产品需要Boot程序 App程序双区运行中断向量表就是绕不开的一道坎。我的项目是这样设计的芯片上电先跑Boot区Boot负责从串口接收固件、校验、擦写Flash然后把控制权交给App区。App区跑BLE协议栈和业务逻辑里头自然离不开定时器中断、串口中断、蓝牙中断这些外设响应。第一次联调的时候Boot能正常跳转App的main函数也能执行LED灯都闪了我以为万事大吉。结果只要协议栈一初始化系统立刻卡死。后来接上调试器一看PC指针跑到了一个根本不应该出现的位置再仔细查是中断触发之后CPU去读了一个错误的中断入口地址。问题根源很清楚从Boot跳到App之后CPU的中断入口还在老地方它根本不知道App的向量表已经搬家了。1.2 中断向量表的运作逻辑与两种内核的差异先帮你理一遍中断的基本流程。当外设产生中断时CPU会暂停当前指令流跳到一个预先约定好的入口地址去执行中断服务程序。问题在于这个入口地址怎么定不同内核给的答案不一样。ARM Cortex-M系列的做法是提供一个VTOR向量表偏移寄存器里面存一个基地址CPU根据中断号在基地址 4 × 中断号处取出处理函数指针直接跳转。所以STM32做IAP的时候只要改一下VTOR的值把向量表指向App所在地址就行一行代码映射整个表格所以网上ARM平台的帖子特别多。而RISC-V内核走的是另一条路。标准RISC-V不搞一个中断号对应一段偏移地址的查表方式而是把所有异常和中断统一送到一个入口——mtvec寄存器指向的那个地址。CPU进入中断后软件自己通过读mcause寄存器判断是哪种中断再做分发。所以对CH579来说所谓中断向量表重定位核心不是把一张表搬到另一个内存地址而是要确保CPU从复位、Boot、再到App的全过程中处理中断的那套总入口 分发逻辑始终指向当前正在运行的那份固件所期望的位置。打个比方ARM平台搬迁的是整个电话总机电话号码和分机座位一起搬RISC-V平台搬的是话务员不管电话从哪打进来都得先找到这个话务员再由他转接到对应部门。如果App觉得话务员应该在新楼而CPU还在拨旧楼的总机号码电话进来就永远接不通。1.3 用PC启动过程理解顺序问题有个很有意思的原理性问题正好能帮我们把这事想透电脑开机时BIOS的上电自检和中断向量表的建立到底谁先谁后答案是上电自检在前操作系统建立自己的中断向量表在后。整个流程是CPU复位后先从固定地址取指令运行固化在主板ROM里的BIOSBIOS完成CPU、内存、显卡等基础硬件的自检和初始化之后再根据启动顺序把引导程序读入内存最后由引导程序把操作系统内核加载起来操作系统此刻才开始建立属于自己的中断向量表接管所有硬件中断。这个流程跟我们MCU的Boot跳到App几乎是一个模子刻出来的芯片复位先跑Boot相当于BIOSBoot做必要初始化用串口/无线把固件准备好相当于引导程序加载内核然后跳转到AppApp接管的头等大事就是建立自己的中断向量表。顺序一旦搞反CPU后续收中断就会出乱子。所以回到我们的核心问题CH579从Boot跳转到App后必须有一套机制让CPU的中断入口切换到App的话务员。这就是CH579中断向量表重定位方案要解决的终极问题。2. CH579中断机制的底层细节与三条路线2.1 CH579的向量表到底放在哪CH579的Flash从0x00000000地址开始映射芯片出厂自带一段ROM代码用于串口/ISP下载用户代码默认也是从0x00000000开始存放。这里要注意RISC-V处理器复位后PC默认指向0x00000000地址从这个地址取得第一条指令开始执行。所以如果我们把Boot程序放在0x00000000开头那么复位后自然先跑Boot。中断入口由CSR寄存器mtvec控制它保存的是异常入口地址。CH579的青稞V2内核在RISC-V标准基础上做了一些扩展但本质上仍然遵循设置mtvec - 触发中断 - 从mtvec指向的地址取指这个模式。只要Boot跳转之后没有修改mtvecCPU后续触发中断时依然会跑到Boot的异常入口去取指然后执行Boot里那套中断服务逻辑。如果Boot的异常入口代码跟App的中断期望完全不匹配系统就会表现出各种各样的诡异现象。我在项目里确认过CH579在默认状态下SDK启动文件会把异常入口布置在Flash起始区域。换句话说只要Boot占着0x00000000App要想让CPU的异常入口切到自己这边就必须处理两件事一是让mtvec指向App能控制的中断入口二是让这个中断入口能正确分发到App自己的处理函数。2.2 硬件重定位与软件跳板两条主线针对这两件事业界大概有三条路线。路线一硬件向量重定位寄存器。如果芯片厂商在设计时提供了额外的向量偏移寄存器比如把向量表基地址映射到RAM的某个区域那确实能做到类似于ARM VTOR的效果改一个寄存器CPU中断自动去新表查处理函数。但CH579这个级别的中低功耗蓝牙MCU并不是所有型号都保证提供这样的寄存器具体要看芯片参考手册。如果手册里确认有这个寄存器那确实最轻松但我个人不建议把整个项目赌在这上面因为一旦换型号或者寄存器行为有出入整套方案都要重写。路线二软件跳板查表分发。思想是把中断总入口固定放在一个Boot和App都能访问的安全位置比如Boot区某段代码入口处先保存现场读取mcause获得中断编号然后去一张RAM里的中断处理函数跳转表查到真正要执行的函数地址最后跳转执行。这张RAM表是个公共资源Boot阶段由Boot填充成Boot的处理函数App启动后再由App覆盖成App的处理函数。因为跳转表在RAM里读写非常方便谁在跑就刷新成谁的函数指针整个重定位过程就是改一串内存地址。路线三直接在Flash里改写跳转指令。把0x00000000附近的向量表做成若干条跳转指令Boot运行前先烧好跳转到Boot handler的指令跳转App前再用Flash擦写把跳转指令改成跳转到App handler。这个方案看着挺底层实际操作起来痛苦不堪Flash擦写有寿命限制、操作时间还长、万一写一半掉电直接变砖。我在调研阶段试过一次就放弃了除非产品完全不需要现场升级否则别碰。综合来看方案二最稳既不需要赌芯片有没有专用寄存器也不涉及Flash运行时改写Boot和App两边逻辑还对称。下面的实操部分全部围绕方案二展开。2.3 三方案横向对比方案核心思路优点缺点推荐度硬件寄存器重定位修改芯片专用向量偏移寄存器切换最快、代码最少对芯片型号依赖强需确认手册看型号软件跳板查表分发RAM中放函数指针表统一入口查表跳转通用、安全、不写Flash、调试友好中断响应多几条指令开销强烈推荐Flash跳转指令改写擦写向量表区的跳转指令理论性能最好擦写风险高、寿命受限、掉电变砖不推荐我在实际项目里选择方案二还有一个重要原因它天然把向量表从Flash挪到了RAM这就避免了App区Flash擦写时CPU取不到中断指令的尴尬场景。后文会细讲这个问题因为很多同行在这里吃过闷亏。3. 实操软件跳板查表分发的完整落地3.1 地址规划与链接脚本修改动手第一步先把Boot和App各自的地址空间划分清楚。我的项目设计是总Flash 256KB、总RAM 32KB。Boot区放在0x00000000起始占前16KBApp区从0x00004000开始占后面240KB。RAM方面我把低地址8KB区域预留给Boot的栈、全局变量和中断跳转表App的RAM从0x20002000开始占24KB。这个划分不是拍脑袋定的。Boot区16KB足够放串口接收、Flash驱动和校验逻辑如果你还要加加密算法或者AES解密建议Boot区扩到24KBApp区反正后头还有两百多KB业务逻辑随便放。RAM低8KB之所以单独留出来是因为中断跳转表需要一个Boot和App都能访问的固定地址我把它放在0x20000000占用256字节正好64个表项对齐CH579目前所有中断源数量。App工程的链接脚本MounRiverStudio里是.ld文件需要做如下修改。修改前Flash段大概是MEMORY { FLASH (rx) : ORIGIN 0x00000000, LENGTH 256K RAM (xrw) : ORIGIN 0x20000000, LENGTH 32K }改成MEMORY { FLASH (rx) : ORIGIN 0x00004000, LENGTH 240K RAM (xrw) : ORIGIN 0x20002000, LENGTH 24K }Boot工程的链接脚本则保持不变还是从0x00000000开始。这里有个坑我必须提醒你App工程的所有绝对地址引用包括中断函数、常量表、启动代码都必须基于新的ORIGIN重新编译一遍。如果你只是改了链接脚本却在某个地方硬编码了0x00000000附近的Flash地址跳转到App后读到的内容就是Boot区域的数据函数指针绝对会错乱。3.2 编写统一异常入口trap_entry有了地址规划下面进入核心代码环节。首先要有一段所有中断都从这里进的汇编入口我把它放在Boot区的固定位置比如0x00000100附近。这段代码的作用是保存现场、读取中断原因、查RAM跳转表、跳转到真正的处理函数。.section .text.trap_entry .globl trap_entry .type trap_entry, function trap_entry: // 保存上下文到当前栈这里示意保存部分关键寄存器 addi sp, sp, -64 sw x1, 0(sp) // ra sw x5, 4(sp) // t0 sw x6, 8(sp) // t1 sw x7, 12(sp) // t2 sw x10, 16(sp) // a0 sw x11, 20(sp) // a1 sw x12, 24(sp) // a2 sw x13, 28(sp) // a3 sw x14, 32(sp) // a4 sw x15, 36(sp) // a5 csrr a0, mcause // 读取中断原因 csrr a1, mepc // 读取故障PC备用 // 用中断编号作为索引查表 // g_irq_vector_table 定义在 C 文件中地址在 RAM 固定区域 la t0, g_irq_vector_table // mcause 的低位是中断编号 andi t1, a0, 0xFF slli t1, t1, 2 add t0, t0, t1 lw t2, 0(t0) // 取出真正的处理函数地址 beqz t2, trap_default // 表项为空则走默认处理 jalr t2 // 跳转到处理函数 trap_default: // 默认错误处理可以在这里打点标记或者直接复位 j trap_default // 恢复上下文 lw x1, 0(sp) lw x5, 4(sp) lw x6, 8(sp) lw x7, 12(sp) lw x10, 16(sp) lw x11, 20(sp) lw x12, 24(sp) lw x13, 28(sp) lw x14, 32(sp) lw x15, 36(sp) addi sp, sp, 64 mret这段汇编大家不要照抄完直接上板寄存器保存列表要根据你们项目的实际中断服务情况做完整展开。CH579的完整上下文应该包括全部通用寄存器尤其是那些会呗C编译器当作临时变量的寄存器。我第一次跑的时候偷懒只保存了一部分寄存器结果中断返回后现场被破坏查了半天才发现是上下文保存不全。完整列表可以从官方SDK的startup文件中搬那是最可靠的。为什么要在统一入口里读mcause做分发而不是像ARM那样直接索引跳转因为RISC-V标准模型里只有这一条总入口所有中断号都汇集到这里不分发不行。你可以看到分发逻辑本身只是一次内存读取加一次函数指针跳转性能代价很小在CH579这种几十MHz主频的芯片上多花几十个时钟周期完全无感。3.3 RAM中断处理函数表与分发逻辑有了统一入口剩下的关键就是那张RAM跳转表。我在C文件里定义如下// 中断跳转表放在固定RAM地址Boot和App共用 typedef void (*irq_handler_t)(void); #define IRQ_TABLE_BASE 0x20000000UL #define IRQ_TABLE_NUM 64 volatile irq_handler_t g_irq_vector_table[IRQ_TABLE_NUM] __attribute__((section(.vector_ram))); // 链接脚本里把 .vector_ram 定位到 IRQ_TABLE_BASE这里用了section(.vector_ram)然后在链接脚本里为这个段指定地址确保无论Boot工程还是App工程编译这个数组都稳稳落在0x20000000。如果不指定段两个固件编译出来的全局变量地址可能完全不同Boot写好的表App根本找不到。分发函数本身不复杂核心就是防止越界和空指针void irq_dispatch(uint32_t mcause) { uint32_t idx mcause 0xFFUL; if (idx IRQ_TABLE_NUM) { irq_handler_t h g_irq_vector_table[idx]; if (h ! 0) { h(); } } }前面汇编里其实已经把查表逻辑写死了所以C层面这个分发函数更多是提供一种安全的初始化或兜底入口。如果你愿意全部用汇编搞定irq_dispatch这部分可以省略直接让汇编查表跳转就行。我之所以在C里还留一个分发函数是为了方便调试时在分发函数里打断点看一眼报告过来的中断编号是不是预期值。3.4 Boot跳转App前的现场清理Boot跳转App之前必须做好“现场清理”这是最容易把整套方案搞砸的一步。我的跳转流程大体如下先把Boot自己能用的中断处理函数先填入跳转表比如串口接收中断这样Boot在刷固件期间能正常用串口通信。然后执行App跳转。跳转前要做的事情停止所有用到的外设尤其是定时器、DMA、UART这类会产生中断的外设调用关全局中断确保后续准备过程不被中断打断关闭那些已经使能了的NVIC中断源并把挂在PendSV/外设标志寄存器里的中断pending位清掉防止跳转瞬间把旧环境的中断带到App里关闭全局中断注意这里的关中断不是简单一条指令最好先清mstatus.MIE然后立刻执行一条fence.i保证指令缓存同步设置好App的栈指针、入口地址执行跳转指令。关键的跳转代码我写成这样typedef void (*app_entry_t)(void); #define APP_START_ADDR 0x00004000UL #define APP_VTOR_OFFSET 0x00000000UL static void jump_to_app(void) { uint32_t app_sp *(volatile uint32_t *)APP_START_ADDR; uint32_t app_pc *(volatile uint32_t *)(APP_START_ADDR 4); // 关闭全局中断 __disable_irq(); // 清掉缓存 __asm volatile (fence.i); // 设置App栈指针 __asm volatile (csrw mscratch, %0 :: r(app_sp)); // 跳转到App入口 __asm volatile (jr %0 :: r(app_pc)); }很多RISC-V的固件头部都会在偏移0处放栈指针、偏移4处放复位入口这个布局可以参考官方SDK启动文件的注释。我这段代码里用mscratch先暂存栈指针是因为直接跳转后App启动代码里还会自己做初始化不一定马上用我们传进去的栈指针但先把值准备好没坏处。之所以跳转前要关掉全局中断并清掉pending位是因为如果某个外设中断已经在硬件层面挂起App一旦把总中断打开CPU会立刻进入trap_entry而此时RAM跳转表还残留着旧Boot的表项就会把中断扔给一个不存在的Boot handler然后跑飞。清pending位的具体操作要看是哪类中断比如UART就清RXDNE标志、定时器就清中断标志寄存器实在偷懒也有办法把所有用过的外设时钟先在中断禁止状态下复位一遍再从App重新初始化。3.5 App启动早期的表项自更新App端的工作量其实不大核心是“尽早接管跳转表”。我在App的main函数最开头在时钟、外设初始化之前就先重建中断跳转表内容void app_init_irq_table(void) { // 先关全局中断防止表项写到一半被中断打断 __disable_irq(); g_irq_vector_table[UART0_IRQn] UART0_IRQHandler; g_irq_vector_table[BLE_IRQn] BLE_IRQHandler; g_irq_vector_table[SYSTICK_IRQn] SYSTICK_IRQHandler; // ... 其他中断按需填充 // 保证写完后CPU能看到最新数据 __asm volatile (fence.i); // 重新打开全局中断 __enable_irq(); }这里每个IRQ编号和处理器函数名的对应关系要以CH579的头文件为准不同中断源映射可能有差异。我建议把所有用到的中断都填上不用的表项保持为0因为统一入口里对空表项会走默认处理宁可默认复位也不要在空指针上跳舞。App启动早期还要做一件事把mtvec设置到我们的统一入口trap_entry。如果你的trap_entry在0x00000100那就直接写__asm volatile (csrw mtvec, %0 :: r(0x00000100UL));如果你直接把trap_entry链接在App工程里那地址就是App链接出来的地址把它写进mtvec也行。但我更推荐把trap_entry放在Boot区固定位置因为App区很可能在后续OTA升级中被整体擦除如果中断入口也放在App区擦写Flash时中断一来就完蛋。放在Boot区就稳得多这算是这套方案里很划算的一个保险。还有个小细节mtvec寄存器写入时RISC-V规范要求地址按4字节对齐并且如果芯片支持向量模式低2位是模式位。如果你写0x00000101表示向量模式CPU会按照中断编号跳到不同偏移但那个布局就必须每4字节一条跳转指令来配合。我的方案走的是统一入口软件分发也就是直接写0x00000100明确告诉CPU所有异常都进同一个入口后续分发靠代码查表。这两种模式不要混用否则地址错位很难查。4. 常见问题排查与调试实录4.1 中断完全不触发的排查顺序我遇到过最频繁的问题是升级完这套方案后App里某个中断死活不触发。遇到这种情况我的排查顺序固定如下先看mtvec实际值对不对。在MounRiverStudio的调试界面里打开RISC-V寄存器窗口读取mtvec确认它指向的是trap_entry。如果mtvec还是0说明App启动早期初始化IRQ表的代码根本没跑到或者fence.i没执行到。再看全局中断开关mstatus.MIE是否为1RISC-V的机器模式中断总开关在这里很多人写外设使能时忘了把总中断打开。然后在irq_dispatch入口处打断点如果断点不进来说明中断连统一入口都没到如果能进来但表项是0或者错误的函数指针那就继续查IRQ表内容。最后确认一下外设本身的中断使能比如UART的IE位、NVIC对应的通道enable位置不要被外设使能寄存器骗了它跟全局中断、mtvec是三层独立开关缺一个都进不来。4.2 跳转后中断风暴与标志位残留另一种常见症状跳进App之后系统像是被某个中断“粘住”了不断进入trap_entry看起来很像死循环。碰上这种情况我建议先不要慌在trap_entry里读mcause看看是哪个中断号然后直接去查对应外设的中断状态寄存器。大部分情况下是因为跳转前某个外设的中断挂起标志没有被清除比如UART接收缓冲区里还有未读取的数据RXDNE标志仍然置1。Boot跳转前那段“外设停止操作”如果做得不彻底很容易把这个标志带进App。处理办法是跳转前遍历所有已启用的外设把中断标志位清一遍或者简单粗暴地把相关外设时钟在跳转前全部复位让硬件回到干净的初始状态。清完标志再打开全局中断这个“中断风暴”一般立刻消失。4.3 Flash擦写与中断打架这个问题藏得很深我也是在统计OTA失败率时才发现的。如果中断处理函数和trap_entry都放在App区Flash上那么当App执行Flash擦写时它擦除的扇区可能正好包含中断处理代码或向量表。擦写过程中CPU一旦收到中断取指读到的是全0xFF或者半擦除状态的数据轻则跑飞重则直接卡死。我的方案因为把trap_entry放在Boot区RAM跳转表根本不在Flash上所以天然避开了这个问题。但如果你把某些中断处理函数也放在App区那Flash擦写期间仍然有风险。稳妥做法是Flash写操作期间关中断或者把最紧急的中断处理函数通过__attribute__((section(.irc)))之类的段属性放到RAM执行。这部分代码体积不大RAM充足的话完全放得下。另外提醒一句Flash擦写期间关中断不等于可以无限时关中断。CH579的BLE协议栈对中断响应时间很敏感关中断时长尽量控制在几十微秒以内否则协议栈的链路层很容易掉包。具体优化方式是缩短单次擦写时间、分块擦写、在关键擦写间隙打开中断。4.4 链接脚本地址与重定位表对不齐还有一个不少人容易忽略的坑编译的时候链接脚本改了但代码里某个数组或者函数还是按旧的Flash地址编译的。最典型的是某个函数指针被直接写死在Flash里比如(irq_handler_t)0x00001234这种。一旦App区Flash被擦除重写这个地址对应的内容早就变了中断跳板一跳就跳进虚空。排查方法是检查map文件确认IRQ表被分配到的地址确实是0x20000000。再用nm或者调试器看App入口地址确认链接脚本修改后0x00004000处确实放的是App的复位向量和栈顶。如果发现App复位向量没有落在0x00004000说明链接脚本没生效或者工程里还存在其他固定地址的段干扰了布局。另外boot跳转前从0x00004000地址读App栈顶和入口PC时要注意这两个值是从Flash直接读的如果App还没烧录或者烧录地址不对读到的可能是乱码。我习惯在Boot里加一个App合法性校验读App区开头的若干字节做CRC校验校验通过才跳转否则留在Boot等待升级。这样能避免跳进空Flash的尴尬。4.5 调试器在线仿真时的注意事项最后聊聊调试。我用的是WCH-Link加上MounRiverStudio这套组合整体体验还算顺手。但在线仿真中断向量表重定位方案时有几个点要特别注意。首先调试器复位后PC会指向0x00000000也就是Boot区入口。如果你正在调试App工程复位后直接跑到的是Boot的代码而不是App的main函数。这时候不要慌张直接在App的main函数处打断点然后点击全速运行等Boot跳转过来。或者更暴力一点在调试配置里把复位向量临时改到0x00004000但这样会绕过Boot流程有时候反而更麻烦。其次调试过程中如果把断点打在trap_entry或者irq_dispatch里要留意中断嵌套问题。调试器本身也可能触发异常如果断点打在统一入口里一进中断就会停下来频繁命中会让人崩溃。建议把断点放在具体某个外设的handler函数入口比如UART0_IRQHandler这样能准确捕获目标中断是否真的执行到了。还有一点RAM跳转表的数值最好在watch窗口里盯一下尤其在Boot跳转前和App初始化后这两个时间节点。我曾经遇到过优化器把IRQ表数组优化掉的情况因为表项只被汇编代码引用、C代码本身没有读编译器觉得这是个“死变量”。解决办法是在数组声明时加上volatile并且在链接脚本里给.vector_ram段加上KEEP防止被垃圾回收机制丢掉。这算是少有人提的坑写在这里帮大家省一次排查时间。5. 一点个人体会这套方案在CH579上跑了大半年经过多次OTA验证我可以负责任地说软件跳板查表分发的路子是走得通的。跟硬件寄存器重定位那种“一把梭”的做法比它确实多了几十条指令的响应开销但在实际产品里人类完全感知不到这点延迟。换来的却是不挑型号、不擦Flash、Boot和App对称管理这套优势性价比非常高。最后再分享一个个人习惯每次写完IRQ表更新函数我会在更新前后各加一个临界区保护并在函数末尾做一次断言检查关键表项是否在预期地址上。这类固定在RAM地址的共享资源最容易出的问题就是某次编译后地址悄悄变了而无人察觉。把地址断言写进代码相当于给程序加了一道护栏以后改链接脚本或者加新功能时心里都有底。中断向量表重定位这件事说到底不是记一两个寄存器的问题而是要把“CPU复位后到App接管之前每个人的中断入口分别在哪”这个问题彻底想通。搞懂这一层不管换什么芯片上手都快。
返回列表