资讯动态

eXosip与ffmpeg组合:命令行SIP客户端信令媒体分离实践

发布时间:2026/10/18 12:12:54 来源:尧图企业网站定制
简介针对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 和 ffplay2.1 信令与媒体的职责边界SIP 协议本身只负责会话的建立、修改和拆除真正的声音和画面走的是 RTP。SIP 信令默认走 UDP 5060RTP 走的是动态端口两边是完全独立的传输通道。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 长这样v0 o- 0 0 IN IP4 192.168.1.100 sCall t0 0 maudio 10000 RTP/AVP 0 cIN IP4 192.168.1.100 artpmap:0 PCMU/8000这里maudio 10000表示本端准备在 10000 端口接收音频 RTPcIN IP4 192.168.1.100是本端 IPartpmap: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 muxerrtp ffplay -protocols | grep rtpffmpeg -h muxerrtp能看到-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 URIcontact 填本机实际 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 存成文件v0 o- 0 0 IN IP4 192.168.1.100 sCall t0 0 maudio 10000 RTP/AVP 0 cIN IP4 192.168.1.100 artpmap:0 PCMU/8000 asendrecv把这个文件保存为/tmp/recv.sdp随后启动 ffplay 监听这个端口ffplay -nodisp -protocol_whitelist file,rtp,udp -i /tmp/recv.sdp \ /dev/null 21 -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:1002192.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找cIN IP4和maudiochar *c_line strstr(answer_sdp, cIN IP4); char *m_line strstr(answer_sdp, maudio); char peer_ip[64]; int peer_port 0; if (c_line) sscanf(c_line, cIN IP4 %63s, peer_ip); if (m_line) sscanf(m_line, maudio %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 OKbody填自己的接收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 sinefrequency1000:sample_rate8000 \ -c:a pcm_mulaw -ar 8000 -payload_type 0 -f rtp \ rtp://192.168.1.100:10000 /dev/null 21 参数含义拆开说。-re让输入按实时速率读取避免 lavfi 源瞬间把数据全部压给编码器-f lavfi -i sinefrequency1000:sample_rate8000是 ffmpeg 内置的正弦波发生器做音频链路测试非常合适-c:a pcm_mulaw指定 G.711 编码-ar 8000是采样率PCMU 固定 8000不能写成 48000否则对端播放会变调-payload_type 0强制 RTP payload type 为 0和 SDP 里的artpmap:0 PCMU/8000严格对应。最后的rtp://192.168.1.100:10000是对端主叫的接收地址。视频测链路同理用 testsrc 源能省掉摄像头设备ffmpeg -re -f lavfi -i testsrcsize640x480:rate25 \ -c:v mpeg4 -f rtp rtp://192.168.1.100:10004 \ -payload_type 96 -sdp_file /tmp/video.sdp4.4 挂断与子进程清理收到EXOSIP_CALL_CLOSED或EXOSIP_CALL_BYE后必须把 ffplay 和 ffmpeg 子进程收掉。用system启动的命令会变成 shell 的子进程直接pkill ffplay不优雅但有效。更稳的做法是启动时记录 PIDffplay -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.0RTP 全被本机防火墙吃掉现象SIP 注册、呼叫都正常200 OK 也收到了但对端 ffplay 没有任何声音tcpdump 看不到对端过来的 RTP 包。原因构造 SDP 时偷懒把 IP 写成了0.0.0.0或者127.0.0.1。对端按 SDP 里的地址推流数据包发到 0.0.0.0 直接被系统丢弃或者发到回环口而 ffplay 监听在物理网卡上。解决SDP 里的cIN IP4永远写本机实际可达的网卡 IP。多网卡设备上尤其注意要先确认对端路由能通到哪个网卡再决定写哪个 IP。我调试时习惯先ip addr看一眼当前网卡地址再生成 SDP 文件不偷懒。5.2 payload type 对不上有流量但全是噪声现象tcpdump 能看到 RTP 包在跑UDP 端口也对ffplay 窗口在动但声音完全是炸裂的噪声或者视频花屏。原因ffmpeg 推流时的-payload_type和 SDP 里声明的 rtpmap 不一致。比如 SDP 写artpmap: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 这一张交接单就不会迷失。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑