本文源码基于 Android 10(API 29)。

背景

Android 里几乎所有“跨进程”的事情都离不开 Binder:启动 Activity、绑定 Service、读写 ContentProvider、获取系统服务……我们每天调用的 getSystemService(),背后都是一次 Binder 通信。

先看几个面试里常被问到的问题:

  • Linux 已经有管道、Socket、共享内存,Android 为什么还要自己造一个 Binder?
  • 都说 Binder 只拷贝一次,这一次拷贝是怎么做到的?
  • AIDL 生成的那一大段代码,到底做了什么?
  • 为什么用 Intent 传一张大图会崩溃?

这篇文章从整体结构讲起,再顺着一次 AIDL 调用把 Java 层、Native 层和驱动层串起来。

整体结构

Binder 通信有四个角色:

角色所在位置职责
Client用户空间,调用方进程通过代理对象发起调用
Server用户空间,服务方进程在 Binder 线程池里执行真正的方法
ServiceManager用户空间,独立进程服务的“通讯录”,按名字注册和查找服务
Binder 驱动内核空间在进程之间传递数据、管理引用和线程

Binder 通信结构

Client 和 Server 不能直接访问对方的内存,所有数据都要经过内核里的 Binder 驱动中转。ServiceManager 本身也是一个 Binder 服务,它的句柄固定为 0,所以任何进程都能直接找到它。

原理

1. 为什么是 Binder

Linux 自带的 IPC 方式各有短板,放到手机系统上并不合适:

方式数据拷贝次数能否可靠识别调用方使用方式
共享内存0否需要自己处理同步,容易出错
Binder1是,驱动填入 UID / PIDC/S 架构,像调用本地方法
Socket2否,靠自己约定C/S 架构,但开销大,适合网络
管道、消息队列2否适合简单数据流

Binder 在性能和易用性之间取得了平衡,更关键的是安全:调用方的 UID 和 PID 由内核驱动写入,应用无法伪造。服务端调用 Binder.getCallingUid() 就能拿到真实身份,系统的权限检查正是建立在这一点上。

2. 一次拷贝是怎么做到的

传统 IPC 需要两次拷贝:发送方把数据从用户空间拷到内核(copy_from_user),接收方再从内核拷回自己的用户空间(copy_to_user)。

Binder 省掉了第二次。关键是 mmap:每个使用 Binder 的进程启动时,都会在 ProcessState 里打开 /dev/binder 并映射一块缓冲区:

#define BINDER_VM_SIZE ((1 * 1024 * 1024) - sysconf(_SC_PAGE_SIZE) * 2)

ProcessState::ProcessState(const char *driver)
    : mDriverFD(open_driver(driver))
    // ...省略
{
    if (mDriverFD >= 0) {
        // 把内核中的一块缓冲区映射到本进程的用户空间
        mVMStart = mmap(nullptr, BINDER_VM_SIZE, PROT_READ,
                        MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);
        // ...省略
    }
}

binder_mmap 让这块缓冲区的物理内存同时映射到内核空间和接收方的用户空间。发送数据时,驱动在接收方的缓冲区里分配空间,然后只做一次 copy_from_user,把发送方的数据直接拷进去。因为这块内存接收方本来就能访问,自然不需要第二次拷贝。

传统 IPC 与 Binder 的拷贝次数对比

对照上图:左边的传统 IPC 中,内核缓冲区和接收方的内存是两块不同的物理内存,数据要再拷贝一次才能到达接收方;右边的 Binder 中,内核缓冲区和接收方的映射区是同一组物理页的两个“入口”,数据写进内核缓冲区的那一刻,接收方就已经能读到了。

这里也能看到一个重要的数字:缓冲区大小是 1MB 减去两个页,大约 1016KB。后面踩坑部分会用到。

3. AIDL:Stub 和 Proxy

以一个简单的接口为例:

interface IBookManager {
    List<String> getBooks();
    void addBook(String name);
}

编译后会生成 IBookManager.java,里面有两个关键的内部类:Stub 和 Proxy。客户端和服务端都会把这个文件编译进去,但运行时各用各的:

类运行在哪个进程角色
Stub服务端服务端继承它并实现 getBooks(),它是真正的 Binder 实体
Proxy客户端客户端通过 asInterface() 拿到它,它是服务端 Stub 在客户端的代理

两者代表的都是服务端提供的同一个接口:Proxy 在客户端把调用“打包”发出去,Stub 在服务端“拆包”并执行。

Stub:运行在服务端进程(已简化)

public static abstract class Stub extends Binder implements IBookManager {
    private static final String DESCRIPTOR = "com.example.binderdemo.IBookManager";
    static final int TRANSACTION_getBooks = IBinder.FIRST_CALL_TRANSACTION + 0;

    public static IBookManager asInterface(IBinder obj) {
        if (obj == null) return null;
        IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
        if (iin instanceof IBookManager) {
            return (IBookManager) iin;   // 同一进程:直接返回 Stub 本身
        }
        return new Stub.Proxy(obj);      // 跨进程:返回代理对象
    }

    @Override
    public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
            throws RemoteException {
        if (code == TRANSACTION_getBooks) {
            // 服务端:在 Binder 线程中执行
            data.enforceInterface(DESCRIPTOR);
            List<String> result = this.getBooks(); // 调用服务端自己实现的 getBooks()
            reply.writeNoException();
            reply.writeStringList(result);
            return true;
        }
        return super.onTransact(code, data, reply, flags);
    }
}

Proxy:运行在客户端进程

private static class Proxy implements IBookManager {
    private IBinder mRemote;

    Proxy(IBinder remote) {
        mRemote = remote;
    }

    @Override
    public List<String> getBooks() throws RemoteException {
        // 客户端:在调用方线程中执行(比如主线程)
        Parcel _data = Parcel.obtain();
        Parcel _reply = Parcel.obtain();
        try {
            _data.writeInterfaceToken(DESCRIPTOR);
            // 发起跨进程调用,当前线程会阻塞到服务端返回
            mRemote.transact(Stub.TRANSACTION_getBooks, _data, _reply, 0);
            _reply.readException();
            return _reply.createStringArrayList();
        } finally {
            _reply.recycle();
            _data.recycle();
        }
    }
}

客户端调用一次 bookManager.getBooks(),代码会在两个进程之间这样走:

顺序进程线程执行的代码
1客户端调用方线程bookManager.getBooks(),实际执行的是 Proxy.getBooks()
2客户端调用方线程把方法编号和参数写入 _data,调用 mRemote.transact() 后阻塞等待
3服务端Binder 线程Stub.onTransact() 收到请求,按方法编号找到 getBooks
4服务端Binder 线程执行服务端实现的 getBooks(),把结果写入 reply
5客户端调用方线程被唤醒,从 _reply 读出结果并返回

也就是说,Proxy.getBooks() 是客户端在调用,Stub.onTransact() 是服务端在执行,中间隔着一次跨进程通信。AIDL 只是帮我们自动生成了这段打包和拆包的样板代码,手写也完全可以。

注意 asInterface 的判断:Client 和 Server 在同一进程时,拿到的就是 Stub 对象本身,根本不会走 Binder 驱动。

4. ServiceManager:Proxy 手里的 IBinder 从哪来

构造 Proxy 时要传入服务端的 IBinder。可客户端一开始和服务端毫无联系,这个 IBinder 是怎么拿到的?

一次 AIDL 绑定中 ServiceManager 的位置

整个过程分两段:

先找到 AMS(①②)。 要和别的进程通信,得先有对方的句柄,可句柄本身又要靠 Binder 传递。Android 的解法是约定 0 号句柄永远指向 ServiceManager:AMS 等系统服务开机时按名字登记在它这里,任何进程都能通过 0 号句柄查到 AMS。

再由 AMS 转交 Stub(③~⑦)。 客户端调用 bindService(),AMS 回调服务端的 onBind() 拿到 Stub,再通过 onServiceConnected() 交给客户端。经过驱动转换,客户端拿到的已经是 Stub 的代理,用 asInterface() 包装成 Proxy 后就能直接调用了。

一句话:ServiceManager 是 Binder 的入口,负责让大家找到 AMS 这类系统服务。我们自己的 AIDL 服务不在它那里登记,拿到 Proxy 之后的调用它也不参与。

5. 一次调用的完整链路

mRemote.transact() 之后发生了什么?以 Android 10 为例,从 Java 层一路到驱动,再回到服务端:

步骤所在位置关键调用
1Client · JavaBinderProxy.transact() → transactNative()
2Client · NativeBpBinder::transact() → IPCThreadState::transact()
3Client · NativetalkWithDriver() → ioctl(fd, BINDER_WRITE_READ, ...)
4内核 · 驱动binder_transaction():在 Server 缓冲区分配空间,copy_from_user 拷贝数据,唤醒 Server 的 Binder 线程
5Server · NativeBinder 线程从 ioctl 返回,executeCommand(BR_TRANSACTION)
6Server · NativeBBinder::transact() → JavaBBinder::onTransact()
7Server · JavaBinder.execTransact() → Stub.onTransact() → getBooks()
8返回结果写入 reply,按原路经驱动送回 Client,Client 线程被唤醒

整个过程中,Client 的调用线程在第 3 步之后一直阻塞,直到第 8 步拿到结果,这就是普通 Binder 调用是同步的原因。如果方法声明为 oneway,驱动把数据交给 Server 后就立即返回,Client 不等待结果。

6. Binder 线程池

第 5 步里 Server 用来执行方法的并不是主线程,而是 Binder 线程池中的线程。每个进程的 Binder 线程默认最多 15 个,再加上主动加入的主 Binder 线程,一共 16 个:

#define DEFAULT_MAX_BINDER_THREADS 15

这带来两个直接的结论:

  • 服务端的方法可能被多个线程同时调用,必须考虑线程安全
  • 如果服务端方法里做了耗时操作,又被大量并发调用,Binder 线程可能被占满,后续请求只能排队

示例:用 AIDL 实现一个跨进程服务

服务端运行在独立进程,在 Manifest 里声明:

<service
    android:name=".BookService"
    android:process=":remote" />

服务端实现 Stub:

class BookService : Service() {

    // 方法运行在 Binder 线程池,使用线程安全的集合
    private val books = CopyOnWriteArrayList<String>()

    private val binder = object : IBookManager.Stub() {
        override fun getBooks(): List<String> = books

        override fun addBook(name: String) {
            books.add(name)
        }
    }

    override fun onBind(intent: Intent): IBinder = binder
}

客户端绑定服务,并监听服务端进程死亡:

class MainActivity : AppCompatActivity() {

    private var bookManager: IBookManager? = null

    private val deathRecipient = object : IBinder.DeathRecipient {
        override fun binderDied() {
            // 运行在 Binder 线程,服务端进程已经不在了
            bookManager?.asBinder()?.unlinkToDeath(this, 0)
            bookManager = null
            bindBookService() // 重新绑定
        }
    }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, service: IBinder) {
            bookManager = IBookManager.Stub.asInterface(service)
            service.linkToDeath(deathRecipient, 0)
        }

        override fun onServiceDisconnected(name: ComponentName) {
            bookManager = null
        }
    }

    private fun bindBookService() {
        val intent = Intent(this, BookService::class.java)
        bindService(intent, connection, Context.BIND_AUTO_CREATE)
    }
}

asInterface 在这里返回的是 Proxy,因为 BookService 跑在 :remote 进程。把 Manifest 里的 android:process 去掉再运行,就会拿到 Stub 本身。

踩坑记录

1. TransactionTooLargeException

前面提到,每个进程的 Binder 缓冲区只有约 1MB,而且是这个进程里所有正在进行的 Binder 调用共用的。oneway 异步调用能用的空间还要再减半。

最常见的触发场景:

  • 用 Intent 的 extras 传一张大 Bitmap 或一个很大的列表
  • onSaveInstanceState 里保存了大量数据。从 Android 7.0 开始,targetSdk 不低于 24 时,这种情况会直接抛出异常,而不只是打一条日志

解决思路是不要通过 Binder 传大数据:

场景推荐做法
页面之间传图片传文件路径或 Uri,由接收方自己加载
页面之间传大列表传 ID,接收方从数据库或内存缓存中读取
跨进程传大块数据使用 SharedMemory(API 27+)或 ParcelFileDescriptor
保存页面状态onSaveInstanceState 只存关键 ID,数据放到 ViewModel 里

2. 在主线程调用远程方法导致 ANR

普通的 Binder 调用是同步的。如果服务端方法执行要 3 秒,客户端在主线程调用它,主线程也会跟着阻塞 3 秒。更麻烦的是,服务端慢可能是因为它的 Binder 线程被占满了,这种问题在客户端很难排查。

所以对待 AIDL 方法要像对待网络请求一样:放到子线程调用,或者在不需要返回值时声明为 oneway。

3. 服务端进程被杀后的 DeadObjectException

服务端进程崩溃或被系统回收后,客户端再调用 Proxy 的方法,会抛出 DeadObjectException。示例里的 linkToDeath 就是为此准备的:进程死亡时驱动会通知客户端,我们在 binderDied() 里清理引用并重新绑定。

需要注意 binderDied() 运行在 Binder 线程,不是主线程,不能直接更新 UI。

总结

问题答案
为什么用 Binder?一次拷贝,性能好;驱动写入调用方 UID / PID,安全;C/S 架构,易用
一次拷贝怎么实现?接收方 mmap 了内核缓冲区,驱动只需 copy_from_user 一次
ServiceManager 在 AIDL 中做什么?0 号句柄,Binder 的入口:客户端通过它找到 AMS,再由 AMS 转交服务端的 Stub
AIDL 做了什么?生成两个类:Proxy 在客户端打包参数发起调用,Stub 在服务端拆包并执行;同进程时 asInterface 直接返回 Stub
服务端方法在哪个线程执行?Binder 线程池,默认最多 15 个加 1 个主 Binder 线程
为什么传大图会崩?进程共用约 1MB 的 Binder 缓冲区,超出就抛 TransactionTooLargeException

Binder 是理解 Android Framework 的另一把钥匙。有了这篇的基础,再去看 startActivity 如何通过 Binder 调到 AMS,以及 AMS 又如何回调到应用进程,整条链路就清楚多了。