做音视频通信项目这几年,我几乎把市面上叫得出名字的SIP协议栈都过了一遍。PJSIP这个开源多媒体通信库,最终成为我在多个项目里的默认选择。这篇文章不打算复述官方文档,而是从核心原理和实际研发视角,把PJSIP的架构、关键流程、编译配置以及我在真实项目中踩过的坑一次性讲清楚。如果你是准备自研软电话、对讲终端、呼叫中心话务,或者需要把SIP能力嵌进嵌入式设备的开发者,这篇应该能帮你少走不少弯路。
我先说一个结论:PJSIP从来不是功能最花哨的SIP协议栈,但它把「协议完整度、跨平台能力、资源占用、文档可读性」这几个做通信项目的硬指标平衡到了极致。后面所有内容都会围绕这句话展开。
1. 为什么做SIP通信选型时,我最终倒向了PJSIP
1.1 通信类项目选型难在哪
SIP(Session Initiation Protocol)本身是一个协议族,不是一份可以直接贴进去的代码。它解决的是「会话的创建、修改、终止」,但真正跑起来还要牵扯SDP媒体协商、RTP媒体传输、鉴权挑战、事务重传、NAT穿越、编解码器管理等问题。如果你从零自研一个能用的SIP协议栈,仅INVITE事务的重传定时器、多分支请求forking、鉴权摘要算法这三块,就够一个团队写上几个月,而且测出来的边界问题往往比预期多得多。
所以绝大多数项目会选择现成协议栈。但现成方案也分风格:有的追求功能完整但体积巨大,不适合嵌入式;有的API优雅但协议实现不完整,遇到复杂服务器时行为异常;还有的对IPv6、TLS、ICE支持不到位,一到真实网络环境就完蛋。PJSIP在这些选项里属于「协议细节抠得比较死、资源控制比较狠」的那一类。
1.2 PJSIP的定位与优势
PJSIP本质是一套用C语言实现的模块化开源通信库,核心围绕SIP/RTP操作。它的优势可以归纳成四点:
- 分层清晰:基础工具库、媒体库、信令栈、用户代理层各自独立,按需取舍。
- 跨平台能力好:主流的桌面、服务器、移动端和嵌入式环境都有移植支持,这也是很多物联网音视频项目选它的关键原因。
- 资源占用可控:可以在内存很小的嵌入式设备上裁剪编译,只保留必要模块。
- 双API设计:底层提供C接口,上层提供C++接口,不同技术栈的团队都能找到顺手的使用方式。
我接触过的项目中,有在普通Linux服务器上跑上千并发注册的,也有在ARM嵌入式板子上做对讲终端的,PJSIP都撑得住。区别只是编译配置和API选型不同。
1.3 哪些场景适合硬上PJSIP,哪些不该硬上
根据我的经验,这几个场景非常适合选PJSIP:
- 软电话客户端:需要独立注册SIP账号、拨打接听、支持音频视频通话。
- 对讲/门禁终端:设备资源有限,又要稳定接入SIP服务器。
- 呼叫中心话务条:需要快速接通、稳定媒体流,和现有业务系统集成。
- 语音网关/中继适配层:需要完整SIP协议栈做信令翻译或媒体代理。
但也有一些情况我不建议硬上:
- 如果你只需要浏览器里点对点通话,直接用WebRTC技术栈比引一个PJSIP插进来轻量得多。
- 如果只是临时拉通一个Demo,不打算长期维护,先用现成的解决方案验证业务更重要。
- 如果团队完全没有人懂SIP信令,PJSIP虽然文档不错,但还是需要一定的协议基础才能真正用好。
选型这件事,没有绝对最好的栈,只有和你的场景及团队能力最匹配的方案。我的建议是:先弄清楚你要的是「一个完整的SIP UA」还是「一个SIP信令参考实现」,前者PJSIP很合适,后者你可能更需要对着RFC一遍遍抠协议。
2. 先看清楚PJSIP的分层架构再动手
2.1 四层结构:pjlib、pjmedia、pjsip、pjsua
我第一次看PJSIP源码时有点懵,因为它的目录和模块实在太多。后来我把它的结构抽象成下面这张表,思路立刻就顺了。
| 层级 | 模块 | 主要职责 |
|---|---|---|
| 基础层 | pjlib / pjlib-util | 内存池、线程、网络IO、日志、哈希、XML/JSON工具 |
| 媒体层 | pjmedia / pjmedia-codec | 音频设备抽象、编解码器注册与调用、回声消除、媒体传输 |
| 信令层 | pjsip / pjsip-ua | SIP栈核心、事务管理、对话管理、UA逻辑 |
| 用户代理层 | pjsua / pjsua2 | 把上面三层整合成可直接使用的用户代理API |
这个分层不是摆设,它直接影响你排障时看哪份代码和哪条日志。比如注册流程走的是pjsip信令层,声音采集走的是pjmedia音频设备层,RTP发包则涉及pjmedia-transport和网络层。如果一上来就盯着pjsua2的API去看「为什么没声音」,大概率会走弯路。
底层基础库pjlib里面有个很核心的设计:内存池。所有PJSIP内部对象都从池里分配,而不是频繁调用malloc/free。这样设计的好处是碎片少、分配快、行为可预测,在长时间运行的话务设备上尤其重要。代价就是如果你把某个对象的生命周期管理错了,很容易出现整片池释放后仍有悬垂引用的问题,这个后面排查部分我会细说。
2.2 该选pjsua还是pjsua2
PJSIP给了两套用户代理API,很多人在这里纠结。
pjsua是C语言风格的API,优点是没有C++运行时依赖,函数调用直接,内存布局透明,适合嵌入式和C项目。缺点是所有事件都通过回调函数分发,业务逻辑多了以后,回调海里找状态很容易让人头大。
pjsua2是对pjsua的C++封装,提供了Endpoint、Account、Call、Buddy这些面向对象的类,回调也封装成了可重写的虚函数。业务代码写起来更像普通C++应用,团队的接受成本低。我后来的新项目基本都选的pjsua2,只有维护老嵌入式C代码时才继续用pjsua。
选pjsua2不是因为底层C API不行,而是因为它能显著减少「把业务状态硬塞进C回调」的别扭感。比如你有一个通话流程要保存自定义上下文,pjsua2里直接在Call子类加成员变量即可,pjsua里则需要自己维护一个把call ID映射到业务对象的表。
2.3 信令与媒体分离的设计思想
PJSIP架构里最容易被忽略、却最关键的思想是:信令和媒体严格分离。
信令走的是SIP消息,底层可以是UDP、TCP或TLS,端口通常是5060。媒体走的是RTP/RTCP,底层是UDP端口,通常是偶数端口区间内的动态端口。PJSIP在内部维护着完全不同的两个通道:一个处理SIP事务与对话状态,一个处理RTP包的收发、编解码、抖动缓冲和音视频同步。两个通道之间通过会话描述建立联系,但运行期互不阻塞。
这个设计直接影响了排障思路:
- 注册不上、被叫无响应,先查信令通道,看SIP日志即可。
- 能注册、能拨号,但双方听不见,先查媒体通道,抓RTP流量和音频设备状态。
- 通话断续、音质差,多半是抖动缓冲不足或编解码器配置不合理,和SIP信令基本无关。
如果你在看PJSIP日志时一直分不清哪行属于信令、哪行属于媒体,排障顺序就容易乱。记住一句口诀:信令出问题会没状态,媒体出问题会没声音,两者都出问题才需要怀疑交叉因素。
3. 把第一个可运行的PJSIP客户端跑起来
3.1 拿到源码与编译参数
PJSIP建议直接拉官方仓库的稳定发布分支,而不是随便抓一份网上拷贝。源码目录很大,不用担心,编译时只会构建你需要的部分。
本地Linux环境下,常见的最小化编译步骤是这样:
./configure --enable-shared make dep make sudo make installmake dep是生成依赖关系,很多人都漏了这一步,结果make到一半发现缺头文件。新版本也支持更现代的构建方式,但传统三步还是最稳妥。
真正决定效率的是configure参数。嵌入式或者纯软电话场景,我一般会裁剪掉用不到的模块:
./configure --disable-video --disable-libwebrtc --disable-opencore-amr --disable-sound --enable-shared- 只做语音,就关掉video和webrtc相关依赖,代码体积和编译时间都会明显下降。
- 不需要本机放声,就关掉sound,媒体层只处理网络包。
- 需要跑在ARM板子上,则追加交叉编译参数,例如
--host=arm-linux-gnueabihf,并把安装路径指定到工具链的临时目录。
我实测过同样的代码,裁剪前后静态库体积可以相差接近一半,这对几十MB内存的设备来说是实打实的收益。
3.2 初始化Endpoint与配置日志
pjsua2里的核心入口是Endpoint。所有PJSIP对象都挂在它下面。初始化顺序必须严格遵守:先创建、再初始化、最后启动。
#include <pjsua2.hpp> using namespace pj; int main() { Endpoint ep; EpConfig epCfg; epCfg.logConfig.level = 4; // 级别越高日志越详细,调试时调到5 epCfg.logConfig.msgLogging = true; // 打印SIP消息细节 ep.libCreate(); ep.libInit(epCfg); ep.libStart(); // 注册账户、拨打号码... ep.libDestroy(); return 0; }把日志级别调到5再打开msgLogging,是排查信令问题的第一步操作。它能直接看到发出的SIP请求、收到的响应、鉴权头、SDP内容。很多第三方文档不会强调这点,但它真的能省下大把时间。
这里有个容易踩的坑:libInit之前必须先libCreate,libStart之前必须完成对账号和传输的设置。顺序错了会返回错误码,日志里通常提示某个组件尚未初始化。不要图省事把三步并成一步。
3.3 注册一个SIP账户的最小配置
注册是软电话的第一步。通过AccountConfig构造好账户信息,再调用Account的create方法发起注册:
AccountConfig accCfg; accCfg.idUri = "sip:1001@192.0.2.100"; accCfg.regConfig.registrarUri = "sip:192.0.2.100"; accCfg.sipConfig.authCreds.push_back( AuthCredInfo("digest", "*", "1001", 0, "password") ); MyAccount acc; acc.create(accCfg);AuthCredInfo的五个参数依次是方案、realm、用户名、数据、密码。realm填*表示通配,实际服务器返回时会自动匹配。用户名就是SIP认证用户名,通常和分机号一致,但不一定,需要看服务器配置。
注册是异步流程,create之后立刻用acc.isValid()判断没有意义,真正的结果要等到onRegState回调里才有。一般建议在回调里判断status:
void MyAccount::onRegState(OnRegStateParam &prm) { if (prm.code >= 300) { // 注册失败,日志里会打印具体SIP响应码 } else { // 注册成功或正在进行中 } }理解异步回调,是上手PJSIP的第一道门槛。它不像普通函数调用那样return一个结果,而是把网络事件推到你的回调里。你写的代码得顺着它的节奏走,而不是试图让它迎合你的同步逻辑。
4. 从原理到实战:注册、呼叫、媒体协商是如何串起来的
4.1 注册与鉴权链路
PJSIP客户端发起注册时,实际是发出一条REGISTER请求。很多SIP服务器收到后不会直接返回200,而是先返回401或407来质询客户端身份。401表示需要对用户身份做摘要认证,407表示还需要代理认证。
这正好解释了一个常见现象:为什么你抓包能看到多个REGISTER消息来回,而不是一发一收。第一次REGISTER往往没有Authorization头,被服务器打回,客户端重新计算摘要后带着Authorization头再次发送。如果密码算错,服务器继续返回401,导致注册不断重试。
PJSIP内部已经封装好了这套逻辑,你只需要把AuthCredInfo配对。但要注意:
- 用户名和密码不要填反。
- 若服务器要求特定realm,不要通配
*,填具体值反而更稳。 - 用安全要求高的服务器时,确保编译期已经打开TLS支持,并且传输层选的是TLS而不是默认UDP。
注册成功后,PJSIP会按刷机周期自动刷新注册。你不必自己写定时器周期性地调用register,栈会管理。
4.2 一次呼出的完整信令链
发起呼叫时,用户代理会构造一条INVITE请求,消息体携带SDP媒体描述。所谓SDP,就是告诉对端:我能接收哪些编码器、我的RTP端口开在哪里、音视频流参数如何。
一个典型成功的呼出流程是这样的:
客户端 服务器/对端 |--- INVITE ------>| |<-- 100 Trying ---| |<-- 180 Ringing --| |<-- 200 OK -------| |--- ACK --------->| |==== RTP媒体 ====|180代表被叫开始振铃,200代表被叫接听。客户端收到200后必须回一条ACK,整个会话正式进入通话状态。很多初学SIP的人会漏掉ACK这一步,最后导致媒体通了但会话状态异常。
在pjsua2中,发起呼叫的代码很简短:
CallOpParam prm; prm.opt.audioCount = 1; Call *call = new MyCall(*acc); call->makeCall("sip:1002@192.0.2.100", prm);但背后发生的事情非常多:PJSIP要为新呼叫建立对话框、生成SDP、打开RTP传输、检查编解码器能力、启动媒体流。所以别看到一行makeCall就以为核心逻辑很简单,恰恰相反,媒体协商才是决定通话质量的重头戏。
4.3 呼入处理与状态机
软电话需要接听来电,所以Account子类里通常重写onIncomingCall:
void MyAccount::onIncomingCall(OnIncomingCallParam &prm) { MyCall *call = new MyCall(*this, prm.callId); CallOpParam op; op.opt.audioCount = 1; call->answer(op); // 或者延迟到用户点击接听时再answer }接听不是只能立刻answer,你完全可以把Call对象存起来,等用户点击UI再answer。挂断则调用hangup。
PJSIP里所有呼叫状态都通过onCallState回调暴露,这个回调对应的是SIP对话状态机的变化。命令行程序里可以直接打印CallInfo查看状态:
void MyCall::onCallState(OnCallStateParam &prm) { CallInfo ci = getInfo(); // ci.state 就是当前状态,比如Ringing、Confirmed、Disconnected }你不需要记忆全部状态常量,只要抓住几个关键点:Ringing对应振铃中,Confirmed对应已接通,Disconnected代表通话已结束。业务逻辑只需要在这几个状态点上做处理,例如开始录音、停止录音、弹计费窗、重置界面。
4.4 状态回调机制为什么要这样设计
PJSIP把所有异步事件都设计成回调,而不是阻塞等待,核心目的是避免一个线程卡在网络IO上导致整个应用无响应。SIP高层的口语化理解是:信令包随时可能从多个传输来,一个事务可能要等几秒才超时,如果同步等,你的界面早就冻住了。
实际项目里,开发者往往会再包一层线程模型。比如在界面应用里,onRegState、onIncomingCall这类回调并不是跑在UI线程,你需要用线程切换工具跳回UI线程才能安全更新控件。PJSIP本身不关心你用哪种界面框架,它只负责告诉你事件已经发生,剩下的调度由应用层决定。
这个设计让PJSIP既适合纯后台进程,也适合有UI的客户端。你只要记住一条:不要在回调里做耗时操作,不要在回调里直接操作共享资源不加锁,否则排查起诡异崩溃来会非常痛苦。
5. 音视频通话里的硬骨头:NAT穿透与媒体质量
5.1 为什么NAT穿透是最大变量
很多终端部署在私有网络后面,SIP信令和RTP媒体都面临着同一个问题:SIP消息里的IP地址是私网地址,对端无法直接访问;即使信令通过服务器转发解决了,媒体流如果走P2P直连,RTP包也可能被NAT策略拒掉。
NAT的行为差异很大。锥型NAT在对外发出一个包后,会允许外部回包从同一映射端口进入,P2P媒体有机会直连。对称NAT则为每个目标地址分配不同端口,外部根本无法通过猜端口回包,只能依赖中继。这就是为什么PJSIP配置里同时存在STUN和TURN选项:STUN负责探测公网映射,TURN负责在打洞失败时中继流量。
5.2 ICE/STUN/TURN的正确配置方式
PJSIP实现了ICE标准,按优先级收集主机候选、服务端映射候选和中继候选,最终协商出一对可用地址。启用方式在pjsua2里比较直接:
MediaConfig mc = epCfg.mediaConfig; mc.enableIce = true; TransportConfig tc; tc.port = 5060; tc.publicAddress = ""; // 留空则自己不强制宣告公网IP然后再给传输层配置STUN服务器地址。公共STUN服务可以找,生产环境建议自建或使用可靠的网络服务商提供的地址。TURN则按需配置,包含服务器地址、端口以及用于中继分配的账号凭据。
我的建议是分三步走:
- 内网测试阶段,不开ICE和TURN,纯直连,先把流程跑通。
- 外网P2P测试阶段,开STUN和ICE,观察是否成功建立P2P媒体。
- 遇到对称NAT或跨运营商苛刻网络时,再上TURN中继兜底。
直接把ICE和TURN全开了,不一定更好。TURN中继服务器带宽成本高,能不启动尽量不启动。ICE协商成功后,从日志里能看到用的是哪种类型的候选,据此判断媒体是直连还是走了中继。
5.3 编码器选型与语音质量参数
语音编码器直接影响带宽和音质。常用组合是:
| 编码器 | 采样率 | 典型码率 | 适合场景 |
|---|---|---|---|
| G.711 | 8kHz | 64kbps | 默认兼容性最好 |
| G.722 | 8kHz外 带 16kHz | 64kbps | PSTN与VoIP互通常见 |
| Opus | 48kHz | 动态可调 | 网络好时音质上限最高 |
调节编码器优先级和参数后,注意音频格式协商顺序:双方都会按自己偏好的顺序发送编码器列表,PJSIP里的方案是优先选双方共有且都排在前面的一项。如果你希望固定使用Opus,就把其他编码器优先级降下去。
语音质量还有一些容易被忽略的开关:
- 回声消除:在pjmedia层启用AEC,对免提设备几乎是必须的。
- 噪声抑制:降低环境底噪,尤其适合户外设备和廉价麦克风。
- 静音检测VAD:节省带宽,但可能把轻微语音切掉导致句子开头丢失,要根据场景取舍。
- 抖动缓冲:PJSIP提供自适应jitter buffer,建议保持开启,网络抖动时音质明显更稳。
我看过很多项目抱怨音质差,最后发现是采集设备的采样率和编解码器不匹配。先检查音频设备的能力,再去看编码器配置,顺序别搞反。
5.4 音频设备管理
无声音是PJSIP最常见的投诉之一,而根因常常根本不在SIP层,而在采集和播放设备索引配错了。pjsua2里通过AudDevManager控制设备:
AudioMediaCapture *capture = new AudioMediaCapture(); capture->create(); // 默认采集设备实际项目里你需要先枚举设备列表,再按名称匹配需要的声卡,而不是直接写死设备号。嵌入式设备的声卡加载顺序偶尔会变,写死编号是隐患。更稳妥的做法是:能扫到设备就按设备名选择,扫不到就输出告警日志,让维护人员知道音频设备没起来。
6. 实测中的那些坑:从编译到运行的排查链路
6.1 编译阶段的常见问题
编译PJSIP时,我遇到最多的问题不是源码本身,而是依赖环境不完整。常见症状是configure报缺少头文件或链接期符号未定义。解决办法不是盲目加包,而是先看configure前几行的错误提示,确认缺的是哪个开发包,再针对性补齐。
资源受限设备上,另一类问题是开启的模块太多导致链接体积超标。解决办法是裁剪:--disable-video、--disable-libwebrtc、只保留必要编码器。我见过有人为了省事开着全部模块交叉编译,结果单是二进制体积就让设备存储吃紧,完全没有必要。
交叉编译还有一个容易踩的点:混淆了宿主编译和目标编译的库路径。configure时用--prefix指定安装目录,编译完务必检查生成的是目标平台CPU架构的库,可以用文件类型工具看一眼。架构不对,运行起来直接SIGILL,排查起来特别像代码问题,实际上只是编译配置错误。
6.2 注册失败的标准排查链路
注册失败不要瞎改配置,先按下面这个链路走:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 确认服务器地址、端口、协议 | 抓包能看到请求发出和响应返回 |
| 2 | 打开日志level=5和msgLogging | 能看到INVITE/REGISTER和响应码 |
| 3 | 查401/407响应中的realm | 确认认证凭据与服务器要求一致 |
| 4 | 检查用户名、密码和鉴权方案 | 密码错误会重复401 |
| 5 | 尝试关闭IPv6 | IPv4服务器对v6包无响应时尤其有效 |
如果服务器地址通、端口通,但REGISTER始终无响应,先检查防火墙丢包,再检查服务器是否只监听TLS。PJSIP对传输层的选择是在TransportConfig里指定的,默认UDP,很多安全服务器要求TLS并禁用明文传输。
6.3 单通、无声音类问题排查
单通指一方听到另一方,反之不行;无声音是双方都不通。这两类问题的排查逻辑完全不同,因为单通意味着媒体通道建立了一半,多半是NAT或防火墙把某一方向的RTP回包丢了;无声音则可能是媒体通道完全没建立,或者音频设备有问题。
排查链路由粗到细:
- 看SDP里协商的RTP端口和IP,确认不是私网IP被发给了公网对端。
- 抓RTP包,看有没有双向收包。只有单向包就是NAT映射问题。
- 如果RTP双向都有包依然无声,检查音频设备选择和采样率匹配。
- 检查是否启用了静音检测,有个别设备对非常规语音误判为静音,直接掐断上行。
我碰到过一个特别典型的案例:双方都能看到通话界面,都喊话但互相听不到,最后还是靠抓包发现一方的RTP被防火墙丢弃,修改防火墙放行UDP端口范围后彻底解决。整个过程里SIP信令完全正常,如果不是先看媒体层而是一味查账户问题,可能永远找不到原因。
6.4 日志分析方法与一个通用排查模板
PJSIP日志里有用的信息全带中括号,比如pjsua_core.c、sip_transport.c、pjmedia_audiodev.c,看到文件名就能判断是哪一层。我的习惯是按层筛选:
| 日志关键字 | 对应问题方向 |
|---|---|
pjsip_ua/sip_transaction | 信令事务、状态机 |
pjmedia/audio_dev | 媒体流、设备 |
ice/stun/turn | NAT穿透候选 |
sip_*消息内容 | SIP消息细节,含SDP |
排查时把日志调到5,一次性复现问题,把从启动到问题出现之间的全部日志存下来,再按关键字分类。这个过程比瞎试配置高效太多。
调日志级别时注意别在生产环境长期开5级,日志量会非常大,影响性能和存储。生产上建议级别4或更低,调试时再临时开到5。
做通信项目,一半时间是在和看不见的协议包打交道。PJSIP给了足够多的日志和调控接口,但真正要把问题定位准,还是得保持信令媒体分层的意识,一步步缩小范围。这套思路也是我后来排查其他协议栈问题时的通用方法,光记住API是不够的。