
1. 为什么Binder值得单独拎出来讲做Android系统开发或者应用层性能优化的朋友迟早会撞上Binder这个词。它不像Activity、Service那样天天在代码里出现但只要你碰过跨进程通信、系统服务调用、AIDL、甚至只是看一份ANR日志Binder就绕不开。我见过不少工作两三年的开发者AIDL写得挺溜但一问“一次跨进程调用到底拷贝了几次数据”“Binder线程池满了会怎样”就答不上来了。这就是典型的会用不会懂。这一章我打算把Binder IPC机制从头到尾拆一遍。不是照本宣科地翻译源码注释而是按照一个实际做系统层开发的人的理解路径来组织先搞清楚它到底解决什么问题再弄明白它的数据结构长什么样然后走一遍完整的通信流程最后聊那些文档里不写、但踩过一次就忘不掉的坑。内容会涉及内核态的驱动逻辑、用户态的ServiceManager、以及应用层怎么通过AIDL把这一切串起来。适合谁看如果你正在做系统服务开发、插件化框架、多进程架构设计或者单纯想搞明白“为什么Android不用管道和Socket做主要IPC手段”那这篇内容应该能帮你把零散的知识点串成一条线。我会尽量用生活化的类比来解释内核里的那些结构体但该有的技术细节一个都不会省。2. Binder到底解决了什么问题2.1 传统IPC手段在移动端的尴尬Linux本身提供了不少IPC机制管道、消息队列、共享内存、信号量、Socket。这些在服务器端用得好好的搬到手机上就有点水土不服。管道和消息队列的问题在于数据要经过两次拷贝——发送方用户态到内核态内核态再到接收方用户态。共享内存虽然拷贝次数少但控制逻辑复杂同步问题容易出bug。Socket倒是通用但作为本地IPC使用时它的协议栈开销对于频繁的、小数据量的调用来说太重了。Android系统里跨进程调用有多频繁你点一下屏幕从Input子系统到WindowManager再到应用进程中间要经过好几次跨进程通信。如果每次都用Socket走一遍TCP/IP协议栈那电量早就崩了。所以Android需要一个专门为移动场景优化的IPC机制拷贝次数少、安全性好、支持面向对象的调用语义。Binder就是在这个背景下被设计出来的。2.2 一次拷贝是怎么做到的Binder最常被拿出来说的就是“一次拷贝”。传统IPC是两次拷贝Binder通过内存映射把接收方的用户空间缓冲区直接映射到内核空间发送方的数据从用户态拷贝到内核态时实际上就写到了接收方也能访问的那块物理内存上。这样接收方读数据时不需要再从内核态拷贝一次。这个机制的核心在于mmap。Binder驱动在初始化时会为每个进程分配一块内核缓冲区同时把这块缓冲区映射到进程的用户空间。当进程A要向进程B发送数据时驱动把数据从A的用户空间拷贝到B的内核缓冲区——因为B的内核缓冲区和B的用户空间是同一块物理内存所以B直接就能读到。整个过程只有一次CPU参与的拷贝操作。注意这里说的“一次拷贝”是指数据本身的拷贝次数。Binder协议中还有控制信息、对象引用的传递这些不涉及大数据量的搬运所以通常不纳入拷贝次数的讨论。2.3 安全性的设计考量Binder在安全性上做了两层保障。第一层是内核层的UID/PID校验。每次跨进程调用Binder驱动都会把发送方的PID和UID附加在数据上接收方可以随时通过Binder.getCallingPid()和Binder.getCallingUid()拿到这些信息。这意味着接收方不需要信任发送方自己报上来的身份内核已经帮你验证过了。第二层是Binder的引用计数机制。一个Binder对象被多少个进程引用驱动里都有记录。当引用计数归零时对象才会被销毁。这避免了悬空指针的问题也防止了某个进程意外退出导致其他进程访问非法内存。相比之下共享内存方案里如果一方崩溃另一方可能读到脏数据Binder在这方面的健壮性要好得多。3. Binder核心数据结构拆解3.1 binder_proc进程的身份证每个使用Binder的进程在驱动里都有一个binder_proc结构体。你可以把它理解成这个进程在Binder世界里的“档案”。里面记录了进程的PID、线程列表、Binder节点树、内存映射信息等。当进程打开/dev/binder设备时驱动就会为它创建一个binder_proc。这个结构体里有一个rb_root类型的nodes成员是一棵红黑树存储了该进程所有的Binder实体。还有threads红黑树记录了该进程所有的Binder线程。为什么用红黑树因为Binder对象的查找和插入非常频繁红黑树的O(log n)复杂度比链表的O(n)要高效得多。在一个系统服务进程里Binder实体可能有几十上百个这个性能差异就很明显了。3.2 binder_nodeBinder实体的内核代表binder_node代表一个Binder实体也就是服务端提供的那个“东西”。比如你写了一个AIDL接口服务端实现类的一个实例在驱动里就对应一个binder_node。这个结构体里保存了实体所属的进程、实体的用户态地址、引用计数等信息。关键的一点是binder_node里有一个ptr字段指向用户态的实际对象。当客户端发起调用时驱动通过这个指针找到服务端的处理函数。但驱动本身并不直接调用用户态代码它只是把请求挂到服务端线程的待处理队列上由服务端线程自己从队列里取出来执行。这个设计保证了内核态和用户态的边界清晰。3.3 binder_ref引用的内核表示客户端要调用服务端的Binder实体不能直接访问binder_node而是通过一个binder_ref。binder_ref是“引用”在内核里的代表它指向一个binder_node同时记录了引用它的进程。每个进程有自己的引用树所以同一个Binder实体在不同进程里的引用编号可以不同。这种设计的好处是隔离性。客户端A和客户端B都引用同一个服务端实体但A的引用编号是1B的引用编号可能是5。服务端不需要关心谁在引用它驱动会负责把引用编号翻译成实际的binder_node。当客户端传递一个Binder对象给另一个进程时驱动会把binder_ref转换成binder_node再在目标进程里创建一个新的binder_ref。这个过程叫做“引用传递”是Binder支持对象跨进程传递的基础。3.4 binder_transaction一次调用的载体每次跨进程调用驱动里都会创建一个binder_transaction结构体。它记录了这次调用的所有信息发送进程、目标进程、目标线程、数据缓冲区、代码也就是AIDL方法对应的整数标识、标志位等。这个结构体是Binder通信的核心载体从客户端发起调用到服务端处理完毕整个生命周期都围绕着它。binder_transaction里有一个buffer字段指向一块内核缓冲区里面存放了从发送方拷贝过来的数据。前面说的“一次拷贝”拷贝的目的地就是这块缓冲区。服务端线程处理完请求后会通过BC_FREE_BUFFER命令通知驱动释放这块内存。如果服务端处理时间过长客户端会进入等待状态直到收到回复或者超时。4. 一次完整的跨进程调用是怎么走的4.1 客户端发起调用假设你在应用进程里调用了一个远程服务的方法。AIDL生成的代理类会把方法参数打包成Parcel然后调用transact()。这个方法最终会通过ioctl系统调用进入Binder驱动命令是BINDER_WRITE_READ。在ioctl的参数里包含了binder_transaction_data结构体里面有目标Binder的引用编号、要执行的方法代码、以及打包好的数据。驱动收到请求后首先根据引用编号找到对应的binder_ref再通过binder_ref找到binder_node从而确定目标进程。然后驱动会检查目标进程是否有可用的Binder线程。如果有就把binder_transaction挂到那个线程的待处理队列上并唤醒它。如果没有空闲线程请求会排队等待直到有线程空闲出来。4.2 驱动层的数据搬运数据搬运是Binder最核心的操作之一。驱动会从发送方的用户空间把Parcel数据拷贝到目标进程的内核缓冲区。这个缓冲区是通过mmap映射的所以目标进程在用户态可以直接访问。拷贝过程中驱动还会处理Parcel里的Binder对象如果数据里包含一个Binder引用驱动会把它转换成目标进程里的引用编号如果包含文件描述符驱动会在目标进程里创建一个新的fd指向同一个文件。这个转换过程叫做“翻译”。翻译是递归的因为Parcel里可能嵌套了多个Binder对象。驱动会遍历整个数据缓冲区对每个Binder对象和fd进行处理。处理完毕后目标进程收到的数据里Binder对象已经变成了它自己能理解的引用编号fd也是有效的本地fd。4.3 服务端线程的处理服务端进程通常有一个Binder线程池由ProcessState管理。这些线程在talkWithDriver()里阻塞等待一旦驱动有请求进来它们就被唤醒。线程从驱动读取binder_transaction_data然后根据里面的code找到对应的onTransact()方法。这个方法在服务端的Binder类里实现负责解析参数、执行实际逻辑、然后把结果写回Parcel。执行完毕后服务端线程通过BC_REPLY命令把回复数据发给驱动。驱动再把回复数据拷贝到客户端的缓冲区唤醒客户端线程。客户端从transact()返回拿到结果。整个过程如果顺利耗时通常在微秒级别。但如果服务端处理慢客户端就会阻塞这也是为什么不能在主线程做跨进程调用的原因。4.4 线程池的管理策略Binder线程池的大小不是随便定的。默认情况下每个进程最多有16个Binder线程。这个数字是ProcessState里定义的可以通过setThreadPoolMaxThreadCount()调整但一般不建议改。线程太少并发请求会排队线程太多上下文切换开销大而且每个线程都要占用栈空间。线程的创建是懒加载的。进程启动时只有一个主线程注册到Binder驱动。当请求到来且没有空闲线程时驱动会通知ProcessState创建新线程。新线程创建后也注册到驱动然后参与请求处理。如果一段时间没有请求多余的线程不会被销毁而是继续阻塞等待。所以一个进程的Binder线程数一旦涨上去就不会主动降下来。实操心得如果你在开发系统服务发现Binder调用偶尔超时先看看是不是线程池被打满了。用dumpsys binder可以查看当前进程的Binder线程状态和待处理事务数量。5. ServiceManager与Binder的寻址机制5.1 服务注册的流程ServiceManager是Android里所有系统服务的“通讯录”。当一个服务启动时它需要先向ServiceManager注册自己。注册的过程本身也是一次Binder调用但这次调用的目标是ServiceManager这个特殊的Binder实体。ServiceManager在Binder驱动里有固定的引用编号0所以任何进程都能找到它。服务端调用ServiceManager.addService()传入服务名称和Binder对象。ServiceManager收到后把名称和引用保存到一个全局的映射表里。这个映射表在ServiceManager进程里是一个std::map或者类似的容器。之后客户端要使用某个服务就通过名称向ServiceManager查询拿到Binder引用后再发起实际调用。5.2 客户端获取服务的流程客户端调用ServiceManager.getService()时ServiceManager会查找映射表找到对应的Binder引用然后把这个引用返回给客户端。返回的过程中驱动会在客户端进程里创建一个新的binder_ref指向服务端的binder_node。客户端拿到这个引用后就可以像调用本地对象一样调用远程服务了。这里有一个细节ServiceManager返回的Binder引用是“强引用”还是“弱引用”默认是强引用意味着只要客户端还持有这个引用服务端的Binder对象就不会被销毁。如果客户端进程崩溃驱动会自动清理它持有的所有引用服务端的引用计数会相应减少。这个机制保证了不会因为客户端异常导致服务端资源泄漏。5.3 服务查询的性能考量每次getService()都是一次跨进程调用虽然数据量小但频率高了也会有开销。所以Android在应用层做了一层缓存ServiceManager.getService()拿到的Binder对象会被缓存在ServiceManager的静态Map里。下次再查询同一个服务直接返回缓存的对象不再走Binder调用。但这个缓存有个问题如果服务重启了缓存的Binder对象就失效了。所以系统服务通常还会注册DeathRecipient当服务端进程死亡时客户端会收到通知然后清理缓存并重新查询。这个机制在系统稳定性设计中非常重要很多“服务突然不可用”的问题根源就是没有正确处理Binder死亡通知。6. AIDL背后的自动生成逻辑6.1 AIDL文件到Java类的映射你写一个.aidl文件编译时会生成一个同名的.java文件。这个文件里包含了一个接口、一个Stub抽象类、一个Proxy代理类。接口定义了方法签名Stub是服务端要继承的Proxy是客户端实际使用的。Proxy里每个方法都会把参数写入Parcel然后调用transact()最后从回复Parcel里读出结果。生成的代码里transact()的第二个参数是一个整数叫做code。这个code是AIDL编译器自动分配的每个方法一个唯一值。服务端的onTransact()里用switch语句根据code分发到具体方法。这个设计的好处是协议紧凑一个整数就能标识方法不需要传输方法名字符串。6.2 Parcel的序列化规则Parcel是Binder通信的数据容器。它支持基本类型、String、数组、List、Map以及Binder对象和文件描述符。写入和读取的顺序必须严格一致否则会读到错误的数据。AIDL生成的代码保证了这一点Proxy里先写什么Stub里就先读什么。对于自定义对象需要实现Parcelable接口。Parcelable的writeToParcel()和CREATOR里的createFromParcel()必须成对正确实现。我见过不少bug是因为writeToParcel()里写了一个字段但createFromParcel()里忘了读导致数据错位。这种问题在编译期不会报错运行时才暴露排查起来很头疼。6.3 oneway关键字的影响AIDL支持oneway关键字。加了oneway的方法调用是异步的客户端发出请求后立即返回不等待服务端处理结果。这在不需要返回值的场景下很有用比如日志上报、事件通知。但oneway也有副作用如果短时间内发送大量oneway调用Binder驱动里的缓冲区可能被占满导致后续调用阻塞。另外oneway调用不保证顺序。虽然驱动会按顺序把请求挂到队列上但如果服务端有多个Binder线程它们可能并发处理这些请求实际执行顺序就不确定了。所以如果你的业务逻辑依赖调用顺序就不能用oneway。7. 那些文档里不写的坑7.1 主线程跨进程调用的代价在应用的主线程做跨进程调用如果服务端处理慢主线程就会阻塞。超过5秒就是ANR。但很多人不知道的是即使服务端处理很快跨进程调用本身也有开销。一次Binder调用的耗时包括ioctl系统调用、数据拷贝、线程唤醒、上下文切换。在低端设备上这个开销可能达到几毫秒。如果在一个循环里做几百次跨进程调用累计耗时就很可观了。实操心得批量操作尽量设计成一次调用传一个列表而不是循环调用单个方法。比如要获取100个联系人用getContacts(ListLong ids)比循环调用getContact(long id)快一个数量级。7.2 Binder缓冲区的大小限制Binder的传输缓冲区大小是有限制的通常是1MB左右。这个限制是每个进程的不是每次调用的。如果一次传输的数据超过这个限制就会抛出TransactionTooLargeException。这个异常在Activity之间传递大Bitmap时经常遇到。解决办法不是简单地把数据拆成多次传输因为多次传输之间可能被其他调用穿插导致数据不一致。更好的做法是用文件或者ashmem共享内存来传大数据Binder只传一个文件描述符或者句柄。系统里的MemoryFile和ParcelFileDescriptor就是干这个用的。7.3 死亡通知的注册与反注册linkToDeath()和unlinkToDeath()必须成对使用。如果只注册不反注册当Binder对象销毁时DeathRecipient还持有引用可能导致内存泄漏。更严重的是如果DeathRecipient里做了耗时操作而它是在Binder线程里回调的可能阻塞其他Binder调用。我遇到过一个案例某个应用在DeathRecipient里重新绑定服务但重新绑定又触发了新的linkToDeath形成了循环。服务端每次重启客户端就疯狂重连最后把系统服务打挂了。正确的做法是在binderDied()里先unlinkToDeath()然后再做重连逻辑并且加重试次数限制。7.4 多进程下的静态变量陷阱Binder通信是跨进程的但很多开发者会忘记这一点。他们在服务端把数据存在静态变量里以为客户端能直接访问。实际上客户端和服务端在不同的进程里静态变量是各自独立的。客户端修改了静态变量服务端根本不知道。这个问题的典型表现是单进程测试一切正常改成多进程后数据不一致。解决办法是把共享状态放在服务端客户端通过Binder调用读写。或者使用ContentProvider、文件、数据库等真正的跨进程共享机制。8. 常见问题速查与排查思路8.1 Binder调用超时怎么排查Binder调用超时的原因通常有三类服务端处理慢、Binder线程池满、驱动层阻塞。排查时先看服务端的CPU和线程状态确认是不是某个操作卡住了。然后看Binder线程池的使用情况如果所有线程都在忙新请求就会排队。最后看驱动日志dmesg里如果有Binder相关的警告可能是驱动层的问题。现象可能原因排查手段调用偶尔超时服务端偶发耗时操作抓服务端方法耗时调用持续超时Binder线程池耗尽dumpsys binder查看线程状态调用立即失败Binder对象已死亡检查DeathRecipient是否触发大数据传输失败超过缓冲区限制检查数据大小改用文件描述符8.2 TransactionTooLargeException的触发条件这个异常在两种情况下触发一是单次传输的数据超过1MB二是多次传输累计超过缓冲区大小但尚未被处理。第二种情况更隐蔽因为每次传输的数据都不大但服务端处理慢缓冲区里的数据越积越多最终溢出。解决办法是控制传输频率或者用oneway调用时加流控。如果确实需要传大数据用ParcelFileDescriptor传文件描述符让接收方自己读文件。这样Binder只传一个整数不占缓冲区。8.3 Binder线程被阻塞的连锁反应一个Binder线程被阻塞如果它持有某个锁其他Binder线程可能也会被阻塞。如果所有Binder线程都被阻塞新的跨进程调用就无法处理客户端会超时。更严重的是如果系统服务的Binder线程被阻塞可能影响整个系统的响应。避免在Binder线程里做同步的跨进程调用。比如服务端A的Binder线程调用服务端B如果B处理慢A的线程就被占住了。正确的做法是把这种调用放到工作线程里Binder线程只负责接收请求和返回结果。8.4 死亡通知不触发的几种情况binderDied()不触发通常是因为linkToDeath()没有成功注册。可能的原因包括Binder对象已经是死亡状态、DeathRecipient对象被GC回收、或者注册时抛了异常但被吞掉了。注册死亡通知后最好打个日志确认注册成功。另外binderDied()是在Binder线程里回调的不要在里面做耗时操作否则会阻塞其他Binder调用。9. 性能优化的一些实战经验9.1 减少跨进程调用次数这是最有效的优化手段。每次跨进程调用都有固定开销包括系统调用、数据拷贝、线程切换。把多次小调用合并成一次大调用能显著降低开销。比如IM应用里不要每收到一条消息就跨进程通知UI而是攒一批再通知。9.2 合理使用onewayoneway调用不阻塞客户端适合日志、埋点、事件通知等场景。但oneway不保证顺序也不保证一定送达如果缓冲区满调用会阻塞而不是丢弃。所以关键业务逻辑不能用oneway只有那些可以容忍丢失和乱序的场景才适合。9.3 避免在Binder调用里传大对象大对象不仅传输慢还可能触发TransactionTooLargeException。如果确实需要传大对象考虑用共享内存或者文件。Android的Binder本身不适合传大数据这是设计上的取舍它优化的是小数据、高频次的调用场景。9.4 监控Binder调用的耗时在开发阶段可以用BinderProxy的transact()方法加钩子记录每次调用的耗时。线上环境可以用StrictMode的detectCustomSlowCalls()来检测慢调用。如果发现某个调用经常超过阈值就要考虑优化服务端逻辑或者改成异步调用。10. 我个人在实际操作中的体会Binder这套机制刚接触时觉得复杂但把binder_proc、binder_node、binder_ref、binder_transaction这四个结构体的关系理清楚之后很多问题就豁然开朗了。我建议学的时候不要一上来就啃驱动源码先写一个最简单的AIDL例子用dumpsys binder看看进程的Binder状态再对照源码理解每个字段的含义。这样从现象到本质比死记硬背结构体定义要有效得多。另外Binder的很多“坑”其实都是设计上的取舍。比如缓冲区大小限制是为了防止一个进程占用过多内核内存线程池上限是为了控制上下文切换开销。理解这些取舍背后的原因遇到问题时就能更快定位而不是盲目试错。最后分享一个小技巧如果你怀疑某个Binder调用有问题可以在服务端的onTransact()入口和出口各打一条日志带上Binder.getCallingPid()和耗时这样能快速判断是客户端的问题还是服务端的问题。