Mailbox 硬件原理:寄存器、收发机制与中断/轮询

搞 Mailbox 驱动,说白了就是跟硬件寄存器打交道。你想想看,两个处理器核要通信,总得有个“信箱”吧?一方往里投信,另一方取信,就这么简单。但真到了硬件层面,细节就多了。

我个人习惯,拿到一份 Mailbox IP 的手册,先不看时序图,先看寄存器列表。为什么?因为寄存器就是硬件的“API”,你调对了,它就干活;调错了,它就死给你看。

硬件寄存器描述

一个典型的 Mailbox 控制器,核心寄存器就那么几类。我列个表,你对照着手册看,基本八九不离十。

寄存器类别 典型名称 位宽 作用
状态寄存器 MBOX_STATUS / SR 32-bit 指示 mailbox 是否空闲、是否有新消息、是否溢出
控制寄存器 MBOX_CTRL / CR 32-bit 使能/禁能中断、复位、选择工作模式
数据寄存器 MBOX_DATA / DR 32-bit 存放实际要传递的消息内容,通常一次 4 字节
中断寄存器 MBOX_IMR / ISR 32-bit 中断屏蔽、中断状态、中断清除
发送寄存器 MBOX_TX / TXDR 32-bit 写数据到发送通道,触发硬件发送
接收寄存器 MBOX_RX / RXDR 32-bit 读取接收通道的数据

嗯,这里要注意:很多芯片会把发送和接收共用同一个数据寄存器,靠方向位来区分。我在项目中遇到过这种设计,结果驱动里没注意方向位,数据死活发不出去。排查了半天,最后发现是写数据前忘了设方向位。

关键点:Mailbox 的数据寄存器通常只有 32 位宽。一次只能传 4 字节。如果你要传一个大结构体,就得拆包,分多次发送。接收方再拼回去。

发送与接收机制

发送和接收,本质上就是“写寄存器”和“读寄存器”。但硬件为了保证数据一致性,加了一些握手信号。

发送流程

  1. 检查状态:读状态寄存器,确认 mailbox 空闲(没有正在发送的消息)。
  2. 写数据:把消息写入数据寄存器或发送寄存器。
  3. 触发发送:写控制寄存器,或者写一个特定的“发送触发”位。硬件检测到这个动作,就开始把数据推送到对端。
  4. 等待完成:轮询状态寄存器,或者等中断,确认发送完成。
  5. 清标志:清除发送完成标志位,准备下一次发送。

我曾经在调试一个 AMP(非对称多处理)系统时,发现发送端总是卡在“检查状态”这一步。后来用示波器抓了 mailbox 的硬件信号线,才发现对端处理器根本没把状态寄存器清掉。说白了,是对方驱动没写好。

接收流程

  1. 检测新消息:通过轮询状态寄存器,或者等待接收中断。
  2. 读数据:从数据寄存器或接收寄存器读取消息。
  3. 确认接收:写一个“接收确认”位,告诉硬件我已经读走了数据,你可以释放缓冲区了。
  4. 处理消息:根据消息内容,执行对应的动作。

避坑指南:我曾经在读取接收寄存器后,忘了写确认位。结果硬件认为数据还没被取走,一直不释放缓冲区。后续的消息全部被阻塞。这个 bug 藏得很深,因为系统在低负载时不会触发,只有高并发时才复现。

中断与轮询模式

这两种模式,说白了就是“被动等通知”和“主动去问”。各有各的适用场景。

轮询模式

轮询就是 CPU 不断去读状态寄存器,看有没有新消息。优点是实现简单,没有中断上下文切换的开销。缺点是浪费 CPU 周期。

我一般只在两种情况下用轮询:

  • 系统启动阶段:中断系统还没初始化好,只能用轮询。
  • 低负载场景:消息频率很低,比如几秒一次,轮询的开销可以忽略。
/* 轮询接收示例 */
int mailbox_poll_recv(struct mailbox *mbox, uint32_t *msg)
{
    unsigned long timeout = jiffies + msecs_to_jiffies(100);
    
    while (time_before(jiffies, timeout)) {
        if (readl(mbox->base + MBOX_STATUS) & MBOX_RX_READY) {
            *msg = readl(mbox->base + MBOX_RX_DATA);
            writel(MBOX_RX_ACK, mbox->base + MBOX_CTRL);
            return 0;
        }
        cpu_relax();  /* 让出 CPU,避免忙等 */
    }
    return -ETIMEDOUT;
}

中断模式

中断模式是“有消息来了,硬件通知你”。CPU 不用空转,效率高。但中断处理函数里不能做太多事,否则会影响系统实时性。

你想想看,如果中断处理函数里做了个耗时 10ms 的操作,那其他中断就得排队等着。系统响应就慢了。

/* 中断处理函数示例 */
irqreturn_t mailbox_isr(int irq, void *dev_id)
{
    struct mailbox *mbox = dev_id;
    uint32_t status;
    
    status = readl(mbox->base + MBOX_STATUS);
    
    if (status & MBOX_RX_PENDING) {
        /* 有接收中断,读取消息 */
        uint32_t msg = readl(mbox->base + MBOX_RX_DATA);
        writel(MBOX_RX_ACK, mbox->base + MBOX_CTRL);
        
        /* 把消息放入工作队列,不要在中断上下文处理 */
        schedule_work(&mbox->work);
    }
    
    if (status & MBOX_TX_DONE) {
        /* 发送完成中断,唤醒等待的进程 */
        writel(MBOX_TX_ACK, mbox->base + MBOX_CTRL);
        complete(&mbox->tx_done);
    }
    
    return IRQ_HANDLED;
}

注意:中断处理函数里不要做复杂操作。我见过有人直接在 ISR 里调用 kmalloc,结果系统直接 panic。正确的做法是:ISR 里只做最必要的操作(读数据、清中断),然后把耗时操作交给工作队列或 tasklet。

中断 vs 轮询:怎么选?

我个人的经验法则:

  • 消息频率高(每秒几百次以上):用中断。轮询会吃掉大量 CPU。
  • 消息频率低(每秒几次):轮询也可以,但中断更优雅。
  • 对延迟敏感:用中断。轮询的延迟取决于轮询间隔。
  • 系统资源紧张:如果中断线不够用,或者中断控制器有瓶颈,可以考虑轮询。

嗯,其实很多 SoC 的 Mailbox 硬件同时支持两种模式。你可以在驱动初始化时,通过控制寄存器选择。我建议默认用中断,然后提供一个 sysfs 接口,允许用户动态切换到轮询模式。这样调试和生产都方便。

Mailbox 收发与中断/轮询流程 发送端 CPU 写数据到 TX 寄存器 触发发送 Mailbox 硬件 状态寄存器 数据寄存器 控制寄存器 中断寄存器 发送/接收通道 接收端 CPU 轮询状态寄存器 或等待中断 读取 RX 数据 说明: 发送端:写数据 → 触发发送 → 等待完成(中断或轮询) 接收端:轮询状态寄存器 或 等待接收中断 → 读取数据 → 确认接收 中断模式适合高频率、低延迟场景;轮询模式适合低频率或启动阶段。

这张图把整个流程串起来了。左边是发送端,右边是接收端,中间是 Mailbox 硬件。你写驱动时,脑子里要有这张图,知道每一步操作对应哪个寄存器。

个人经验:调试 Mailbox 驱动时,我习惯先在轮询模式下把收发调通,再切换到中断模式。为什么?因为轮询模式简单,没有中断嵌套、竞争条件这些坑。等轮询模式跑通了,再开中断,这样出问题时容易定位。