☰
eXosip与ffmpeg组合:命令行SIP客户端信令媒体分离实践
2026/10/18 12:12:46 网站建设 项目流程

简介:针对SIP视频通话中接入IP摄像头作为视频源的需求,该方案借助轻量级eXosip库处理SIP信令,音视频部分则先用ffmpeg与ffplay命令行完成测试验证,无需修改pjsip庞大源码,提供了一种更灵活的实现思路。压缩包共789个文件,约23.22MB,内容以C/C++源码、头文件及构建脚本为主,辅以大量SIP/SDP测试用例、文档与配置文件,覆盖从信令交互到音视频测试的完整链路。目前已有589人学习下载。对于希望绕过重量级协议栈、快速搭建SIP客户端验证环境的开发者,这份资料提供了可直接参考的工程组织方式、命令行调用思路及关键排错素材,具备较强的实践参考价值。

1. 用 eXosip 管信令、ffmpeg 管媒体:命令行 SIP 客户端拆解

做 SIP 设备联调的时候,最烦的不是信令流程跑不通,而是要快速验证一台终端能不能注册、能不能把语音或视频送到对端。很多项目里并不需要一个完整的话机界面,只需要一个能发 INVITE、能收 RTP、能验证媒体通路的命令行工具。标题里这个组合——eXosip 处理 SIP 信令,ffmpeg 和 ffplay 负责媒体收发——正好是干这件事最顺手的方案。它把信令和媒体分成两个互不干扰的进程,信令面用 C 代码控制,媒体面直接用 ffmpeg 推流、ffplay 播放,省掉了自己写音频采集、编码、RTP 打包的时间。适合做 SIP 网关联调、自动化拨测、嵌入式前端验证的人。这篇就按我实际做过的路子,把注册、呼叫、媒体对接和排查一步一步拆开。

2. 信令与媒体分工:为什么把 RTP 甩给 ffmpeg 和 ffplay

2.1 信令与媒体的职责边界

SIP 协议本身只负责会话的建立、修改和拆除,真正的声音和画面走的是 RTP。SIP 信令默认走 UDP 5060,RTP 走的是动态端口,两边是完全独立的传输通道。eXosip 是基于 osip2 的 SIP 协议栈封装,它帮你处理了事务状态机、鉴权摘要、重传和超时,你要做的就是在事件循环里响应各种事件。但 eXosip 不碰音视频,也不能替你做编码解码。

媒体这块,ffmpeg 的 rtp muxer 可以直接把音频流打包成 RTP 发送,ffplay 也能直接读 RTP 流并播放。这意味着你不需要在自己的程序里引入任何音视频库,不需要管 ALSA、OSS、Windows 音频设备的差异,也不用手写 RTP 封包和抖动缓冲。常见做法是让 eXosip 的程序只管 SIP 状态,把媒体进程交给命令行工具去拉起来。这样做还有个额外好处:ffmpeg 进程崩了,信令进程还活着,可以直接重新拉起媒体,不会导致整个客户端退出。

2.2 SDP 是从信令到媒体的交接单

信令和媒体之间的交接依赖 SDP。INVITE 请求的 body 里带一段 application/sdp,里面写着媒体类型、编码、端口和 IP。比如一段最简单的音频 SDP 长这样:

v=0 o=- 0 0 IN IP4 192.168.1.100 s=Call t=0 0 m=audio 10000 RTP/AVP 0 c=IN IP4 192.168.1.100 a=rtpmap:0 PCMU/8000

这里m=audio 10000表示本端准备在 10000 端口接收音频 RTP,c=IN IP4 192.168.1.100是本端 IP,a=rtpmap:0 PCMU/8000声明用的是 G.711 的 PCMU 编码。对方收到这段 SDP 之后,就知道该往哪里推 RTP,也知道了用什么编码。

这块是很多人第一次做时绕不过去的弯:SDP 里写的 IP 和端口,是“我自己接收媒体的地址”,不是“我要往哪发”。做信令对接的时候,INVITE 里带的是自己的接收地址,200 OK 里回的也是对方的接收地址。理解了这一点,后面的 ffplay 和 ffmpeg 参数就不容易搞反。我一般把 SDP 当成一张交接单:信令进程负责把它送到对方手里,媒体进程按它去收发 RTP。

2.3 环境准备:编译库与 RTP 能力确认

开始之前先确认环境。eXosip 的程序编译需要 libeXosip2 和 libosip2 的开发包,安装好之后写一个最小编译命令像这样:

gcc -o sip_client sip_client.c -leXosip2 -losip2

如果头文件路径不在默认目录,需要加-I指向 eXosip2 的 include 路径。不同发行版的库名后缀略有差异,编译报cannot find -leXosip2的时候先ldconfig -p | grep eXosip看看实际库名。

媒体侧要确认 ffmpeg 和 ffplay 是否支持 RTP 输入输出。ffmpeg 的 rtp muxer 默认编译进主流发行版,确认命令是:

ffmpeg -h muxer=rtp ffplay -protocols | grep rtp

ffmpeg -h muxer=rtp能看到-sdp_file和-payload_type选项,说明这个版本支持 RTP 输出。ffplay -protocols里出现 rtp 和 udp,说明可以读 RTP 流。这两个检查都在几秒内能完成,后面所有脚本都依赖它们。

3. 从注册到事件循环:eXosip 最小 C 程序怎么搭

3.1 注册流程:初始化、监听地址与 Register 请求

eXosip 的程序骨架非常固定:初始化、设置监听地址、构造注册请求、发出去,然后进事件循环。我一般把注册写成一个函数,里面处理从构造到发送的完整链路。下面是一段能跑通注册的最小代码:

#include <eXosip2/eXosip.h> #include <osip2/osip_mt.h> #include <stdio.h> #include <string.h> #include <unistd.h> #define LOCAL_IP "192.168.1.100" /* 本机实际IP,不要写0.0.0.0 */ #define SIP_SERVER "192.168.1.200" /* 对端SIP服务器或对端IP */ #define SIP_USER "1001" #define SIP_PASSWORD "1234" #define LOCAL_SIP_PORT 5060 int main(void) { int res = eXosip_init(); if (res != 0) { fprintf(stderr, "eXosip_init failed: %d\n", res); return -1; } res = eXosip_listen_addr(eXosip_get_context(), "0.0.0.0", LOCAL_SIP_PORT, IPPROTO_UDP, 0); if (res != 0) { fprintf(stderr, "listen_addr failed: %d\n", res); return -1; } /* 构造REGISTER请求 */ osip_message_t *reg = NULL; char from[128], to[128], contact[128]; snprintf(from, sizeof(from), "sip:%s@%s", SIP_USER, SIP_SERVER); snprintf(to, sizeof(to), "sip:%s@%s", SIP_USER, SIP_SERVER); snprintf(contact, sizeof(contact), "<sip:%s@%s:%d>", SIP_USER, LOCAL_IP, LOCAL_SIP_PORT); eXosip_register_build_initial_register(eXosip_get_context(), &reg, from, to, contact); if (reg == NULL) { fprintf(stderr, "build register failed\n"); return -1; } /* 带上摘要鉴权信息 */ eXosip_register_set_credentials(eXosip_get_context(), SIP_USER, SIP_USER, SIP_PASSWORD, NULL, SIP_SERVER); eXosip_lock(eXosip_get_context()); eXosip_register_send_register(eXosip_get_context(), reg); eXosip_unlock(eXosip_get_context()); printf("REGISTER sent, waiting events...\n"); /* 事件循环,注册成功后不会再额外做处理 */ for (;;) { eXosip_event_t *ev = eXosip_event_wait(eXosip_get_context(), 0, 50); if (ev == NULL) continue; switch (ev->type) { case EXOSIP_REGISTRATION_SUCCESS: printf("[event] registration success\n"); break; case EXOSIP_REGISTRATION_FAILURE: printf("[event] registration failure, reason: %s\n", ev->textinfo ? ev->textinfo : "unknown"); break; default: break; } eXosip_event_free(ev); } return 0; }

这段代码有几点值得说明。eXosip_listen_addr的0.0.0.0是监听所有网卡的 SIP 信令端口,与 SDP 里写的媒体地址无关,所以这里写通配没问题。eXosip_register_build_initial_register的 from 和 to 通常都填账号的 sip URI,contact 填本机实际 IP 和端口,这个 contact 会被写进 REGISTER 的 Contact 头,服务器后续的来电会按它转发。eXosip_register_set_credentials必须在发送前调用,否则遇到 401 时程序不会自动带鉴权信息重发。

3.2 事件循环是媒体进程的调度入口

eXosip 是一个事件驱动的协议栈,eXosip_event_wait会阻塞等待 SIP 事件返回。这里传入0, 50表示超时 50 毫秒,保证循环能及时响应外部信号。事件类型非常多,但对命令行客户端来说,核心就是四个:注册成功失败、收到 INVITE、收到应答、收到 BYE。

媒体进程的拉起动作,全部嵌在这些事件的 case 分支里。比如注册成功后,说明终端在服务器上在线了,可以等待被叫;收到 INVITE 时,要把对方的 SDP 解析出来,拉起 ffplay 和 ffmpeg;收到 200 OK 应答时,说明主叫侧可以启动推流进程了;收到 BYE 时,把所有媒体子进程杀掉。事件循环里不适合做耗时操作,因为 SIP 的重传和超时计时都在协议栈内部跑,你在这阻塞太久,会导致应该回 200 OK 的消息延迟发送。

3.3 注册失败的常见现象与初步定位

注册失败最容易看到的三类现象。第一类是REGISTRATION_FAILURE且 textinfo 里有 401,说明服务器要求鉴权,但程序没有正确携带 Authorization 头,检查eXosip_register_set_credentials是否在发送前调用,用户名和密码是否和服务器配置一致。第二类是 403 Forbidden,多半是服务器限制了 IP 白名单,或者账号被禁用。第三类是发出 REGISTER 后长时间没任何回包,多半是 UDP 包被防火墙丢了,先用 tcpdump 抓本机 5060 端口确认请求是否真的发出去。

抓包命令我常用这一条,看信令和媒体都靠它:

tcpdump -i any -s 0 -nn -A udp port 5060 or udp portrange 10000-10010

-A能直接看到 SIP 文本内容,媒体端口范围自己按实际来。注册这一步的筛选标准只有一个:事件循环里稳定打印出registration success,之后再做呼叫流程才有意义。

4. 打通一次呼叫:SDP 交接、ffmpeg 推流与 ffplay 播放

4.1 主叫侧:先按自己的接收地址写 SDP,再发 INVITE

最稳的做法是把 SDP 分成“发送给对方的”和“ffplay 自己读的”两份,内容一致,但用途不同。主叫侧先确定一个偶数端口作为媒体接收端口,比如 10000,写一个本地接收 SDP 存成文件:

v=0 o=- 0 0 IN IP4 192.168.1.100 s=Call t=0 0 m=audio 10000 RTP/AVP 0 c=IN IP4 192.168.1.100 a=rtpmap:0 PCMU/8000 a=sendrecv

把这个文件保存为/tmp/recv.sdp,随后启动 ffplay 监听这个端口:

ffplay -nodisp -protocol_whitelist "file,rtp,udp" -i /tmp/recv.sdp \ >/dev/null 2>&1 &

-nodisp表示不弹窗口,纯命令行模式适合调试;-protocol_whitelist "file,rtp,udp"是必须加的,新版 ffplay 默认白名单不包含 rtp 和 udp,不加会直接报错退出。这里的核心逻辑是:ffplay 读的是“我自己的接收 SDP”,它的 m 行端口就是我监听 RTP 的端口。然后把这同一段 SDP 塞进 INVITE 的 body,发给对端,对端看到这条 SDP 就知道该向哪里推流。

代码里构造 INVITE 的示意:

osip_message_t *invite = NULL; char sdp_body[512]; /* 从 /tmp/recv.sdp 读入 sdp_body */ eXosip_call_build_initial_invite(eXosip_get_context(), &invite, "sip:1002@192.168.1.200", NULL, NULL, NULL); osip_message_set_content_type(invite, "application/sdp"); osip_message_set_body(invite, sdp_body, strlen(sdp_body)); eXosip_lock(eXosip_get_context()); eXosip_call_send_initial_invite(eXosip_get_context(), invite); eXosip_unlock(eXosip_get_context());

发送之后,主叫进入等待事件状态。收到EXOSIP_CALL_ANSWERED事件时,对返回的 200 OK 里的 SDP 做解析,拿到对方接收媒体的 IP 和端口。这段逻辑熟练的人会直接手写字符串查找,用strstr找c=IN IP4和m=audio:

char *c_line = strstr(answer_sdp, "c=IN IP4"); char *m_line = strstr(answer_sdp, "m=audio"); char peer_ip[64]; int peer_port = 0; if (c_line) sscanf(c_line, "c=IN IP4 %63s", peer_ip); if (m_line) sscanf(m_line, "m=audio %d", &peer_port);

4.2 被叫侧:先起 ffplay 再回 200,媒体时钟才赶得上

被叫侧流程是反过来的,但思路完全相同。收到EXOSIP_CALL_INVITE后,先把 INVITE 里的 SDP 存下来,同时准备自己的接收 SDP。被叫也要选定自己的媒体端口,比如 10002,写本地接收 SDP 并启动 ffplay 监听,然后构造 200 OK 应答,把本地接收 SDP 作为 body 回给主叫。

先回 200 还是先起 ffplay?我习惯先启动 ffplay 再发 200。原因是主叫收到 200 OK 后会立刻启动 ffmpeg 推流,如果被叫的 ffplay 还没就位,RTP 的前几百毫秒就丢了。测试环境里无伤大雅,但遇到对端检测媒体超时的设备,晚这半秒就可能被判为呼叫失败。

被叫收到 INVITE 后在事件循环里做的事情:

case EXOSIP_CALL_INVITE: /* 1. 保存对方SDP到文件,日志留底 */ /* 2. 写自己的接收SDP /tmp/recv.sdp,端口10002 */ /* 3. system(): 启动ffplay读取 /tmp/recv.sdp */ /* 4. 构造200 OK,body填自己的接收SDP */ /* 5. eXosip_call_send_answer */ /* 6. 解析INVITE中对方的c=和m=,启动ffmpeg推流 */ break;

关键在第 4 步。200 OK 的 SDP 必须回自己的接收地址,如果照抄主叫的 IP 和端口,两边就会互相往对方的 10000 推流,结果一方端口没人监听,媒体必断。

4.3 ffmpeg 推流:编码、采样率与 RTP 目标

被叫解析出主叫的媒体地址后,启动 ffmpeg 向主叫推音频流。常用的是 lavfi 虚拟音频源,不需要真实麦克风就能验证链路:

ffmpeg -re -f lavfi -i "sine=frequency=1000:sample_rate=8000" \ -c:a pcm_mulaw -ar 8000 -payload_type 0 -f rtp \ rtp://192.168.1.100:10000 >/dev/null 2>&1 &

参数含义拆开说。-re让输入按实时速率读取,避免 lavfi 源瞬间把数据全部压给编码器;-f lavfi -i "sine=frequency=1000:sample_rate=8000"是 ffmpeg 内置的正弦波发生器,做音频链路测试非常合适;-c:a pcm_mulaw指定 G.711 编码;-ar 8000是采样率,PCMU 固定 8000,不能写成 48000,否则对端播放会变调;-payload_type 0强制 RTP payload type 为 0,和 SDP 里的a=rtpmap:0 PCMU/8000严格对应。最后的rtp://192.168.1.100:10000是对端主叫的接收地址。

视频测链路同理,用 testsrc 源能省掉摄像头设备:

ffmpeg -re -f lavfi -i "testsrc=size=640x480:rate=25" \ -c:v mpeg4 -f rtp rtp://192.168.1.100:10004 \ -payload_type 96 -sdp_file /tmp/video.sdp

4.4 挂断与子进程清理

收到EXOSIP_CALL_CLOSED或EXOSIP_CALL_BYE后,必须把 ffplay 和 ffmpeg 子进程收掉。用system启动的命令会变成 shell 的子进程,直接pkill ffplay不优雅但有效。更稳的做法是启动时记录 PID:

ffplay -nodisp -protocol_whitelist "file,rtp,udp" -i /tmp/recv.sdp & MEDIA_PID=$! echo $MEDIA_PID > /tmp/ffplay.pid

收尾时用kill $(cat /tmp/ffplay.pid)精确退出,避免误杀其他调试进程。进程回收这个细节看着小,实际做自动化拨测时漏掉一个 ffplay,内存里就会堆积一堆播放进程。

5. SIP 客户端媒体链路排查:信令通了却听不到声的五个典型坑

5.1 SDP 里写了 0.0.0.0,RTP 全被本机防火墙吃掉

现象:SIP 注册、呼叫都正常,200 OK 也收到了,但对端 ffplay 没有任何声音,tcpdump 看不到对端过来的 RTP 包。

原因:构造 SDP 时偷懒把 IP 写成了0.0.0.0或者127.0.0.1。对端按 SDP 里的地址推流,数据包发到 0.0.0.0 直接被系统丢弃,或者发到回环口而 ffplay 监听在物理网卡上。

解决:SDP 里的c=IN IP4永远写本机实际可达的网卡 IP。多网卡设备上尤其注意,要先确认对端路由能通到哪个网卡,再决定写哪个 IP。我调试时习惯先ip addr看一眼当前网卡地址,再生成 SDP 文件,不偷懒。

5.2 payload type 对不上,有流量但全是噪声

现象:tcpdump 能看到 RTP 包在跑,UDP 端口也对,ffplay 窗口在动,但声音完全是炸裂的噪声,或者视频花屏。

原因:ffmpeg 推流时的-payload_type和 SDP 里声明的 rtpmap 不一致。比如 SDP 写a=rtpmap:96 MP4V-ES/90000,但 ffmpeg 默认给动态 PT 分配了别的编号,对端拿着 96 去解实际是别的编码的包,解码器错乱。

解决:RTP 推流命令里显式加-payload_type,保证和 SDP 里的数字一致。PCMU 本身固定是 0,但 H.264 这类动态编码必须手动对齐。这也是我建议推流命令和 SDP 文件在同一次调试里一起生成的原因,不要分开维护。

5.3 端口检查只看有没有包,没看是谁发给谁

现象:两端都说自己在接收,但只有一个方向有声音。用 tcpdump 抓包发现,两边都在往自己的媒体端口发数据。

原因:主叫把自己的接收端口 10000 写进 INVITE,被叫把 INVITE 里的 10000 误当成“我要往里发的地址”,没有解析出真正的主叫接收地址,而是照抄了自己的本地端口。本质还是 SDP 语义理解反了。

解决:每次抓包后先确认源 IP、源端口、目的 IP、目的端口四个字段。主叫发出的 RTP,源地址应是被叫 IP,目的地址应是主叫 IP:10000。只要看到目的端口等于自己发给对方的端口,基本就是方向取反。排查时把 tcpdump 输出里IP >两侧的地址念一遍,很快能定位。

5.4 ffplay 起不来:protocol_whitelist 与音频设备

现象:被叫侧执行 ffplay 命令后立刻退出,终端报错类似Protocol whitelist does not match,或者卡在音频设备上不出声。

原因:新版 ffplay 默认不允许 rtp 和 udp 协议,必须显式加白名单。另外 ffplay 播放 RTP 时如果没指定音频设备,可能默认走 ALSA 的 default 设备,无头服务器上根本没有声卡,进程直接退出。

解决:播放命令固定带上-protocol_whitelist "file,rtp,udp",同时加-nodisp和/dev/null重定向输出。如果确认有声音但设备不对,可以用-audio_device指定设备名,或者干脆先用 lavfi 的 sine 源自测播放链路,区分是 RTP 问题还是声卡问题。

5.5 呼叫断开后媒体进程还在跑,端口被占

现象:第二次呼叫时 ffmpeg 推流报bind: Address already in use,或者 ffplay 打开端口失败,只有第一次呼叫能通。

原因:BYE 事件里只处理了信令,没有杀掉媒体子进程。Shell 后台进程仍然活着,占着 10000 或 10002 端口不释放。

解决:把 PID 管理做成固定习惯。每次启动媒体进程时把 PID 写入/tmp下的文件,BYE 和 CALL_CLOSED 事件里统一读取清理。清理函数写成独立方法,任何退出路径都先调它再继续,能少踩很多次端口占用的坑。

6. 把它变成拨测工具:无人值守呼叫的三个技巧

走到这一步,完整的命令行 SIP 客户端已经能注册、能呼叫、能收发媒体了。再往前一步,这个东西最大的价值不是当电话用,而是当自动化拨测工具用。我自己的项目里是让它每周跑一轮,主动呼叫测试号码,然后用 ffplay 的退出码判断对端是否正常返回了媒体。分享三个让这个方案更耐用的技巧。

第一个技巧是给 ffmpeg 推流加-re和超时控制。-re保证推流速率和真实时间一致,不会把一分钟的音频一秒推完。配合 ffmpeg 的-timeout参数可以控制 RTP 发送时长,跑完自动退出,适合批量拨测场景。

第二个技巧是善用 lavfi 的虚拟音视频源做无硬件依赖的媒体测试。音频用sine,视频用testsrc,不插麦克风、不插摄像头也能完整验证两端媒体链路。实测中这个方案在 CI 环境里跑得很稳,比依赖真实硬件可靠得多。

第三个技巧是给信令和媒体打统一的日志标签。eXosip 的每个事件都有 call_id,打印日志时带上它,同时在启动 ffmpeg 命令时把同样的 call_id 写进命令行的注释或日志文件名。出问题时,把信令日志和媒体日志按 call_id 对接起来,一眼就能看到是哪个环节断了。这个习惯帮我解决过不少“两边都说自己在发,但就是不通”的悬案。

做这套东西半年后我才意识到,命令行 SIP 客户端最不重要的部分是“打电话”,最重要的是把信令流程和媒体流程拆得足够干净,让每一个环节都可以单独验证、单独重试。eXosip 负责理性的状态机,ffmpeg 负责忠实的媒体搬运,你只要盯住 SDP 这一张交接单,就不会迷失。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询