从零开始用 pjsip 打造一个能打电话的软电话:实战全记录
你有没有想过,自己动手写一个可以拨打电话的“软电话”?不是模拟器,不是调用系统 API,而是真正通过 SIP 协议注册到服务器、拨打号码、听到对方声音的那种。
听起来很复杂?其实没那么玄。今天我们就用pjsip——这个被无数 VoIP 产品背书的开源通信库,手把手带你从零构建一个可运行的软电话原型。不需要任何前置知识,只要你懂一点 C 语言,就能跑通第一通电话。
为什么选 pjsip?
市面上做 VoIP 的方案不少:WebRTC、Linphone、Asterisk 客户端……但如果你想要的是对底层完全可控、性能高、资源占用小、还能跑在嵌入式设备上的解决方案,那 pjsip 几乎是唯一的选择。
它是纯 C 写的,跨平台支持极强(Windows/Linux/macOS/Android/iOS/裸机 RTOS 都行),代码结构清晰,文档虽然略硬核,但一旦摸清套路,开发效率非常高。
更重要的是:它不是一个玩具项目。像 Cisco、Polycom、Zoom 的某些模块,甚至一些工业级语音网关,背后都有 pjsip 的影子。
我们今天要做的,就是把它最核心的能力——注册、拨号、接电话、传音频——拆开讲透,让你不仅能跑起来,还能知道每一步到底发生了什么。
先搞清楚:软电话是怎么工作的?
别急着敲代码,先搞明白逻辑。
一个软电话的本质,其实是两个部分:
- SIP 信令控制:负责“打招呼”、“发起通话”、“挂断”这些动作。
- RTP 媒体传输:真正把你的声音打包发出去,把对方的声音收回来播放。
你可以把 SIP 想象成打电话前的“对话”:
“喂,我在吗?”
“在的。”
“我想和你通话。”
“好啊,我这边听筒准备好了,你发声音过来吧。”
等这番“握手”完成,双方就打开 RTP 通道,开始传语音数据了。
而 pjsip 的厉害之处在于:它把这两块全都包圆了。你只需要调几个 API,剩下的——编解码、回音消除、抖动缓冲、NAT 穿透——它都给你处理好了。
第一步:初始化 pjsip 运行环境
所有操作的前提是“启动引擎”。就像开车前要点火,我们必须先初始化 pjsip 的核心运行时。
#include <pjsua-lib/pjsua.h> static pjsua_config cfg; static pjsua_logging_config log_cfg; pj_status_t initialize_pjsip(void) { pj_status_t status; // 1. 创建 UA(User Agent)实例 status = pjsua_create(); if (status != PJ_SUCCESS) return status; // 2. 设置日志级别,方便调试 pjsua_logging_config_default(&log_cfg); log_cfg.level = 4; // 输出详细信息 // 3. 初始化默认配置 pjsua_config_default(&cfg); cfg.max_calls = 4; // 最多支持4路并发通话 cfg.stun_host = pj_str("stun.l.google.com:19302"); // 使用 Google 的 STUN 服务 // 4. 正式初始化 UA 核心 status = pjsua_init(&cfg, &log_cfg, NULL); if (status != PJ_SUCCESS) return status; // 5. 创建 UDP 传输层,监听 5060 端口 pjsua_transport_config tp_cfg; pjsua_transport_config_default(&tp_cfg); tp_cfg.port = 5060; status = pjsua_transport_create(PJSIP_TRANSPORT_UDP, &tp_cfg, NULL); if (status != PJ_SUCCESS) return status; // 6. 启动事件调度器 status = pjsua_start(); if (status != PJ_SUCCESS) return status; return PJ_SUCCESS; }这段代码看着多,其实就干了六件事:
- 创建用户代理(UA)
- 打开日志开关
- 设定最大通话数、启用 STUN(解决 NAT 问题的关键)
- 初始化核心模块
- 绑定 UDP 5060 端口接收 SIP 消息
- 启动事件循环
其中pjsua_start()是关键,它会拉起后台线程处理网络 IO 和定时任务。之后所有的状态变化都会通过回调通知你。
第二步:添加账号并注册到服务器
光有引擎不行,你还得有个“身份”才能打电话。这就是 SIP 账号。
假设你有一个 VoIP 服务商提供的账户:
- URI:sip:alice@sip.provider.com
- 用户名:alice
- 密码:secret123
- 域名:sip.provider.com
我们要把这些信息告诉 pjsip,并让它自动去注册。
pjsua_acc_id add_sip_account(const char *sip_uri, const char *username, const char *password, const char *domain) { pjsua_acc_config acc_cfg; pjsua_acc_id acc_id; pjsua_acc_config_default(&acc_cfg); acc_cfg.id = pj_str((char*)sip_uri); // 完整标识 acc_cfg.reg_uri = pj_str((char*)domain); // 注册地址 acc_cfg.cred_count = 1; acc_cfg.cred_info[0].realm = pj_str("*"); acc_cfg.cred_info[0].scheme = pj_str("digest"); acc_cfg.cred_info[0].username = pj_str((char*)username); acc_cfg.cred_info[0].data_type = PJSIP_CRED_DATA_PLAIN; acc_cfg.cred_info[0].data = pj_str((char*)password); acc_cfg.reg_timeout = 300; // 每5分钟重注册一次 pj_status_t status = pjsua_acc_add(&acc_cfg, PJ_TRUE, &acc_id); if (status != PJ_SUCCESS) { PJ_LOG(1, ("SOFTPHONE", "注册失败: %s", pjsip_strerror(status, NULL, 0))); return PJSUA_INVALID_ID; } return acc_id; }重点看这一句:
pjsua_acc_add(&acc_cfg, PJ_TRUE, &acc_id);第二个参数传PJ_TRUE表示:加完立即注册。否则你得手动调pjsua_acc_set_registration()才能触发。
注册成功后,你会看到日志里出现Registration successful,这时候你的软电话就算“上线”了。
第三步:拨打电话
注册好了,就可以打了。
比如你想打给bob@sip.provider.com,只需一行核心调用:
pjsua_call_id make_outgoing_call(pjsua_acc_id acc_id, const char *dst_uri) { pj_status_t status; pjsua_call_id call_id; pjsua_msg_data msg_data; pjsua_msg_data_init(&msg_data); pj_str_t uri = pj_str((char*)dst_uri); status = pjsua_call_make_call(acc_id, &uri, 0, NULL, &msg_data, &call_id); if (status != PJ_SUCCESS) { PJ_LOG(1, ("SOFTPHONE", "呼叫失败: %s", pjsip_strerror(status, NULL, 0))); return PJSUA_INVALID_ID; } return call_id; }就这么简单?没错。
pjsip 会在背后自动生成 INVITE 请求,附带 SDP 描述本地支持的编码格式(如 PCMU、PCMA、OPUS 等),然后发给对方。
如果对方接受,就会回 200 OK + 它的 SDP,接着你再发 ACK 确认,RTP 通道建立,语音就开始传输了。
整个过程完全由库内部管理,你只需要关注“什么时候拨出”、“什么时候挂断”。
第四步:接听来电
别人打给你怎么办?靠回调。
你需要提前注册一系列事件处理器,其中最重要的就是on_incoming_call。
void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { pjsua_call_info ci; pjsua_call_get_info(call_id, &ci); PJ_LOG(3, ("SOFTPHONE", "来电!来自 %.*s", (int)ci.remote_info.slen, ci.remote_info.ptr)); // 自动接听(实际项目中可弹窗提示) pjsua_call_answer(call_id, 200, NULL, NULL); }当然,完整的应用还需要实现其他回调,比如:
on_call_state():监控通话状态(振铃、已连接、结束等)on_call_media_state():当媒体通道准备好时,启动音频流
void on_call_media_state(pjsua_call_id call_id) { pjsua_call_info ci; pjsua_call_get_info(call_id, &ci); if (ci.media_status == PJSUA_CALL_MEDIA_ACTIVE) { // 媒体已激活,连接到默认音频设备 pjsua_conf_connect(ci.conf_slot, 0); // 输入(麦克风)→ 播放(扬声器) pjsua_conf_connect(0, ci.conf_slot); // 反向也连上 } }这两条conf_connect很关键,相当于把“麦克风”和“扬声器”接到这次通话的混音槽位上。不连这个,就算通了也听不到声音。
第五步:搞定音频设备
很多人第一次跑 demo 发现“能通但没声音”,八成是音频设备没配对。
pjsip 提供了简单的接口来设置录音和播放设备:
pj_status_t setup_audio_device(int capture_id, int playback_id) { pj_status_t status; status = pjsua_set_snd_dev(capture_id, playback_id); if (status != PJ_SUCCESS) return status; // 查看可用设备列表 pjsua_snd_dev_info dev_info[PJMEDIA_SOUND_MAX_DEVS]; unsigned count = PJ_ARRAY_SIZE(dev_info); pjsua_enum_snd_devs(dev_info, &count); for (unsigned i = 0; i < count; ++i) { PJ_LOG(4, ("AUDIO", "[%d] %s (%s%s)", i, dev_info[i].name, dev_info[i].input_count ? "in" : "", dev_info[i].output_count ? ",out" : "")); } return PJ_SUCCESS; }建议首次运行时打印设备列表,确认哪个 ID 对应你的麦克风和扬声器。Windows 上常用的是 PortAudio 或 WASAPI,Linux 上是 ALSA。
另外,强烈建议开启 AEC(回音消除):
// 在配置中启用 AEC cfg.ec_options = PJMEDIA_ECHO_SIMPLE; // 或使用更高级的 SUPPRESSOR不然你在免提状态下会听到自己的回声,体验极差。
实际工作流程拆解
让我们串一遍完整流程:
- 调用
initialize_pjsip()→ 启动 pjsip 引擎 - 调用
add_sip_account()→ 发送 REGISTER 到服务器 - 收到 200 OK → 显示“已上线”
- 用户点击“拨打” → 调
make_outgoing_call() - pjsip 发 INVITE + SDP → 对方回应 200 OK + SDP
- 发 ACK → 建立 RTP 流
on_call_media_state触发 → 连接音频设备- 麦克风采集 PCM → 编码 → 封装 RTP 包 → UDP 发送
- 接收远端 RTP 包 → 解码 → 送扬声器播放
- 用户点“挂断” → 发 BYE → 会话结束
全程异步驱动,无需轮询,CPU 占用很低。
常见坑点与应对策略
❌ 注册失败(Timeout / 403 Forbidden)
- 检查用户名密码是否正确
- 确认防火墙是否放行 UDP 5060 和随机 RTP 端口(通常 10000~20000)
- 如果在公司内网,尝试启用 STUN:
cfg.stun_host = pj_str("stun.l.google.com:19302");
🔊 来电无声或单通
- 检查 SDP 是否协商出共同支持的编解码器(如 PCMU)
- 确保
on_call_media_state中正确连接了 conf_slot - 查看 NAT 类型,如果是对称型 NAT,需部署 TURN 服务器辅助穿透
🗣 回音严重
- 必须启用 AEC:
cfg.ec_options = PJMEDIA_ECHO_SUPPRESSOR; - 调低麦克风增益,避免啸叫
- 使用耳机而非外放,物理隔离反馈路径
🐢 延迟高、卡顿
- 检查网络延迟和丢包率
- 增大 Jitter Buffer:
pjmedia_jbuf_set_delay(target_ms) - 优先使用 OPUS 编码,适应弱网环境
工程最佳实践建议
不要阻塞事件线程
所有回调函数应尽快返回,耗时操作扔到独立线程处理。使用 FSM 管理呼叫状态
把“空闲”、“拨号中”、“振铃”、“通话中”、“挂断中”做成状态机,防止非法操作。预分配内存池
pjsip 使用固定大小内存池,避免动态分配影响实时性。可在初始化时加大 pool size。分级日志输出
开发期设level=5,生产环境降为level=2,减少 I/O 开销。优先选用 OPUS 编码
相比 G.711(64kbps),OPUS 可在 20kbps 下保持高清语音,节省带宽。
结语:这只是个开始
我们现在已经有了一个能注册、能拨号、能接听、能传声音的软电话原型。虽然界面简陋,但它已经具备了 VoIP 终端的核心能力。
接下来你可以继续扩展:
- 加入 GUI(Qt / Dear ImGui)
- 支持视频通话(利用 pjsip 的视频模块)
- 实现 IM 文本消息
- 添加 DTMF 按键推送
- 构建多方会议桥接器
而这一切的基础,都始于今天我们走过的这条路。
pjsip 不是最快的入门工具,也不是最容易上手的框架,但它足够强大、足够稳定、足够灵活。一旦掌握,你就拥有了构建专业级通信系统的钥匙。
所以,别再只停留在“听说”了。现在就去下载 pjsip 源码,编译它,运行它,改它,让它为你所用。
毕竟,真正的理解,永远来自亲手敲下的每一行代码。
如果你在搭建过程中遇到具体问题,欢迎留言交流——我们一起 debug。