Binder 复习手册:从 AIDL 到内核
我在 AIDL 里写了一行 void setBar(in Bar b)。这一行落到内核,变成了几次拷贝、几个结构体、几次唤醒?
这篇是我自己的复习底稿。按「怎么写 → 生成了什么 → 内核搬了什么 → 哪里会翻车」走一遍,最后留三张表和一句口诀。
先看全景
flowchart TD
A["IFoo.aidl"] -->|aidl 编译| B["IFoo.Proxy / IFoo.Stub"]
B -->|往 Parcel 里写| C["BinderProxy.transact()"]
C -->|ioctl BINDER_WRITE_READ| D["binder 驱动"]
D -->|copy_from_user,一次| E["目标进程的 mmap 区"]
E -->|BR_TRANSACTION| F["目标线程 onTransact()"]
F --> G["你写的 setBar"]
上半截是代码生成,下半截是内存搬运。分界线是 transact():往上是你写的,往下是内核。
一、AIDL 怎么写
一个能跑的最小文件:
// IFoo.aidl
package com.ryanhuen.demo;
import com.ryanhuen.demo.Bar; // Parcelable 必须 import,同包也要
import com.ryanhuen.demo.IBaz;
interface IFoo {
int add(int a, int b);
void setBar(in Bar bar);
void getBar(out Bar bar);
void update(inout Bar bar);
oneway void fireAndForget();
}
上述代码的意思是:package 决定 DESCRIPTOR 字符串,import 决定 Parcel 能不能找到类。
第二行那个 import 是新手第一坑。Bar 就在同一个包里,照样要写。
能传什么
| 类型 | 能传 | 备注 |
|---|---|---|
| 八种原始类型 | 是 | byte/short/int/long/float/double/boolean/char,含数组 |
| String、CharSequence | 是 | 值传递,标方向没意义 |
| Parcelable | 是 | 要 import,要有 CREATOR |
List<T> |
是 | T 得是支持的类型,运行时必须是 ArrayList |
Map |
是 | 只支持 Map<String, T>,运行时必须是 HashMap |
| 另一个 AIDL 接口 | 是 | 传的是它的 IBinder |
| 枚举 | 是 | 底层就是 int |
Set、自定义泛型 |
否 | 没有对应的 Parcel 读写方法 |
List 和 Map 那两行是运行时才炸的坑。编译期只看声明,传 LinkedList 一样过编译,跑起来直接崩。
in / out / inout 管什么
这是最容易记混的三个词。
| 标签 | 方向 | 代价 |
|---|---|---|
in |
调用方 → 服务端 | 一次打包,默认值 |
out |
服务端 → 调用方 | 服务端拿到空对象,要自己填 |
inout |
双向 | 两次打包,最贵 |
规则有两条:
- 只对非原始类型有意义。
int、String传的是值,标了也白标。 - 数组、
List、Map、Parcelable 才需要认真选方向。
那么问题来了,为什么 out 参数在真机上好好的,单元测试里却一直是 null?
因为同进程调用根本不进 Parcel。asInterface() 发现本地有实现,直接把对象返回来。out 那套「服务端填、客户端读」的机制压根没跑。
oneway 的两条硬限制
oneway interface IFoo { // 整个接口异步
void ping();
}
也可以只标一个方法。限制是死的:
- 必须返回 void,不能有返回值
- 不能有
out/inout参数 - 调用方立刻返回,不保证送达,服务端的异常也传不回来
同一个 node 的 oneway 请求按顺序处理,内核用 async_todo 队列排队。
版本化那套注解
system 和 HAL 用的 stable AIDL 才需要:
| 注解 | 作用 |
|---|---|
@VintfStability |
进 VINTF 矩阵,system 与 vendor 之间的稳定接口 |
@JavaDerive(toString=true, equals=true) |
生成 toString / equals |
@nullable |
允许传 null |
@utf8InCpp |
C++ 侧按 UTF-8 处理,不是 UTF-16 |
@Backing(type="int") |
枚举的底层类型 |
@SuppressWarnings |
关掉指定告警 |
核心约束只有一条:只能加,不能删。版本冻结之后老方法 id 固定,删掉就等于让老客户端调空。
二、编译器替你写了什么
IFoo.aidl 编译出一个 IFoo.java,结构固定三层:
public interface IFoo extends IInterface {
abstract class Stub extends Binder implements IFoo { ... }
static class Proxy implements IFoo { ... }
}
Proxy 是发送方,Stub 是接收方。
Proxy:打包
@Override
public int add(int a, int b) throws RemoteException {
Parcel data = Parcel.obtain();
Parcel reply = Parcel.obtain();
try {
data.writeInterfaceToken(DESCRIPTOR);
data.writeInt(a);
data.writeInt(b);
mRemote.transact(TRANSACTION_add, data, reply, 0); // 分界线
reply.readException();
return reply.readInt();
} finally {
data.recycle();
reply.recycle();
}
}
上述代码的意思是:把这几个参数按顺序写进 Parcel,交给 transact,回来再按顺序读。
writeInterfaceToken 是防调错接口的。服务端 enforceInterface 会拿 DESCRIPTOR 比对,对不上直接抛。
Stub:拆包
@Override
public boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
switch (code) {
case TRANSACTION_add: {
data.enforceInterface(DESCRIPTOR);
int _arg0 = data.readInt();
int _arg1 = data.readInt();
int _result = this.add(_arg0, _arg1); // 回到你的代码
reply.writeNoException();
reply.writeInt(_result);
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
上述代码的意思是:按 code 找方法,按顺序读参数,调你写的实现,把返回值写回 reply。
这里有个容易忽略的事:onTransact 跑在 binder 线程池的线程里,不是主线程。服务端方法里碰 UI,得自己切回去。
那么问题来了,transact 之后怎么就「进内核」了?
因为 mRemote 是个 BinderProxy,它的 transact 是 native 方法,最后落到一句 ioctl(fd, BINDER_WRITE_READ, ...)。
handle 与 ServiceManager
ServiceManager.getService("foo") 拿回来的 IBinder,背后是一个 handle 数字。
- handle 是进程内编号,换个进程就对不上
- handle 0 是 ServiceManager 自己
Binder(服务端)持有binder_node,BinderProxy(客户端)持有 handle
三、内核搬了什么
sequenceDiagram
participant C as 客户端线程
participant K as binder 驱动
participant S as 服务端 binder 线程
C->>K: ioctl / BC_TRANSACTION
K->>K: copy_from_user,写进服务端 mmap 区
K->>C: BR_TRANSACTION_COMPLETE
K->>S: 唤醒,返回 BR_TRANSACTION
S->>S: onTransact() 拆 Parcel
S->>K: BC_REPLY
K->>C: 唤醒,返回 BR_REPLY
整条链上只有一次 copy_from_user。剩下全靠 mmap,两个进程看的是同一组物理页。
为什么只有一次
flowchart LR
A["发送进程 用户空间"] -->|copy_from_user| B["binder_buffer 内核映射"]
B -.->|同一组物理页| C["接收进程 mmap 区"]
接收进程在 ProcessState 初始化时就 mmap 了一块内存,这块同时映射进内核。
所以内核往自己的映射里写,接收进程在用户态伸手就能拿到,不用再拷一次。
打个比方,两边共用一个抽屉。内核往里放,接收进程直接开抽屉拿。
ioctl 之后的两步
binder_thread_write:解析 BC_TRANSACTION,按 handle 查 binder_ref,找到目标的 binder_node,建一个 binder_transaction,挂到目标线程的 todo 队列。
binder_thread_read:todo 空就睡(wait_event_interruptible),有活就包装成 BR_TRANSACTION 返回用户态。
同步和 oneway 的区别就卡在这:
| 同步 | oneway | |
|---|---|---|
| 发送方 | 阻塞等 reply | 拿到 BR_TRANSACTION_COMPLETE 就走 |
| 能用的缓冲 | 全部 | 一半 |
| 失败 | 抛 RemoteException | 静默丢弃 |
FD 翻译
文件描述符是进程私有的,不能直接传数字。内核得翻译:
- 按发送方的 fd 找到
struct file - 在接收方 fd 表里找个空位
- 拷贝时把数据包里的旧 fd 改成新 fd
所以 ParcelFileDescriptor 能传,裸 int fd 传过去就是个废数字。
数据结构
| 结构体 | 管什么 |
|---|---|
binder_proc |
一个进程一份,持有 mmap 缓冲区和线程池 |
binder_thread |
一个线程一份,各有 todo 队列 |
binder_node |
服务端实体的内核表示 |
binder_ref |
客户端对 node 的引用,就是 handle |
binder_transaction |
一次事务 |
binder_buffer |
mmap 区里切出来的数据块 |
binder_ref_death |
死亡通知的登记表 |
四、五个必踩的坑
1. 1MB 不是 1MB
每个进程的 binder 缓冲是 1MB - 8KB。超了抛 TransactionTooLargeException。
大文件别硬塞,走 SharedMemory 加 FD 传递。
2. 同步调同步 = 死锁
A 的线程调 B,B 的线程回调 A。A 的 binder 线程池被占满,回调没人接,两边一起卡死。
线程池默认上限 15 个,加上主线程一共 16 个。所以别在 binder 线程里做长任务。
3. out 在同进程不生效
asInterface() 发现是本地实现,直接返回对象,不进 Parcel。out 参数原样给你。
写单测最容易撞上这个。我当初查了半天,最后才发现是压根没跨进程。
4. oneway 静默失败
缓冲区满、目标进程已经死了,调用方都无感。要确认送达就别用 oneway。
5. linkToDeath 不帮你重连
它只负责告诉你「死了」,不负责重新 bind。重连逻辑得自己在 binderDied 里写。
五、三张表和一句口诀
表一:一次调用的七步
| 步 | 谁 | 干什么 |
|---|---|---|
| 1 | Proxy | 参数写进 Parcel |
| 2 | BinderProxy | transact() |
| 3 | libbinder | ioctl(BINDER_WRITE_READ) |
| 4 | 内核 | binder_thread_write,建 transaction |
| 5 | 内核 | 唤醒目标线程,BR_TRANSACTION |
| 6 | Stub | onTransact() 拆包,调实现 |
| 7 | 内核 | BC_REPLY 回传 |
表二:三个易混点
| 写什么 | 意思 | 坑 |
|---|---|---|
in |
单向进 | 默认,最便宜 |
out |
单向出 | 服务端拿到空对象;同进程不生效 |
inout |
双向 | 两次打包,能不用就不用 |
表三:几个数
| 项 | 值 |
|---|---|
| 每进程 binder 缓冲 | 1MB - 8KB |
| 异步可用 | 一半,约 508KB |
| binder 线程池 | 默认 15,加主线程 16 |
| 超限异常 | TransactionTooLargeException |
口诀:
写 AIDL 定协议,Proxy 打包,Stub 拆包,内核只搬一次。
复习时只盯三件事:in/out 方向、1MB 边界、线程池别堵。
评论