手把手教你用 pjsip 搭出第一个 VoIP 通话应用:从零开始的实战指南
你有没有想过,自己动手写一个能打电话的程序?不是用微信、不是走运营商,而是真正通过网络传输声音——哪怕只是两台电脑之间“喂喂”两声。这听起来像是黑科技,但其实,只要掌握pjsip,这件事比你想象中简单得多。
在实时通信领域,VoIP(Voice over IP)早已不再是大厂专属的技术。无论是智能门禁对讲、工业调度系统,还是定制化语音客户端,底层都可能跑着一套基于 SIP 协议的通话逻辑。而在这其中,pjsip就像是一位全能选手:轻量、高效、跨平台,还完全开源。
本文不讲空话,直接带你从环境准备到代码运行,一步步搭建出你的第一个可接打电话的 pjsip 应用。无论你是嵌入式开发者、音视频新手,还是想搞点 IoT 通信的小白,这篇都能让你上手就用。
为什么是 pjsip?它到底强在哪?
市面上做 VoIP 的库不少,比如 Linphone、Asterisk 客户端、甚至 WebRTC。那为啥选 pjsip?
因为它够“底层”又够“高阶”。
- 它不是一个完整 App,而是一个 C 语言写的多媒体通信库;
- 支持完整的 SIP 协议栈 + SDP 媒体协商 + RTP 流处理;
- 内建音频设备抽象层、NAT 穿透(STUN/TURN/ICE)、回声消除(AEC)等关键模块;
- 可以跑在 Linux、Windows、macOS、Android、iOS,甚至是 ARM 开发板上。
更重要的是,你可以控制每一个细节:什么时候发起呼叫、用什么编解码器、是否启用加密、如何处理来电……这种自由度,是很多封装好的 SDK 给不了的。
📌 简单说:如果你不想被“黑盒 SDK”绑架,pjsip 是一条通往自主可控通信系统的捷径。
先搞明白一件事:SIP 到底是怎么打上电话的?
在写代码之前,我们得先理解最核心的机制 ——SIP 是怎么建立一次通话的?
别怕,它不像 HTTP 那么复杂,反而有点像打电话时的对话流程:
你:喂,我想跟你通话。(INVITE) 对方:正在响铃…(180 Ringing) 对方:好,我接了!(200 OK) 你:收到,开始说话吧。(ACK) → 双方进入通话状态整个过程就是三个信令来回:
INVITE→ 发起呼叫200 OK← 对方接听ACK→ 确认连接
媒体流(也就是你的声音)并不走 SIP,而是通过另一个叫RTP的协议独立传输。SIP 只负责“谈妥”双方地址和编码方式(通过 SDP 协商),然后让 RTP 自己去传数据。
所以你可以把 SIP 想象成“订餐电话”,RTP 就是“送外卖的小哥”。
上手第一步:用 pjsua 快速搭起通话框架
pjsip 功能虽多,但我们不需要从零造轮子。它的高层 API ——pjsua,已经把账户管理、呼叫控制、音频处理全都打包好了。
我们只需要四步就能跑通一个基本通话程序:
- 初始化 pjsua 环境
- 设置监听端口(通常是 5060)
- 添加本地 SIP 账户
- 注册回调函数处理来电
下面这段代码,就是你能运行的最小可用版本。复制过去,链接 pjsip 库,就能变成一个可以拨打和接听的终端。
#include <pjsua-lib/pjsua.h> #include <stdio.h> #include <string.h> // 来电自动接听 static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { pjsua_call_setting call_opt; pj_bzero(&call_opt, sizeof(call_opt)); pjsua_call_answer(call_id, 200, &call_opt); } int main() { pjsua_config cfg; pjsua_logging_config log_cfg; // 1. 创建并初始化 pjsua pjsua_create(); pjsua_config_default(&cfg); pjsua_logging_config_default(&log_cfg); log_cfg.level = 4; // 日志级别调高,方便调试 // 2. 注册事件回调 cfg.cb.on_incoming_call = &on_incoming_call; if (pjsua_init(&cfg, &log_cfg, NULL) != PJ_SUCCESS) { puts("初始化失败!"); return -1; } // 3. 启动 TCP 监听(端口 5060) pjsua_transport_config tcfg; pjsua_transport_config_default(&tcfg); tcfg.port = 5060; if (pjsua_transport_create(PJSIP_TRANSPORT_TCP, &tcfg, NULL) != PJ_SUCCESS) { puts("创建传输层失败!"); return -1; } // 4. 添加本地账户(无需注册服务器) pjsua_acc_config acc_cfg; pjsua_acc_config_default(&acc_cfg); acc_cfg.id = pj_str("sip:1234@192.168.1.100"); // 本机身份 acc_cfg.reg_uri = pj_str("sip:192.168.1.100"); // 注册地址(这里仅示意) acc_cfg.cred_count = 0; // 不需要认证 if (pjsua_acc_add(&acc_cfg, PJ_TRUE, NULL) != PJ_SUCCESS) { puts("添加账户失败!"); return -1; } // 5. 启动 pjsua 核心 if (pjsua_start() != PJ_SUCCESS) { puts("启动失败!"); return -1; } // 6. 主循环:支持手动拨号 char input[10]; printf("输入 'q' 退出,'c' 拨打 sip:5678@192.168.1.101:\n"); while (scanf("%s", input)) { if (input[0] == 'q') break; else if (input[0] == 'c') { pjsua_call_setting call_opt; pjsua_call_setting_default(&call_opt); pjsua_call_make_call(0, &pj_str("sip:5678@192.168.1.101"), &call_opt, NULL, NULL, NULL); printf("已拨打...\n"); } } // 清理资源 pjsua_destroy(); return 0; }✅ 这段代码能做什么?
- 在
192.168.1.100:5060监听 SIP 请求; - 收到来电自动接听;
- 输入
c可向sip:5678@192.168.1.101发起呼叫; - 使用标准输入交互,适合测试验证。
💡 注意:这里的 IP 地址要换成你真实的局域网 IP。两台机器各跑一个实例,改一下账号 ID 和目标地址,就可以互相打了!
让声音真正传起来:音频配置不能少
现在信令通了,但你还听不到声音 —— 因为音频设备还没打开。
默认情况下,pjsua 会尝试使用系统的默认录音和播放设备。但在某些平台(尤其是 Linux 的 ALSA),可能需要手动指定。
如何查看可用音频设备?
可以用这个小技巧:
int dev_count = pjsua_get_snd_dev_count(); printf("发现 %d 个音频设备\n", dev_count); for (int i = 0; i < dev_count; ++i) { pjmedia_aud_dev_info info; pjsua_enum_aud_devices(i, &info); printf("[%d] %s\n", i, info.name); }然后选择合适的输入输出设备:
pjsua_set_snd_dev(0, 0); // 输入设备索引, 输出设备索引启用回声消除(AEC),告别“嗡嗡”声
如果没有 AEC,扬声器的声音会被麦克风重新拾取,形成反馈环路,导致刺耳的啸叫或低频嗡鸣。
pjsip 内建了回声消除模块,只需一行配置即可开启:
pjsua_media_config med_cfg; pjsua_media_config_default(&med_cfg); med_cfg.ec_tail_len = 200; // 回声延迟最长 200ms pjsua_media_modify_config(&med_cfg);这个参数很关键:
- 太小 → 消不干净;
- 太大 → 占 CPU,增加延迟。
建议从100~200ms开始试,根据实际环境调整。
局域网能通,外网打不了?别忘了 NAT 穿透!
你在家里开发得好好的,一上线就“单通”、“无法注册”?大概率是 NAT 搞的鬼。
大多数设备都在路由器后面,公网 IP 并不直接暴露。这时候就需要STUN/TURN/ICE来帮忙打通路径。
STUN:告诉我我在公网上的真实地址
STUN 服务器的作用很简单:你问它,“我从外面看长什么样?” 它告诉你:“你是x.x.x.x:54321”。
pjsip 中启用 STUN 极其简单:
pjsua_transport_config tcfg; pjsua_transport_config_default(&tcfg); tcfg.port = 5060; tcfg.stun_host = pj_str("stun.l.google.com:19302"); // Google 免费 STUN pjsua_transport_create(PJSIP_TRANSPORT_UDP, &tcfg, NULL);一旦启用,所有 SIP 和 RTP 的地址都会替换成公网映射地址,大幅提升穿透成功率。
ICE:多个备选路线,自动选最优
ICE 更进一步,它会收集多种候选地址(本地 IP、NAT 映射、TURN 中继),然后挨个测试连通性,选出最佳路径。
启用 ICE 也很方便:
pjsua_var.media_cfg.ice_cfg.enable_ice = PJ_TRUE;不过要注意:如果要用 TURN 中继(即媒体走服务器转发),你需要自己部署或租用 TURN 服务(如 Coturn)。
实战中常见的坑,我都替你踩过了
刚上手 pjsip,总会遇到一些奇怪问题。下面是几个高频“翻车现场”及应对方案:
| 问题 | 表现 | 解决方法 |
|---|---|---|
| 只能听见对方,自己说不了话 | 单通 | 检查防火墙是否放行 RTP 端口(通常 4000–5000);启用 STUN |
| 双方都说不了话 | 媒体不通 | 检查 SDP 协商结果是否一致;确认编解码器匹配 |
| 来电无提示 | INVITE 没收到 | 查看 SIP 是否绑定正确端口;抓包看是否有 UDP 到达 |
| 声音断续、卡顿 | 抖动严重 | 调大 Jitter Buffer:med_cfg.jb_max = 200(单位 ms) |
| 日志全是乱码或看不懂 | 信息太多 | 设置log_cfg.console_level = 3,只看关键消息 |
🔍 推荐调试手段:用 Wireshark 抓 SIP 和 RTP 包,看信令交换是否完整,RTP 时间戳是否连续。
更进一步:这些设计经验值得收藏
当你能把两个终端打通之后,就可以考虑更贴近生产环境的设计了。
✅ 日志分级管理
开发阶段开到 level 4 或 5,能看到完整的 SIP 消息头;上线后降到 2~3,避免日志爆炸。
✅ 编解码器优先级调整
默认 PCMU(G.711u)优先,但如果带宽紧张,可以把 OPUS 或 G.729 提前:
// 示例:提升 OPUS 优先级 pjsua_codec_set_priority(&pj_str("opus/48000/2"), 255);✅ 异常恢复机制
监听on_call_media_state和on_call_state_changed,在网络中断后尝试重连或重拨。
✅ 移动端优化
- 启用 VAD(静音检测)降低功耗;
- 处理来电时申请音频焦点;
- 后台运行时切换到低带宽编码模式。
✅ 安全加固(重要!)
- 信令走 TLS:
PJSIP_TRANSPORT_TLS - 媒体走 SRTP:设置
srtp_use = PJMEDIA_SRTP_MANDATORY - 避免明文密码存储,使用摘要认证
结语:你的第一个 VoIP 应用已经诞生
看到这里,你应该已经有了一个能拨打电话的 pjsip 程序,也许还不够完美,但它已经具备了 VoIP 的所有核心能力:
- SIP 信令控制
- SDP 媒体协商
- RTP 音频传输
- NAT 穿透支持
- 回声消除与抖动缓冲
下一步,你可以尝试:
- 加入视频通话(pjsip 也支持 H.264)
- 实现即时消息(MESSAGE 方法)
- 接入 WebRTC 客户端(通过 B2BUA 模式)
- 打造自己的软交换调度系统
pjsip 的魅力就在于:它既是一个工具,也是一个教科书式的通信架构范例。每读一段代码,你都在深入理解实时通信的本质。
如果你也想摆脱“调 SDK”的被动局面,不妨从今天开始,亲手敲下属于你自己的第一行 VoIP 代码。
💬欢迎在评论区分享你的实现体验:你是在树莓派上跑的吗?遇到了哪些奇葩问题?我们一起解决!