异常向量表与异常入口

好,咱们接着聊异常处理。上一章我讲了异常的基本概念,这一章咱们深入底层,看看异常向量表到底长什么样,怎么用。

说实话,我刚接触VxWorks时,觉得异常向量表就是个跳转列表。后来踩过坑才明白——这东西要是没配好,系统连启动都启动不了。你想想看,CPU一上电,第一条指令就是去查异常向量表。如果表是空的或者地址错了,那系统直接就“飞”了。

异常向量表的结构

异常向量表,说白了就是一张地址映射表。每个异常类型对应一个固定的表项,表项里存放的是异常处理函数的入口地址。

在ARM架构下,异常向量表通常放在内存的起始位置,也就是0x00000000处。每个表项占4个字节,一共8个异常类型,总共32个字节。我列个表给你看:

偏移地址 异常类型 说明
0x00 复位异常 系统上电或复位时触发
0x04 未定义指令异常 CPU遇到不认识的操作码
0x08 软件中断异常 SWI指令触发,常用于系统调用
0x0C 预取指令中止异常 取指令时发生错误
0x10 数据中止异常 读写数据时发生错误
0x14 保留 ARM架构保留未用
0x18 IRQ中断 普通中断请求
0x1C FIQ中断 快速中断请求,优先级最高

每个表项里放的是什么?是一条跳转指令。比如:

B   _excHandlerReset     ; 复位异常跳转
B   _excHandlerUndef     ; 未定义指令跳转
B   _excHandlerSwi       ; 软件中断跳转
B   _excHandlerPrefetch  ; 预取中止跳转
B   _excHandlerDataAbort ; 数据中止跳转
B   _excHandlerReserved  ; 保留
B   _excHandlerIrq       ; IRQ跳转
B   _excHandlerFiq       ; FIQ跳转

嗯,这里要注意:有些架构允许放绝对地址,但ARM推荐用跳转指令。为什么?因为跳转指令是位置无关的,你挪动代码位置也不用改表项。我在项目中遇到过有人直接放地址,结果代码重定位后系统直接崩溃——那场面,真是惨不忍睹。

异常入口函数的编写

异常入口函数,就是真正处理异常的地方。但你不能直接写C函数就完事了。为啥?因为异常发生时,CPU的上下文(寄存器、状态位)是混乱的。你得先保存现场,再处理异常,最后恢复现场。

我一般这样写入口函数:

_excHandlerDataAbort:
    // 第一步:保存上下文
    STMFD   sp!, {r0-r12, lr}   // 压栈保存通用寄存器和返回地址
    
    // 第二步:获取异常原因
    MRS     r0, cpsr            // 读取当前程序状态寄存器
    AND     r0, r0, #0x1F       // 提取模式位
    
    // 第三步:调用C语言处理函数
    BL      excDataAbortHandler // 跳转到C函数处理
    
    // 第四步:恢复上下文
    LDMFD   sp!, {r0-r12, lr}   // 出栈恢复寄存器
    
    // 第五步:返回异常点
    SUBS    pc, lr, #8          // 修正返回地址并跳回

这里有个关键点:返回地址的修正。数据中止异常发生时,LR寄存器保存的是异常指令的下一条指令地址。但你想继续执行,得回到异常指令本身重新尝试。所以需要减8(ARM模式下每条指令4字节,两条指令就是8字节)。

我个人习惯在入口函数里加一个调试钩子:

_excHandlerDataAbort:
    STMFD   sp!, {r0-r12, lr}
    
    // 调试钩子:记录异常发生时的PC值
    LDR     r0, =excDebugFlag
    LDR     r1, [r0]
    CMP     r1, #1
    BNE     skipDebug
    BL      excSaveDebugInfo
    
skipDebug:
    MRS     r0, cpsr
    AND     r0, r0, #0x1F
    BL      excDataAbortHandler
    LDMFD   sp!, {r0-r12, lr}
    SUBS    pc, lr, #8

这个钩子平时是关的,出问题时打开,能帮你定位到异常发生的精确位置。我曾经靠这个钩子,花了一下午就找到了一个内存越界bug——要是没有它,估计得查三天。

异常向量表的初始化

初始化异常向量表,说白了就是把表放到正确的位置,然后告诉CPU“表在这里”。

在VxWorks中,初始化通常在sysHwInit()函数里完成。我一般这样做:

STATUS sysExcVecInit(void)
{
    UINT32 *vecTable;
    int i;
    
    // 第一步:分配向量表空间
    // 注意:必须放在非缓存区域,否则异常发生时缓存还没初始化
    vecTable = (UINT32 *)EXC_VEC_BASE;
    
    // 第二步:填充向量表
    // 每个表项放一条跳转指令
    for (i = 0; i < 8; i++)
    {
        vecTable[i] = EXC_BRANCH_INSTR(i);
    }
    
    // 第三步:刷新指令缓存
    // 确保CPU看到的是最新的表内容
    cacheInvalidate(DATA_CACHE);
    cacheInvalidate(INSTRUCTION_CACHE);
    
    // 第四步:设置向量表基址寄存器
    // 不同架构寄存器不同,这里以ARM为例
    __asm__ volatile (
        "MCR p15, 0, %0, c12, c0, 0\n\t"
        :
        : "r" (vecTable)
    );
    
    return OK;
}
警告:向量表的位置不能随便选。有些CPU要求向量表必须在0x00000000,有些允许重定位。我建议你查一下芯片手册,别想当然。我曾经在某个项目里把向量表放在0x10000000,结果CPU根本不认——白白浪费了两天调试时间。

初始化完成后,最好做个自检:

STATUS sysExcVecVerify(void)
{
    UINT32 *vecTable = (UINT32 *)EXC_VEC_BASE;
    int i;
    
    // 验证每个表项是否有效
    for (i = 0; i < 8; i++)
    {
        // 检查是否是跳转指令
        if ((vecTable[i] & 0xFF000000) != 0xEA000000)
        {
            return ERROR;  // 表项损坏
        }
    }
    
    return OK;
}
小技巧:自检函数最好在系统启动早期调用。如果向量表坏了,越早发现越好。我习惯在sysHwInit()的最后调用这个自检,确保硬件初始化没破坏向量表。

最后说一句:异常向量表是系统的“命门”。你把它配好了,系统就稳如泰山;配不好,那就是定时炸弹。我个人建议,每次修改BSP后都重新检查一遍向量表配置,别偷懒。

下一章咱们聊聊具体异常类型的处理策略,比如数据中止怎么恢复、未定义指令怎么处理。到时候我会分享一些实战中的坑,保证让你少走弯路。