ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PJSIP通话Demo拆解:SIP信令、呼叫转移与保持实现要点

PJSIP通话Demo拆解:SIP信令、呼叫转移与保持实现要点 简介基于PJSIP开源SIP协议栈打造的语音通话演示项目面向VoIP应用开发初学者、嵌入式工程师以及需要快速理解会话初始化协议流程的移动端开发者完整演示了呼叫发起、接收、转接、保持等基础通话功能同时明确不包含会议与录音模块适合作为二次开发与协议学习的最小可运行底座。压缩包整体大小约43.14MB共包含383个文件其中以260个Java源码文件、49个XML配置文件、7个SO动态链接库为主体并辅以Gradle构建脚本、PNG界面资源、属性配置与少量示例数据目录结构清晰便于直接导入工程或按模块追踪通话逻辑。目前已有559人学习下载。源码覆盖PJSIP库初始化、SIP服务器注册、账号参数配置以及INVITE、REFER等关键消息的事件处理同时包含错误处理与日志记录机制通过阅读和编译可掌握呼叫转接与保持的实现脉络。结合配置文件中服务器地址、端口、用户名和密码的调整即可快速验证真实通话效果为后续扩展会议、录音等功能奠定基础。1. 拆一个 PJSIP 通话 DEMO转接、保持是核心会议与录音别指望刚开始接触 SIP 的人拿到这个 sipdemo第一反应往往是到处找会议和录音的代码翻遍工程没找到然后觉得这个 Demo 没含金量。我的看法相反这个 DEMO 的价值恰恰在于砍掉会议和录音只保留通话建立、呼叫转移和保持三条主线。PJSIP 是 C 语言的高性能 SIP 协议栈把 INVITE、REFER、UPDATE 这类信令封装成 API这个工程的价值是把 API 组合成能跑通整个通话生命周期的骨架——注册后能呼出、来电能接听、通话中能转移、能保持还能恢复。它适合两类人刚接手 VoIP 项目、想在干净工程里看 PJSIP 初始化全流程的开发者以及做人脸门禁、安防话机正在配置海康平台 SIP 对接参数、需要先用本地 DEMO 验证信令的人。看懂它没放什么比看懂它放了什么更能帮你在真实项目里做减法。2. SIP 信令对齐INVITE 建话路、REFER 转接、UPDATE 保持读码前先看协议这个 Demo 的源码量不大但直接扎进代码里读很容易晕因为 PJSIP 的回调机制把信令流程打散到了十几个函数里。我建议先把 SIP 协议层的对话模型建立起来再回头读工程效率会翻倍。2.1 一个通话的完整生命周期从 100 Trying 到 ACK一个最基本的 SIP 通话信令面上只有五个关键动作我把它整理成下面的时序表阶段SIP 方法方向作用呼出INVITE主叫 → 被叫携带 SDP 媒体描述发起会话临时响应100 Trying被叫/服务器 → 主叫已收到请求正在处理振铃180 Ringing被叫 → 主叫被叫终端开始响铃应答200 OK被叫 → 主叫应答并带回被叫侧的 SDP确认ACK主叫 → 被叫完成三次握手媒体流开始传输结束BYE任一方 → 另一方释放通话会话结束PJSIP 把这套流程封装成了on_call_state回调里的状态机从PJSIP_INV_STATE_CALLING到PJSIP_INV_STATE_CONFIRMED再到PJSIP_INV_STATE_DISCONNECTED。读这个 Demo 时最常用的就是拿CONFIRMED作为“可以转接、可以保持”的动作触发点。这比从 socket 层裸写一个 SIP 栈要省一半以上的心智负担——事务层的重传、超时、3xx 重定向处理PJSIP 内部全做掉了你只负责在正确的状态挂上自己的业务逻辑。提示如果你之前只玩过 SIPp 或者只调过话机你会发现这里的核心概念是“对话Dialog”。一次通话对应一个 DialogINVITE 建立 DialogREFER 在 Dialog 之上建子事务BYE 销毁 Dialog。保持通话不新建 Dialog只是通过 re-INVITE 或 UPDATE 改变媒体方向。2.2 为什么是 PJSIP 而不是裸写 socket 或其他栈选型这事直接决定你后面要补多少课。这个 Demo 用 PJSIP我认为有三个理由值得在动手前想清楚。第一是协议完备度。PJSIP 对 RFC 3261 的实现是业界公认的高完成度从鉴权、重定向、NAT 穿透到各类扩展方法都有对应模块。裸写 socket 的话一个 401 重试逻辑就能消耗你一周时间而这在 PJSIP 里只是初始化配置里一个字段。第二是跨平台可移植性。PJSIP 的底层 pjlib 把内存池、线程、socket、日志做了统一抽象同一份代码可以在 Windows、Linux、Android、iOS 上编译。安防设备里常见的海思、瑞芯微平台都能跑这也是它被大量嵌入式话机、门禁设备选用的原因。第三是它自带一套完整的媒体栈。pjmedia负责 RTP 收发、编解码、回声消除和抖动缓冲Demo 里不需要你手动处理音频流的声道映射。相比之下很多轻量 SIP 协议栈只处理信令媒体还得另找库拼装。这个 Demo 把 PJSIP 的初始化、注册、呼叫控制、事件回调四块拼成了一个最小可运行模型正好覆盖了一个 VoIP 应用 80% 的骨架代码。从商业项目改造的角度看它的价值就是告诉你“哪一行代码对应哪一条信令”。2.3 解压后先过滤噪音bin 缓存不是源码别在上面浪费时间拿到压缩包解压后你可能会看到一排类似classAnalysis.bin、taskHistory.bin、jarAnalysis.bin、fileHashes.bin、last-build.bin、config这样的文件。第一次看很容易误以为是 SIP 相关的数据文件但实际上这类.bin大概率是构建系统自动生成的依赖分析缓存或 CI 产物不是通话逻辑的一部分。用 Linux 的file命令扫一眼就能确认——它们通常是二进制索引而非 SIP 报文。真正要花时间的是这几类内容文件/目录类型内容优先级src/*.c、*.hPJSIP 初始化、账号配置、回调处理、呼叫控制全文精读config/ 配置文件SIP 服务器地址、端口、用户名、密码必改跑通的前提Makefile / 构建脚本编译链接参数、库路径按平台调整*.bin缓存文件构建系统副产物忽略即可我一般会把源码里pjsua_init、pjsua_acc_add、pjsua_call_get_info这三个函数出现的位置先标出来然后在 IDE 里从main函数开始按调用链顺一遍。这样二十分钟就能摸清骨架不会陷在缓存文件里出不来。3. PJSIP 初始化与账号注册三步把 Demo 注册进 SIP 服务器看完协议层接下来就是把 Demo 真正跑起来。这一步的核心是初始化 PJSIP 栈、配置账号、把日志打开。三步走完你就能在日志里看到一条完整的 REGISTER 信令。3.1 初始化 PJSIP 栈pjsua_create、配置与日志级别先看初始化代码这是整个 Demo 的地基/* main.c - PJSIP 栈初始化 */ #include pjsua-lib/pjsua.h static pj_status_t init_sip_stack(void) { pj_status_t status; /* 1. 分配并初始化 pjsua 核心资源必须先于任何 API 调用 */ status pjsua_create(); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_create failed, status); return status; } pjsua_config ua_cfg; pjsua_config_default(ua_cfg); /* 最大并发通话数Demo 骨架设 8 路足够按产品规格再调 */ ua_cfg.max_calls 8; ua_cfg.cb.on_call_state on_call_state; ua_cfg.cb.on_call_media_state on_call_media_state; ua_cfg.cb.on_incoming_call on_incoming_call; pjsua_logging_config log_cfg; pjsua_logging_config_default(log_cfg); /* 5 级能看到 INVITE/REGISTER 信令6 级连 SDP 载荷都打出来 */ log_cfg.console_level 5; log_cfg.log_filename pj_str(sipdemo.log); log_cfg.log_file_level 6; pjsua_transport_config tp_cfg; pjsua_transport_config_default(tp_cfg); /* SIP 默认端口 5060本机调试跑双实例时第二个改成 5062 */ tp_cfg.port 5060; status pjsua_init(ua_cfg, log_cfg, tp_cfg); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_init failed, status); return status; } return pjsua_start(); }这段代码的逻辑不复杂pjsua_create负责内存池和线程池的创建pjsua_init把配置落进核心pjsua_start才真正拉起 SIP 事务线程。三个参数结构体的默认值都来自 PJSIP 库你只需要改业务相关的字段。max_calls设成 8 意味着 Demo 允许最多 8 路通话并发真实设备上这个值要根据内存估算设太大会导致每路通话等待媒体端口分配时出现延迟。console_level是调试命脉低于 4 时很多关键信令根本不打出来你排查问题会非常被动而log_file_level设 6 是为了把完整 SDP 写入文件方便事后对照 Wireshark 抓包。注意pjsua_create必须是第一个被调用的 API且整个生命周期内最好在同一个线程里做初始化。你在 Demo 里看到的所有回调都运行在 pjsua 内部的 poll 线程上。3.2 注册到 SIP 服务器账号、realm 与鉴权信息初始化成功之后下一步是添加 SIP 账号。这个 Demo 的配置文件里一般会预留192.168.x.x这样的占位地址改成你自己的服务器即可。核心代码如下/* acc_config.c - 添加 SIP 账号 */ static pjsua_acc_id add_sip_account(void) { pjsua_acc_config acc_cfg; pjsua_acc_id acc_id; pjsua_acc_config_default(acc_cfg); /* 对外展示的 SIP URI用户 1001域是服务器地址 */ acc_cfg.id pj_str(sip:1001192.168.1.10); /* 注册服务器地址REGISTER 请求发往这里 */ acc_cfg.reg_uri pj_str(sip:192.168.1.10); /* 摘要认证信息realm 必须和服务器 401 Challenge 一致 */ acc_cfg.cred_count 1; acc_cfg.cred_info[0].realm pj_str(192.168.1.10); acc_cfg.cred_info[0].scheme pj_str(digest); acc_cfg.cred_info[0].username pj_str(1001); acc_cfg.cred_info[0].data pj_str(123456); pj_status_t status pjsua_acc_add(acc_cfg, PJ_TRUE, acc_id); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_acc_add failed, status); return PJSUA_INVALID_ID; } return acc_id; }这段代码里id是注册成功后向外界展示的身份对方话机上显示的号码就是你这里配的 1001reg_uri决定 REGISTER 报文发到哪个服务器cred_info里的 realm、username、data 对应摘要认证三元组。很多人第一次跑通不了都是卡在 realm 上——服务器返回 401 时会在WWW-Authenticate头里带一个 realm这个字段必须和这里配置的完全一致否则客户端算不出正确的摘要响应。pj_str是 PJSIP 的字符串类型别直接传 C 字符串指针因为 pjsua 内部要持有这份数据到注册完成。常见做法是从配置文件读这些值而不是写死在源码里这个 Demo 的config文件干的就是这件事。3.3 编译与首次验证日志开到最高先看 REGISTER 结果初始化代码和账号配置都改好之后开始编译。压缩包里带的具体构建脚本以实际为准常见的是 autotools 风格或 CMake 风格# 我习惯用 CMake 工程组织方式举例具体以包内脚本为准 mkdir -p build cd build cmake .. -DPJ_DIR/opt/pjsip make -j4 # 把日志级别直接拉到 6确认注册流程 ./sipdemo --log-level 6首次运行只关心两件事日志里有没有出现REGISTER请求以及账号状态有没有从PJSUA_ACC_STATUS变到PJ_SUCCESS。如果看到反复的 401 重试先别急着改代码回到 3.2 的 cred 配置里核对 realm 和密码。这个阶段最容易犯的错误是只跑一个客户端实例然后拿它直接呼另一个真实话机。正确的验证路径是在服务器上建两个账号 1001 和 1002本机跑两个 Demo 实例第二个实例端口换成 5062让它们互相呼叫。这一步通过了才说明注册、鉴权、信令链路全部正常。4. 呼叫转移与保持的实现call_xfer、set_hold 与事件回调的配合注册跑通之后进入了这个 Demo 真正有含金量的部分呼叫转移和呼叫保持。这两块是 SIP 里最容易“看着会了、一调就翻车”的功能因为它们在信令层的表现和用户直觉完全不同。4.1 呼叫转移pjsua_call_xfer 发 REFER而不是重新发 INVITE先说结论呼叫转移在 SIP 协议里是通过 REFER 方法实现的不是再发一个 INVITE 去另外的地方。PJSIP 对外的封装就是pjsua_call_xfer。来看 Demo 里最可能出现的调用片段/* call_control.c - 把指定通话转移到目标分机 */ pj_status_t transfer_call(pjsua_call_id call_id) { /* 目标 URI 可以是分机号、外部号码或完整 SIP URI */ pj_str_t dst_uri pj_str(sip:1002192.168.1.10); /* 第三个参数 options 填 NULL 表示采用默认行为 向对端发送 REFER 请求由对端发起新呼叫至 1002 */ pj_status_t status pjsua_call_xfer(call_id, dst_uri, NULL); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_call_xfer failed, status); return status; } return PJ_SUCCESS; }pjsua_call_xfer的语义是“让当前通话的对端主动去呼dst_uri”所以信令流向是本端 → 对端发 REFER对端回 202 Accepted然后对端再向 1002 发起 INVITE。这个细节决定了为什么转移不是打断当前通话——在 REFER 执行期间原来的通话链路是保持着的直到对端成功建立新呼叫并回 NOTIFY 确认旧呼叫才会被 BYE 掉。参数上call_id来自on_call_state回调里透传的那个值dst_uri建议写完整的 SIP URI只写分机号在某些软交换上也能被解析但碰到严格校验的服务器会被拒options为 NULL 时走默认行为如果你需要转移后立刻挂断原通话需要设置 options 里的标志位。这个 Demo 的定位是展示基本转移所以 NULL 就够用。注意REFER 的目标地址只存在于 REFER 请求头里主叫方对被叫方的新 INVITE 中间的 180、200 是不可见的。所以 UI 上如果显示“转移成功”太早用户会看到一段真空期。4.2 保持与恢复set_hold 背后的 re-INVITE 与媒体方向协商呼叫保持的实现路径和转移完全不同它是通过媒体方向协商完成的信令上是 re-INVITE。PJSIP 把这事封装成了pjsua_call_set_hold/* call_control.c - 保持与恢复 */ pj_status_t hold_call(pjsua_call_id call_id) { /* NULL 表示不需要额外 option内部自动构造 sendonly SDP */ pj_status_t status pjsua_call_set_hold(call_id, NULL); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_call_set_hold failed, status); } return status; } pj_status_t unhold_call(pjsua_call_id call_id) { /* 恢复时媒体方向从 sendonly 切回 sendrecv */ pj_status_t status pjsua_call_set_unhold(call_id, NULL); if (status ! PJ_SUCCESS) { pjsua_perror(sipdemo, pjsua_call_set_unhold failed, status); } return status; }保持的本质是本端向对端发一个 re-INVITESDP 里把媒体方向标成sendonly意思是“我不再接收你的音频但我还在发”。对端如果回 200 OK 且 SDP 是recvonly保持就成功了。恢复则是把方向改回sendrecv再协商一次。参数上NULL表示走默认协商流程PJSIP 会自动生成新的 SDP 并重协商编解码。这里有新手常犯的误区以为保持是“不发媒体了”于是在应用层直接停掉 RTP 发送线程结果信令面还处于通话中对端几十秒后才因为 RTP 超时自动挂断。正确做法永远是信令先走 re-INVITE媒体流跟着信令走。4.3 状态回调CONFIRMED 后再动作别在回调线程里阻塞转移和保持的函数本身很简单难的是什么时候调。看一段典型的事件处理代码/* event.c - 通话状态变化回调 */ void on_call_state(pjsua_call_id call_id, pjsip_event *e) { pjsua_call_info call_info; pjsua_call_get_info(call_id, call_info); if (call_info.state PJSIP_INV_STATE_CONFIRMED) { /* 通话已建立此刻才能安全执行转接或保持 */ schedule_transfer_if_needed(call_id); } else if (call_info.state PJSIP_INV_STATE_DISCONNECTED) { /* 通话结束清理对应的 UI 和资源 */ cleanup_call_ui(call_id); } }这个回调运行在 pjsua 的 poll 线程上所有事件处理都在这个线程里串行执行。如果你直接在回调里做耗时操作——比如写数据库、调用远端 API、执行阻塞式转接——整个 UA 的信令处理都会被卡住表现为其他通话的 INVITE 超时。常见做法是schedule_*系列函数把动作投递到自己的业务线程队列回调里只做轻量判断。Demo 里on_call_state是最重要的一个回调它的call_info.state字段覆盖了整个通话生命周期。读懂这个状态机的跳变顺序比背任何 API 文档都管用。5. PJSIP 通话 Demo 避坑清单401 认证、静音恢复、REFER 失效怎么查这类坑在“把 Demo 跑通”的阶段多半碰不到真正集中爆发是在你把它当骨架接进真实设备、或者去对接海康这类第三方平台的时候。下面按我踩的顺序列每一条都是现象 → 原因 → 解决的结构。5.1 401/407 死循环realm 与服务器 Challenge 不一致现象注册不上去日志里 REGISTER 发出去收到 401 Unauthorized然后客户端好像没带 Authorization 头再发一次一直循环到超时。原因服务器返回 401 时WWW-Authenticate头里带了一个 realmPJSIP 会拿这个 realm 去匹配你配置的cred_info[0].realm。两边不一致客户端就认为自己没有对应的凭证不会生成摘要响应。解决先把console_level打到 6grep 日志里的WWW-Authenticate把 realm 原样复制到配置文件。另一个坑是某些平台要求 realm 是域名而不是 IP比如“sip.example.com”本地测试时用 IP 没问题上生产就换域名。5.2 Hold 之后恢复双方没有说话声音现象通话中按保持键再按恢复UI 显示通话已恢复但双方听不到彼此声音RTP 包也不再发送。原因恢复时 PJSIP 发的 re-INVITE 里媒体方向协商失败或者对端在保持期间主动挂断但本端状态没有同步收敛。最常见的是对端设备不支持 sendrecv 方向切换回复的 SDP 仍然标成 recvonly两边都只收不发。解决恢复后主动查媒体方向。在on_call_media_state回调里读pjsua_call_get_info的media_status如果停在PJSUA_CALL_MEDIA_NONE或方向不对就强制重新协商先pjsua_call_set_unhold再判断状态必要时调用一次pjsua_call_reinvite强制 200 OK 后重建 RTP 通道。5.3 REFER 转移后两端状态错乱新呼叫振铃但老通话不挂现象A 和 B 通话A 把通话 REFER 给 C。C 振铃并接通了但 A 的 UI 还显示和 B 通话中B 也显示在通话中两边互相听不到最后靠超时切断。原因REFER 的完成状态是通过 NOTIFY 事件上报的PJSIP 用独立的on_call_transfer_state回调上报。Demo 里如果只监听了on_call_state没有跟踪 transfer 的 NOTIFY final 状态业务层就不知道转移已经完成不会对原通话发起 BYE。解决给 Demo 补上on_call_transfer_state回调在收到PJSIP_EVENT NOTIFY且term_state PJSIP_TERM_STATE_SUCCESS时主动调用pjsua_call_hangup(call_id, PJSIP_SC_OK, NULL, NULL)挂断旧通话。这一步不加REFER 功能就是“看起来在工作实际永远收不了尾”。5.4 退出时偶发崩溃崩在 pjsua_destroy现象程序退出时有时正常有时 Segfault调用栈指向pjsua_destroy而且越频繁操作通话越容易复现。原因pjsua 内部有独立的媒体和信令线程如果退出时还有通话在活跃状态直接pjsua_destroy会让底层资源在线程还在引用时被释放。Demo 里如果没做“先挂断所有通话再停止线程最后销毁”的顺序控制就必现这个问题。解决退出流程按这个顺序走先遍历所有pjsua_call_id调pjsua_call_hangup再调pjsua_stop停掉线程最后才pjsua_destroy。在信号处理函数里不要直接调 destroy只设置退出标志让主循环退出后再走清理流程。5.5 本机双实例调试第二个实例起不来现象按 3.3 的方法本机跑两个 Demo 实例做互呼测试第二个实例启动报bind失败直接退出。原因两个实例都绑定了 5060 端口第二个必然失败。这个问题看着低级但几乎每个新手都踩一遍。解决第二个实例的传输层端口改成 5062同时注册时两个账号保持一致即可。PJSIP 的传输端口不影响注册只影响本端 socket 监听所以 1001 用 50601002 用 5062服务器侧自动处理。6. 验证与进阶抓包看 REFER 语义再把骨架改成安防话机接线员代码改完怎么确定它真的按 SIP 语义在工作我习惯的做法是 Wireshark 抓包验证比等 UI 反馈准确得多。几个常用的过滤场景验证目标过滤表达式观察点查看注册交互sip.Method REGISTER401 的 realm 是否匹配配置查看转移信令sip.Method REFERREFER 目标 URI 是否正确跟踪单路会话sip.Call-ID 指定ID整个 INVITE/ACK/BYE 时序确认媒体方向rtp ip.addr 本机IP保持后是否还在发 RTPREFER 是否真正成功要盯着 NOTIFY 报文里的event: refer头看最终状态是不是 200 OK。如果看到 486 Busy Here 或 603 Decline说明被转移端拒绝问题在对端策略不在你的代码。这一条验证下来转移逻辑的成败根本不用猜。验证完基础能力这个 Demo 的进阶用法是给它加业务规则。我拿它改过一个简单的安防话机接线逻辑门禁呼叫进来时先匹配来访者号码命中黑名单就直接把通话转移给前台话机。核心就是在on_incoming_call回调里加一段判断/* incoming_call 回调里做号码匹配命中则延后转接 */ void on_incoming_call(pjsua_call_id call_id, pjsip_rx_data *rdata) { pjsua_call_info info; pjsua_call_get_info(call_id, info); if (is_blacklisted(info.remote_uri)) { /* 不要在回调里立即 xfer排队到 CONFIRMED 再执行 */ schedule_transfer(call_id, sip:1002192.168.1.10); } }这里要注意on_incoming_call触发时通话还没建立立刻pjsua_call_xfer会导致 REFER 发在 INVITE 的 200 OK 之前对端逻辑上根本处理不了。所以我在工程里加了一个延迟队列等on_call_state里状态变成CONFIRMED再执行转接。从那以后我每次改转移相关逻辑都强制走一遍console_level 6加 Wireshark 双开的流程确认 NOTIFY 最终落在 200 OK 才提交代码这个习惯已经帮我拦下了至少三次“假转移”事故。希望帮到你。本文还有配套的精品资源点击获取
返回列表