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 字节。如果你要传一个大结构体,就得拆包,分多次发送。接收方再拼回去。
发送与接收机制
发送和接收,本质上就是“写寄存器”和“读寄存器”。但硬件为了保证数据一致性,加了一些握手信号。
发送流程
- 检查状态:读状态寄存器,确认 mailbox 空闲(没有正在发送的消息)。
- 写数据:把消息写入数据寄存器或发送寄存器。
- 触发发送:写控制寄存器,或者写一个特定的“发送触发”位。硬件检测到这个动作,就开始把数据推送到对端。
- 等待完成:轮询状态寄存器,或者等中断,确认发送完成。
- 清标志:清除发送完成标志位,准备下一次发送。
我曾经在调试一个 AMP(非对称多处理)系统时,发现发送端总是卡在“检查状态”这一步。后来用示波器抓了 mailbox 的硬件信号线,才发现对端处理器根本没把状态寄存器清掉。说白了,是对方驱动没写好。
接收流程
- 检测新消息:通过轮询状态寄存器,或者等待接收中断。
- 读数据:从数据寄存器或接收寄存器读取消息。
- 确认接收:写一个“接收确认”位,告诉硬件我已经读走了数据,你可以释放缓冲区了。
- 处理消息:根据消息内容,执行对应的动作。
避坑指南:我曾经在读取接收寄存器后,忘了写确认位。结果硬件认为数据还没被取走,一直不释放缓冲区。后续的消息全部被阻塞。这个 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 硬件。你写驱动时,脑子里要有这张图,知道每一步操作对应哪个寄存器。
个人经验:调试 Mailbox 驱动时,我习惯先在轮询模式下把收发调通,再切换到中断模式。为什么?因为轮询模式简单,没有中断嵌套、竞争条件这些坑。等轮询模式跑通了,再开中断,这样出问题时容易定位。