简介:在VoIP和音视频通信开发中,SIP协议负责会话建立与拆除,RTP协议承载音频数据流,二者共同构成IP通话的核心链路。传统图形化软电话将信令与媒体封装在一起,出现问题难以快速定位,尤其对协议栈开发和自动化测试工程师而言,需要可拆分、可脚本化的终端工具。基于eXosip的SIP协议栈处理注册、呼叫与事务状态,结合ffmpeg做音频编码与RTP推流,再用ffplay拉流播放,即可用纯命令行拼装出极简SIP客户端。该方案将复杂通话链路拆解为可独立验证的模块:信令故障与媒体故障互不干扰,便于排查注册失败、RTP丢包、编解码不匹配等常见问题。适用场景包括SIP网关测试、VoIP自动化验证以及ffmpeg音视频采集接入SIP环境,是工程实践中易上手且灵活的调试路径。
1. 为什么要用命令行拼出一个 SIP 客户端
做 VoIP 调试的时候,最烦的事情不是协议栈报错,而是图形界面客户端把信令和媒体处理裹在一起,出了问题你根本不知道是 SIP 注册挂了,还是 RTP 音频没通。用 eXosip 处理 SIP 信令、ffmpeg 做音频编码推流、ffplay 做接收播放,三个命令行工具拼出一个 SIP 客户端,听起来像是绕远路,实际上是把整个通话链路拆成了三个可以单独验证的模块。信令归信令,媒体归媒体,哪一段出问题就在哪一段排查,这才是这套方案真正值钱的地方。
这套组合适合两类人:一类是做 SIP 协议栈开发或者 VoIP 网关测试的工程师,需要在命令行环境下快速模拟一个终端去验证注册、呼叫、挂断这些基本流程;另一类是做音视频采集和推流的开发者,手里已经有 ffmpeg 的采集命令,想用最小的代价把它接进 SIP 通话场景,而不是去集成一个几百兆的软电话 SDK。它的定位很清楚:不是在图形界面里点来点去的成品客户端,而是一个能跑在服务器上、能进脚本、能自动化测试的极简 SIP 终端。
整个实现思路是让 eXosip 负责 SIP 信令——注册、Invite、Bye、ACK 这些消息的收发和状态机维护;ffmpeg 负责把麦克风采集的音频编码成 G.711 或者 Opus,通过 RTP 推出去;ffplay 负责接收对端发来的 RTP 流并解码播放。信令和媒体分离的架构意味着你可以在没有音频设备的环境里只测 SIP 注册流程,也可以在完全不跑 eXosip 的情况下单独验证 ffmpeg 的 RTP 推流参数,这种灵活性是图形界面客户端给不了的。
2. 先拆骨架:eXosip、ffmpeg、ffplay 在一个 SIP 客户端里各管什么
2.1 三个工具的分工边界和为什么选它们
SIP 客户端的本质工作是两件事:用 SIP 协议完成会话的建立和拆除,用 RTP 协议传输音视频数据。eXosip 是基于 osip2 协议栈的上层封装,它把 SIP 的注册、呼叫、事务状态机这些底层细节变成了简单的 API 调用,你不需要手动拼 SIP 消息,也不需要自己维护定时器和重传逻辑。ffmpeg 和 ffplay 则是多媒体层的瑞士军刀,ffmpeg 负责采集、编码、封装和推流,ffplay 负责拉流、解码和播放。
这套选型的核心考量在于:eXosip 是 C 库,可以用命令行程序直接调用编译;ffmpeg 和 ffplay 是独立的可执行文件,用进程间通信或者命令行参数来调度,三者之间通过 RTP 流衔接,信令和媒体完全解耦。相比直接集成 PJSIP 或者 linphone 的完整 SDK,这种组合方式的优势在于 ffmpeg 的编码参数、RTP 封装格式都是业界标准,你可以先用 ffmpeg 实测通不通,再把它接进 eXosip 的控制逻辑,问题定位的粒度会细很多。
常见的做法是用一个主控 C 程序(比如 main.c)内嵌 eXosip,程序跑起来之后通过 system() 或者 fork+exec 去拉起 ffmpeg 和 ffplay 进程。ffmpeg 负责把本机采集的音频用 RTP 发给对端,ffplay 负责从对端拉 RTP 流播放。主控程序同时还承担 SDP 协商的职责:eXosip 收到 INVITE 之后解析出对端的 IP、端口、编码格式,然后把对应的参数填进 ffmpeg 和 ffplay 的命令行。
2.2 SIP 注册与呼叫的信令流程在代码里长什么样
先看最简单的注册流程。eXosip 的使用范式是先初始化,再监听事件,然后把注册消息丢出去。下面的代码片段演示了用 eXosip 向 SIP 服务器发起 REGISTER 请求的最小流程:
#include <eXosip2/eXosip.h> int main() { struct eXosip_t *ctx = eXosip_malloc(); if (eXosip_init(ctx) != 0) { printf("eXosip_init failed\n"); return -1; } eXosip_set_user_agent(ctx, "cmd-sip-client/1.0"); struct eXosip2_ctx *net_conf = NULL; eXosip_set_option(ctx, EXOSIP_OPT_ADD_DNS_NAMESERVER, "8.8.8.8"); char identity[128]; snprintf(identity, sizeof(identity), "sip:1001@192.168.1.10"); char reg_uri[128]; snprintf(reg_uri, sizeof(reg_uri), "sip:192.168.1.10"); eXosip_lock(ctx); eXosip_add_identity(ctx, identity, "1001", "192.168.1.10", "123456"); eXosip_clear_authentication_info(ctx); eXosip_add_authentication_info(ctx, "1001", "1001", "123456", NULL, NULL); eXosip_unlock(ctx); osip_message_t *reg = NULL; eXosip_lock(ctx); eXosip_build_register(ctx, ®, reg_uri, NULL, NULL, 3600); eXosip_register_send_initial_register(ctx, reg); eXosip_unlock(ctx); // 事件循环:等待 REGISTER 响应 int event_type; eXosip_event_t *ev; while (1) { ev = eXosip_event_wait(ctx, 0, 5000); if (ev == NULL) continue; event_type = ev->type; if (event_type == EXOSIP_REGISTRATION_SUCCESS) { printf("REGISTER OK, contact: %s\n", ev->rid); } else if (event_type == EXOSIP_REGISTRATION_FAILURE) { printf("REGISTER FAILED, reason: %s\n", ev->text); } eXosip_event_free(ev); if (event_type == EXOSIP_REGISTRATION_SUCCESS) break; } eXosip_terminate(ctx); eXosip_free(ctx); return 0; }这段代码的关键在于 eXosip_add_identity 和 eXosip_add_authentication_info 两个调用。add_identity 告诉协议栈本机的 SIP URI 和域名,add_authentication_info 提供注册认证用的用户名和密码。实际落地的时候,很多人只调了 add_identity 忘了加认证信息,注册请求会因为没有 Authorization 头而被服务器 401 拒绝。另一个容易忽略的点是 eXosip_lock 和 eXosip_unlock,eXosip 内部是线程安全的,但调用 API 时要显式加锁,否则在并发场景下可能踩到野指针。
注册成功之后,主动呼叫对端的话,需要构造 INVITE 请求,并且在 SDP 里声明本端要发送和接收的媒体参数。这一步是把 eXosip 和 ffmpeg 衔接起来的关键地方——INVITE 的 SDP 内容必须和即将拉起的 ffmpeg 推流参数一致,否则对端应答的 SDP 无法匹配。
2.3 媒体协商:SDP 内容怎么映射成 ffplay 的拉流命令
SDP 协商是 SIP 客户端里最容易出逻辑错误的地方。发送 INVITE 时,你的 SDP 里写明了音频编码是 PCMU(G.711 u-law)、端口是 12000,那么对端回 200 OK 时,它自己的 SDP 里也会告诉你要往哪个 IP 和端口发送 RTP 流。eXosip 的 API 不直接给你解析好的 SDP 媒体参数,你需要自己从 ev->sdp 里提取,或者用 osip 的 SDP 解析函数去拿字符串。
我一般会在收到 200 OK 之后做这样几件事:先从响应里拿到对端的联系地址(Contact),再从 SDP body 里抽出媒体类型、编码名称、端口号。拿到这些值之后,ffplay 的拉流命令就能拼出来了:
ffplay -nodisp -autoexit -protocol_whitelist "file,udp,rtp" -i play.sdpplay.sdp 的内容需要你根据协商结果动态生成,格式是这样的:
v=0 o=- 0 0 IN IP4 127.0.0.1 s=Playback c=IN IP4 192.168.1.20 t=0 0 m=audio 12000 RTP/AVP 0 a=rtpmap:0 PCMU/8000这里的 192.168.1.20 是对端的 RTP 接收 IP,12000 是对端声明接收音频的端口,0 是 RTP payload type 编号,对应 PCMU 编码。这一行参数的来源必须且只能是 200 OK 里 SDP 的 c= 行和 m= 行。如果你在代码里写死对端 IP 或者编码类型,一旦对端实际协商结果不同,ffplay 会一直收不到可解码的 RTP 包,表现就是打开了播放器但是没有任何声音。
ffplay 本身不解析 SIP 的 SDP,你需要先用程序把协商结果写成本地 .sdp 文件,再启动 ffplay 指向这个文件。这也是为什么整个过程必须用命令行拼装而不是直接在 ffplay 里写 URL——RTP 会话参数是动态协商出来的,不是预先知道的。
3. 从零搭起命令行 SIP 客户端:注册到通话的最小可运行实现
3.1 环境准备:eXosip 库的编译安装和 ffmpeg 的 PATH 问题
在动手写完整程序之前,先把依赖环境理清楚。eXosip 在 Linux 环境下一般通过源码编译安装,依赖 osip2 和 c-ares 库。Ubuntu/Debian 系统上可以先装基础依赖,再编译 eXosip:
sudo apt-get install build-essential libosip2-dev libc-ares-dev wget https://download.savannah.gnu.org/releases/exosip/libeXosip2-5.3.0.tar.gz tar zxvf libeXosip2-5.3.0.tar.gz cd libeXosip2-5.3.0 ./configure --prefix=/usr/local make && sudo make install sudo ldconfig编译出来的主控程序要用 gcc 链接 eXosip 库,编译命令大概是:
gcc -o sip_client main.c -leXosip2 -losip2 -lpthreadffmpeg 和 ffplay 在 Windows 上的问题是 PATH 环境变量没配好,命令行直接输 ffmpeg 会提示“不是内部或外部命令”。解决方式是把 ffmpeg 解压目录下的 bin 文件夹加进系统 PATH,或者在启动主控程序之前用绝对路径调用。我一般会在主控程序里加一个配置项记录 ffmpeg 的绝对路径,避免依赖环境变量。
3.2 主控程序:一个 C 文件搞定事件循环和 ffmpeg 进程调度
完整的 SIP 客户端主控程序需要同时做三件事:跑 eXosip 事件循环、维护通话状态、按需拉起和杀掉 ffmpeg/ffplay 子进程。下面的代码展示了一个可运行的骨架,包含注册、主动呼叫、接听、挂断四个核心流程:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <eXosip2/eXosip.h> static pid_t ffplay_pid = -1; static pid_t ffmpeg_pid = -1; void start_ffmpeg_rtp(const char *remote_ip, int remote_port, const char *codec) { char cmd[512]; // 采集麦克风,编码成 PCMU,通过 RTP 发送到对端 snprintf(cmd, sizeof(cmd), "ffmpeg -f alsa -i default -c:a pcm_mulaw -ar 8000 -ac 1 " "-f rtp rtp://%s:%d", remote_ip, remote_port); ffmpeg_pid = fork(); if (ffmpeg_pid == 0) { execl("/bin/sh", "sh", "-c", cmd, NULL); exit(0); } } void start_ffplay_sdp(const char *sdp_file) { char cmd[512]; snprintf(cmd, sizeof(cmd), "ffplay -nodisp -autoexit -protocol_whitelist \"file,udp,rtp\" -i %s", sdp_file); ffplay_pid = fork(); if (ffplay_pid == 0) { execl("/bin/sh", "sh", "-c", cmd, NULL); exit(0); } } void stop_media() { if (ffplay_pid > 0) { kill(ffplay_pid, SIGTERM); waitpid(ffplay_pid, NULL, 0); ffplay_pid = -1; } if (ffmpeg_pid > 0) { kill(ffmpeg_pid, SIGTERM); waitpid(ffmpeg_pid, NULL, 0); ffmpeg_pid = -1; } } void write_sdp(const char *remote_ip, int port, const char *codec, int payload_type, int clock_rate) { FILE *fp = fopen("remote.sdp", "w"); fprintf(fp, "v=0\n"); fprintf(fp, "o=- 0 0 IN IP4 127.0.0.1\n"); fprintf(fp, "s=Call\n"); fprintf(fp, "c=IN IP4 %s\n", remote_ip); fprintf(fp, "t=0 0\n"); fprintf(fp, "m=audio %d RTP/AVP %d\n", port, payload_type); fprintf(fp, "a=rtpmap:%d %s/%d\n", payload_type, codec, clock_rate); fclose(fp); }这段代码的核心思路是 fork 子进程执行 ffmpeg 和 ffplay,主控程序通过 pid 管理它们的生命周期。start_ffmpeg_rtp 里的采集参数 -f alsa -i default 在 Linux 下默认走 ALSA 驱动,如果你的机器用的是 PulseAudio,需要改成 -f pulse -i default。编码参数 -c:a pcm_mulaw -ar 8000 -ac 1 对应 G.711 µ-law、8kHz 采样率、单声道,这是 SIP 通话最通用的音频参数,几乎所有软交换都支持。
write_sdp 函数生成的 SDP 文件是给 ffplay 用的,里面填的是对端的接收参数。这里有一个容易搞混的点:发送 INVITE 时我们自己的 SDP 要写自己接收 RTP 的端口,但生成 ffplay 的 SDP 文件时,ip 和 port 填的是 200 OK 响应里对端告诉我们的值。一个是发布自己的接收地址,一个是订阅对端的发送地址,方向不能搞反。
3.3 呼叫流程编排:从 INVITE 到 200 OK 再到拉起 ffplay
有了底层函数,呼叫流程的编排就是把 eXosip 事件循环里的各个事件和媒体进程的启停对应起来。下面是一个简化的呼叫处理逻辑:
void handle_invite(struct eXosip_t *ctx, eXosip_event_t *ev) { // 收到 INVITE,先应答 100 Trying,防止对端超时重传 eXosip_lock(ctx); eXosip_send_message(ctx, ev->tid, "100 Trying", NULL, NULL); eXosip_unlock(ctx); // 解析对端 SDP,拿到 RTP 接收地址和端口 char remote_ip[64]; int remote_port = 0; const char *sdp_body = ev->message->body; // 这里实际应该用 SDP 解析器,简化起见用正则或字符串匹配 parse_sdp_audio(sdp_body, remote_ip, &remote_port); // 生成 ffplay 用的 SDP 文件 write_sdp(remote_ip, remote_port, "PCMU", 0, 8000); // 应答 200 OK,带自己的接收 SDP osip_message_t *answer = NULL; eXosip_lock(ctx); eXosip_build_answer(ctx, &answer, ev->tid, 200, NULL); // 添加音频媒体行到 answer 的 SDP eXosip_unlock(ctx); // 启动播放器 start_ffplay_sdp("remote.sdp"); // 启动推流 start_ffmpeg_rtp(remote_ip, remote_port, "pcm_mulaw"); } void handle_call_established(struct eXosip_t *ctx, eXosip_event_t *ev) { // 收到 200 OK 的 ACK 之后,通话建立,媒体开始流动 printf("[call] established\n"); } void handle_bye(struct eXosip_t *ctx, eXosip_event_t *ev) { // 对端挂断,停掉所有媒体进程 stop_media(); eXosip_lock(ctx); eXosip_send_message(ctx, ev->tid, "200 Bye", NULL, NULL); eXosip_unlock(ctx); }handle_invite 里的 SDP 解析是整段逻辑里最需要仔细处理的地方。SDP body 的 m= 行可能包含多个媒体流,音频和视频叠加在一起,你需要找到 m=audio 的那一行提取端口号。对端发送的 RTP 流可能来自不同于信令 IP 的地址,所以不要想当然用 SIP 消息的源 IP 去当作 RTP 目标 IP,必须以 SDP 中 c= 行的 IP 为准。
事件循环里还有个细节:eXosip 收到 INVITE 之后,协议栈内部会自动发送 100 Trying,但如果你想控制响应内容,得在事件回调里显式调用发送接口。上面代码里的 eXosip_send_message 就是用来发送自定义响应的。实际线上环境中,100 Trying 如果漏了,对端会在 1 秒左右重发 INVITE,造成重复呼叫的误判。
4. 媒体链路的关键参数:RTP 封装、编码采样率和 ffplay 的拉流行为
4.1 ffmpeg 推流参数怎么配才能和对端编解码器对齐
SIP 通话的媒体协商本质上是两边相互妥协的过程。你的客户端能力集是 PCMU、PCMA、Opus 这些编码的组合,对端会从中挑一个它自己也支持的。一旦协商结果确定,ffmpeg 的编码参数必须严格匹配,否则对端解码会出杂音或者直接丢弃包。
核心参数是采样率、声道数和码率。G.711 固定是 8000 Hz、单声道、64 kbps,没有商量余地;Opus 则灵活得多,48000 Hz 和 16000 Hz 都能跑,但需要在 SDP 里声明 a=fmtp 参数。这里的实践经验是:命令行 SIP 客户端尽量优先用 PCMU/PCMA,因为它们免专利费、解码复杂度低、几乎所有软交换都默认支持。用 Opus 的话,需要确认对端是否声明了 opus/48000/2,如果对端只回了 8000 Hz,你的 ffmpeg 命令就得跟着降采样率。
推流命令里还有一个隐藏参数是 RTP 包的 payload type。SDP 协商出的 PT 值可能不是 0,比如某些软交换用 96 到 127 之间的动态值。ffmpeg 的 rtp 封装格式默认把 PT 编码在 RTP 包头里,如果协商出来的 PT 和 ffmpeg 默认值不一致,需要加 -payload_type 参数显式指定:
ffmpeg -f alsa -i default -c:a pcm_mulaw -ar 8000 -ac 1 \ -payload_type 0 -f rtp rtp://192.168.1.20:12000如果不指定 -payload_type,ffmpeg 会按 codec 的默认值封装。大多数情况下 PCMU 的默认 PT 就是 0,问题不大,但如果是 G.722 或者 Opus 这种动态 PT 编码,默认值很可能对不上,对端就会因为 PT 不匹配而丢包。这也是为什么生成 ffmpeg 命令时不要用模板字符串,而是要从协商结果动态拼接。
4.2 ffplay 收流:protocol_whitelist 和 buffer 时延怎么调
ffplay 是一个简单的媒体播放器,但它对 RTP 流的处理有几个需要留意的边界。首先是 -protocol_whitelist 参数,新版 ffmpeg 出于安全考虑,默认只允许白名单内的协议,不加这个参数直接读 .sdp 文件会报 “Protocol not on whitelist” 的错误。白名单里至少要包含 file、udp、rtp 三个协议,因为 ffplay 需要先读本地 SDP 文件,再通过 UDP 接收 RTP 数据。
音频播放的时延控制是另一个问题。ffplay 默认的音频 buffer 比较大,在 SIP 通话场景下,300 到 500 毫秒的端到端时延是能接受的,但如果你用 ffplay 默认参数,实际时延可能超过 1 秒,对话的时候感觉就是对方说完话要等一拍才能听到。可以用 -fflags nobuffer 和 -flags low_delay 降低缓存:
ffplay -nodisp -autoexit -fflags nobuffer -flags low_delay \ -protocol_whitelist "file,udp,rtp" -i remote.sdp-nodisp 参数表示不打开视频窗口,因为我们的客户端只处理音频;-autoexit 表示播放结束自动退出,这个参数在 RTP 流断开时很关键,不加的话 ffplay 进程会一直挂着。
低于 1 秒时延的代价是抗抖动能力下降。如果对端网络不稳定,RTP 包到达时间抖动超过 buffer 深度,声音就会断断续续。这个参数没有万能值,实测下来本地局域网 20 毫秒左右的 buffer 就够了,跨公网的话建议给 ffplay 加上 -analyzeduration 30000 让它多分析一会儿再开始播,避免因为初始丢包导致长时间无声。
4.3 音频设备选型:ALSA、PulseAudio 和 Windows 的差异
ffmpeg 的音频输入设备在不同操作系统上差异很大,这是移植这套方案时最容易翻车的地方。Linux 下如果桌面环境走的是 PulseAudio,直接用 -f alsa -i default 大概率会报设备忙或者没有权限。排查方法是先用 ffmpeg -sources list 查看可用输入:
ffmpeg -hide_banner -sources list输出里会列出 pulse 和 alsa 两套设备。如果系统默认音频走 PulseAudio,正确的采集命令是:
ffmpeg -f pulse -i default -c:a pcm_mulaw -ar 8000 -ac 1 \ -f rtp rtp://192.168.1.20:12000Windows 上没有 ALSA 也没有 PulseAudio,ffmpeg 用的是 dshow(DirectShow)接口,设备名是音频设备的产品名,需要用 enum 先列出来:
ffmpeg -list_devices true -f dshow -i dummy拿到设备名之后,采集命令变成:
ffmpeg -f dshow -i "audio=麦克风阵列" -c:a pcm_mulaw -ar 8000 -ac 1 \ -f rtp rtp://192.168.1.20:12000Windows 下还有一个坑是 dshow 的 buffer 默认太大,采集延迟比较明显,建议加 -rtbufsize 16M 适当减小缓存。设备名的中文引号在命令行里容易出错,如果脚本里有特殊字符,最好把设备名写到配置文件里读取,不要硬编码在命令里。
5. 避坑指南:注册失败、只闻其声不见其人、僵尸进程
5.1 注册返回 401 Unauthorized 但密码明明是对的
现象:eXosip 事件循环里收到 EXOSIP_REGISTRATION_FAILURE,服务器返回 401,日志里看到 Authorization 头缺失或者 Digest 校验失败。
原因:eXosip 的注册流程是两段式的——先发不带 Authorization 的 REGISTER,收到 401 之后用服务器返回的 realm 和 nonce 重新计算 Digest 摘要,再发第二次 REGISTER。如果你的 eXosip_add_authentication_info 调用里的 realm 参数填了固定值,而服务器实际返回的 realm 不同,协议栈算出来的摘要就会不匹配。另一个常见原因是 add_identity 里的域名部分和服务器期望的不一致,比如用 IP 注册但服务器要求域名。
解决:把 realm 参数留空,让 eXosip 自己从 401 响应里提取。同时确认 add_identity 的第三个参数填的是注册服务器的地址,第四个参数是 SIP 域名,不要混淆。调试时可以用 eXosip 自带的 sip_reg 工具先测一遍服务器行为,排除账号本身的问题。
5.2 呼叫能建立但 ffplay 有窗口没声音,或者 ffmpeg 一直报 “Connection refused”
现象:INVITE 和 200 OK 都正常,通话状态显示 established,但 ffplay 打开后没有声音;同时 ffmpeg 侧日志出现 udp connect failed 或者 Connection refused。
原因:RTP 流的目标端口和 IP 用了 SIP 信令的对端地址,而不是 SDP 里 c= 行的媒体地址。很多 SIP 服务器或者软交换的媒体地址和信令地址不是同一个网卡,尤其是有 NAT 或者负载均衡的场景下,信令从 A 地址过来,媒体却要求发到 B 地址。
解决:强制定位 SDP 解析步骤,打印出 c= 行和 m= 行的完整内容,再和 ffmpeg 命令里的目标地址比对。ffplay 这边没有声音,需要确认 remote.sdp 里的 IP 和端口是否也是从同一个 SDP 里提取的。可以先手动用 ffplay 单独播放对端发来的 RTP 流,验证网络通路本身通不通。
5.3 通话结束以后 ffmpeg 进程变成僵尸进程
现象:调用 stop_media 之后,ps 命令里还能看到 ffmpeg 的僵尸进程,CPU 占用为 0,但进程一直在。
原因:fork 出的子进程收到了 SIGTERM 信号,但主进程没有调用 waitpid 回收子进程的退出状态,导致子进程变成僵尸。更隐蔽的情况是 ffmpeg 进入了一种无法被 SIGTERM 中断的状态,比如正在等待音频设备返回数据,信号被阻塞了。
解决:stop_media 里 kill 之后要 waitpid,并且要判断 waitpid 的返回值是否有 -1(子进程已结束)。如果 SIGTERM 杀不掉,升级为 SIGKILL:
void stop_media() { if (ffplay_pid > 0) { kill(ffplay_pid, SIGTERM); // 给 2 秒时间退出 usleep(2000000); if (waitpid(ffplay_pid, NULL, WNOHANG) == 0) { kill(ffplay_pid, SIGKILL); waitpid(ffplay_pid, NULL, 0); } ffplay_pid = -1; } // ffmpeg 同样处理 }这种超时强杀的模式比直接杀进程可靠得多,尤其在高并发自动化测试场景里,僵尸进程积累会导致系统文件描述符耗尽,最终连新的 SIP 注册都做不了。
5.4 同一台机器跑两个客户端实例,端口冲突导致注册失败
现象:并行跑两个测试客户端,第二个启动后 eXosip 初始化失败,日志提示 bind 失败。
原因:eXosip 默认监听 5060 UDP 端口,两个实例抢同一个端口。ffplay 和 ffmpeg 的 RTP 端口同理,默认配置可能都用了 10000 到 20000 之间的随机端口,冲突概率很大。
解决:给每个实例显式指定不同的本地端口。eXosip 的初始化传入本地端口参数,RTP 端口在 SDP 生成时用 20000 + 实例编号 * 2 这种递增方式,避免随机碰撞。
6. 进阶调试技巧:用回调日志定位信令和媒体不同步的问题
跑通基本通话流程之后,你会发现这个命令行客户端最大的价值在于可以精确控制日志输出和事件时序。eXosip 自带的事件回调机制能告诉你每个时刻协议栈内部发生了什么,配合 ffmpeg 和 ffplay 的日志去重定向,能把一次通话的全链路日志抓下来慢慢分析。
我给这个客户端加过的两个实用功能:一个是通过环境变量控制日志级别,另一个是把 eXosip 的 SIP 消息全文打印到 pcap 文件。第一个功能用 eXosip_set_log_level 实现:
const char *level = getenv("SIP_LOG_LEVEL"); if (level && strcmp(level, "debug") == 0) { eXosip_set_log_level(ctx, EXOSIP_LOG_DEBUG); } else { eXosip_set_log_level(ctx, EXOSIP_LOG_ERROR); }这样在生产脚本里默认只输出错误日志,排障时手动加环境变量就能切到 debug 模式,看到每个 SIP 消息的完整报文头。
第二个功能更实用。用 tcpdump 抓包来验证信令和媒体是否同步,比看日志更直观:
tcpdump -i any -s 0 -w sip_call.pcap "udp port 5060 or udp portrange 12000-12010"抓包文件用 Wireshark 打开,先看 SIP 层的 INVITE 和 200 OK 的 SDP 内容,再用“电话”菜单里的 VoIP 通话分析功能,它能自动把同一通电话的 SIP 信令和 RTP 流关联起来。如果 Wireshark 显示 RTP 流的 SSRC 或者 PT 值跟 SDP 协商不一致,问题在 ffmpeg 封装参数;如果 RTP 包正常但播放无声,问题在 ffplay 的解码参数或者客户端声卡路由。
还有一个我常用的验证技巧:先用 ffprobe 检查 RTP 流能不能被正确识别。在 ffmpeg 推流的同时,用另一个终端执行:
ffprobe -v verbose -protocol_whitelist "file,udp,rtp" -i remote.sdp如果 ffprobe 能正确读出 Audio: pcm_mulaw 8000 Hz 的信息,说明 RTP 封装和 SDP 头部信息是一致的;如果 ffprobe 报错或者读出的采样率不对,直接检查 SDP 文件里的 rtpmap 行,不用碰 C 代码。
最后说一个我自己的习惯:所有的 ffmpeg 和 ffplay 调用都封装成独立的 shell 脚本,C 程序通过 system() 调用,而不是把命令字符串散落在 C 代码里。调试的时候直接跑脚本,参数不对改脚本重启就行,不用重新编译。RTP 推流的码率参数、采样率这些值,第一次调通之后我通常不会再去动它,但是会在脚本里保留一个 -loglevel debug 的开关,等到真正需要排查的时候再打开,避免日常运行时日志刷屏把有效信息淹没。
这套方案跑到现在,最值回票价的部分不是“能打电话”,而是“每次打电话都能知道为什么通、为什么不通用”。把信令和媒体拆成两条独立的流水线,每一条都可以单独验货。希望这个命令行拼装思路能帮你在调试 SIP 和 RTP 问题时省下一些来回折腾的时间。
本文还有配套的精品资源,点击获取