SIP视频通话开发:eXosip信令控制与ffmpeg RTP流媒体推送实践
简介:使用eXosip处理SIP信令、ffmpeg/ffplay命令行作为音视频通道的源码附件,面向有C++基础且需要接入IP摄像头做视频通话的开发者。针对pjsip体积大、需改源码的问题,这套方案以eXosip单独完成SIP注册、呼叫与媒体协商,并用ffplay播放RTP流、ffmpeg推送摄像头数据,形成一套可先验证后编码的原型路径。压缩包大小23.22MB,共789个文件,其中以C/C++源码与头文件为主体(158个.c、150个.h、22个.cc),同时包含configure/Makefile等构建脚本、Visual Studio工程文件、大量以sip数字命名的报文样本、osip文档及测试用例,便于读者对照SIP消息流程和库源码理解会话建立与媒体协商细节。对于希望自行组合SIP协议栈与多媒体组件、或需要快速验证IP摄像头RTP推流与接收的开发者,这份附件能提供直接的参考素材,减少搭环境与调试信令的时间。目前已有587人学习下载,适合作为音视频开发中SIP客户端原型验证的重要参考,也可作为后续用ffmpeg API替换命令行实现完整客户端的开发起点。
1. 摄像头做 SIP 视频源:eXosip 与 ffmpeg/ffplay 的分工思路
用 SIP 做视频通话,最常见的坑在媒体源侧。IP 摄像头厂家给的 RTSP 流,要送进 SIP 通话里,pjsip 功能完整但媒体层耦合太重,改源码的成本和时间都不少。我的做法是把信令和媒体彻底拆开:eXosip 只管注册、INVITE、ACK、BYE 这些 SIP 信令,音视频流全部交给 ffmpeg 命令行推成 RTP,ffplay 命令行在收端监听播放。这样链路打通之前一行 C++ 都不用写,编解码参数、端口、payload type 全部用终端命令就能验证。测试通过之后再考虑把 ffmpeg 命令行替换成 API 调用。这个方案特别适合被 pjsip 媒体层源码卡住的开发者,也适合在写代码前先验证摄像头、网络、编码链路是否可行的团队。下面按信令、推流、收流、进程编排的顺序,给出这套链路的关键代码和坑位。
2. eXosip 信令层:注册认证、INVITE 应答与 SDP 重写
2.1 为什么选 eXosip 而不是直接用 oSIP
eXosip 与 oSIP 是上下层关系。oSIP 是 RFC 3261 的事务处理库,把请求/响应的状态机、消息解析做得完整,但它不管理会话。eXosip 在 oSIP 之上补了 dialog 管理、注册刷新、认证回调,以及一个内部的事件队列,业务侧直接用 eXosip_event_wait 收事件就行。选择它的实质意义在于:信令侧的鉴权重发、事务重传、dialog 的 UPDATE/RE-INVITE 都不需要自研,可以把精力集中在媒体编排上。
在 eXosip 的框架里,调用线程和事件线程不是必须一致,但业务侧访问 eXosip 内部状态时要注意加锁。常见做法是用 eXosip_lock / eXosip_unlock 包住会修改 dialog 或 transaction 的操作,否则在频繁注册刷新时可能出现事务数据竞争,表现为偶发呼不通。
2.2 注册与事件循环的初始化代码
先把注册的骨架代码写出来。
#include <eXosip2/eXosip.h>
#include <osip2/osip.h>
static struct eXosip_t *g_ctx = NULL;
int sip_init_and_register(const char *sip_server,
const char *account,
const char *passwd)
{
int ret;
g_ctx = eXosip_malloc();
if (g_ctx == NULL) {
return -1;
}
ret = eXosip_init(g_ctx);
if (ret != OSIP_SUCCESS) {
return -2;
}
// 监听本机 UDP 5060 端口,这是 SIP 信令默认端口
ret = eXosip_listen_msg(g_ctx, "0.0.0.0", 5060,
AF_INET, IPPROTO_UDP);
if (ret != OSIP_SUCCESS) {
return -3;
}
// 注册成功后收到 401/407 时 eXosip 会自动带 Authorization 重发
eXosip_add_authentication_info(g_ctx,
account, account,
passwd, NULL, sip_server);
osip_message_t *reg = NULL;
char from[128] = {0};
char contact[128] = {0};
snprintf(from, sizeof(from), "sip:%s@%s", account, sip_server);
snprintf(contact, sizeof(contact), "sip:%s@%s", account, sip_server);
// 构建 REGISTER,3600 秒后 eXosip 负责续订
eXosip_register_build_initial_register(g_ctx,
from,
"sip:" "10.10.10.2",
contact,
3600, ®);
eXosip_register_send_register(g_ctx, reg);
return 0;
}
代码说明: eXosip_malloc 先创建上下文, eXosip_init 做内部模块初始化,这两步失败说明库版本或依赖有问题。 eXosip_listen_msg 的第二个参数 "0.0.0.0" 表示监听所有网卡,生产环境建议指定内网网卡 IP,避免多网卡时信令地址选错。 eXosip_add_authentication_info 的参数顺序是用户名、From 头中的 id、密码、ha1(预计算哈希,传 NULL 表示让 eXosip 用密码计算)、realm(传 NULL 表示用服务器返回的 realm)、服务器地址。最后构建 REGISTER 时四个字符串参数分别是 From 地址、proxy 地址、Contact 地址和有效期。
事件循环里最关心的是注册状态和呼叫事件:
void sip_event_loop(void)
{
while (1) {
eXosip_event_t *ev = eXosip_event_wait(g_ctx, 0, 200);
if (ev == NULL) {
continue; // 200ms 超时无事件
}
eXosip_lock(g_ctx);
switch (ev->type) {
case EXOSIP_REGISTRATION_SUCCESS:
printf("register ok\n");
break;
case EXOSIP_REGISTRATION_FAILURE:
printf("register failed: %s\n", ev->textinfo);
break;
case EXOSIP_CALL_INVITE:
on_call_invite(ev);
break;
case EXOSIP_CALL_ACK:
printf("call established\n");
break;
case EXOSIP_CALL_CLOSED:
stop_media_process();
break;
default:
break;
}
eXosip_unlock(g_ctx);
eXosip_event_free(ev);
}
}
eXosip_event_wait 的三个参数分别是上下文、等待策略和超时毫秒数,这里传 0 表示按默认策略阻塞等待,200 表示最多等 200 毫秒,返回 NULL 就是超时。业务线程在事件循环里调用 on_call_invite ,媒体进程的启动和停止挂在 INVITE 和 CALL_CLOSED 两个事件上,这个对应关系是整个信令媒体拆分的核心。
2.3 处理 INVITE:SDP 重写与 200 OK
被叫场景下,收到 EXOSIP_CALL_INVITE 时主叫方已经把它的 SDP offer 放在 INVITE body 里了。eXosip 的做法是构造 200 OK 应答、塞入新的 SDP、再用 eXosip_call_send_answer 发出。这里最容易错的是 SDP 的地址端口和本地监听状态不一致。
static int on_call_invite(eXosip_event_t *ev)
{
osip_message_t *answer = NULL;
// 本地媒体地址,必须与 ffmpeg 推流命令绑定一致
const char *local_sdp =
"v=0\r\n"
"o=1000 0 0 IN IP4 10.10.10.3\r\n"
"s=call\r\n"
"c=IN IP4 10.10.10.3\r\n"
"t=0 0\r\n"
"m=video 5004 RTP/AVP 96\r\n"
"a=rtpmap:96 H264/90000\r\n"
"a=fmtp:96 packetization-mode=1\r\n";
// 主叫方 SDP 在 INVITE body 中,调试时直接打印确认协商结果
osip_body_t *body = NULL;
osip_message_get_body(ev->request, 0, &body);
if (body != NULL) {
printf("remote sdp:\n%s\n", body->body);
}
// 顺序很重要:先启动 ffmpeg,再发 200 OK
start_media_process();
int ret = eXosip_call_build_answer(g_ctx, ev->tid, 200, &answer);
if (ret == OSIP_SUCCESS && answer != NULL) {
osip_message_set_body(answer, local_sdp, strlen(local_sdp));
osip_message_set_content_type(answer, "application/sdp");
eXosip_call_send_answer(g_ctx, ev->tid, 200, answer);
}
return 0;
}
这里 start_media_process 放在 send_answer 之前的原因很直接:200 OK 一旦发出,对端马上向 SDP 里的 c= 地址和 m= 端口发送 RTP 包,如果 ffmpeg 还没绑定 5004 端口,这些 UDP 包会直接丢弃,造成已接通但无画面的假象。
SDP 各字段与 ffmpeg 参数有明确的对应关系:
| SDP 行 | 作用 | 与 ffmpeg 参数的对应关系 |
|---|---|---|
c=IN IP4 10.10.10.3 | 媒体接收地址 | 推流命令中 rtp:// 写本地址 |
m=video 5004 RTP/AVP 96 | 视频端口与 PT 值 | 对应 -payload_type 96 和监听端口 5004 |
a=rtpmap:96 H264/90000 | 编码格式与时钟频率 | H264 视频时钟固定 90000 Hz |
a=fmtp:96 packetization-mode=1 | H264 打包模式 | 摄像头 H264 流必须为 1 |
packetization-mode=1 表示非交错打包模式,这是绝大多数 H264 摄像头和 ffmpeg 的默认输出,如果写 0 或缺失,部分 SIP 终端会协商失败。
3. ffmpeg 命令行推 RTP 流:编码参数与 payload type 对齐
3.1 直接推 RTP 与导出 SDP 两条路径
ffmpeg 内置了 RTP muxer,可以直接把编码后的流打包成 RTP 送到指定地址端口。命令行的写法有两个要点:一是 -f rtp 指定输出格式,二是 rtp://ip:port 给出目标地址。这条路径适合点对点 SIP 通话,媒体地址端口完全写死,与第 2 章 SDP 里声明的地址端口保持一致即可。
调试阶段我更推荐第二条路径:加 -sdp_file 让 ffmpeg 自动生成 SDP 文件,拿这个文件去验证 ffplay 收流,能区分问题是出在推流参数还是 SDP 手工编写上。
# 生成推流 SDP 的命令
ffmpeg -re -i test.mp4 \
-an -c:v copy \
-f rtp -payload_type 96 -ssrc 10002 \
-sdp_file video.sdp \
rtp://10.10.10.3:5004
# 查看生成的 SDP 内容
cat video.sdp
-re 表示按输入文件的原始帧率读取,不加它 ffmpeg 会以最大速度把文件瞬间推完。 -an 先关掉音频,验证视频链路时少一个变量。 -c:v copy 不对视频码流做二次编码,源是什么编码就直接打包进 RTP,CPU 占用最低。
3.2 摄像头 RTSP 源的推流命令
摄像头输入的推流命令比文件场景多一个拉流参数:
ffmpeg -re -rtsp_transport tcp \
-i "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101" \
-an \
-c:v copy \
-f rtp -payload_type 96 -ssrc 10001 \
rtp://10.10.10.3:5004
-rtsp_transport tcp 是 ffmpeg 作为 RTSP 客户端拉取摄像头流时选的传输协议。海康、大华的摄像头默认走 UDP 传输,在无线或有损网络上会出现大面积花屏,TCP 重传机制更稳。这个参数只影响拉流,不影响推流侧的 RTP。
如果手里的源不是摄像头而是本地视频文件,想要模拟实时推流,编码参数需要显式控制:
ffmpeg -re -i test.mp4 \
-an -c:v libx264 -preset veryfast -tune zerolatency \
-b:v 800k -maxrate 800k -bufsize 800k \
-g 25 -keyint_min 25 -sc_threshold 0 \
-pix_fmt yuv420p \
-f rtp -payload_type 96 -ssrc 10002 \
rtp://10.10.10.3:5004
-
-tune zerolatency关闭编码器延迟缓冲,对讲场景必须加,否则画面延迟能到几百毫秒 -
-g 25设定 GOP 长度为 25 帧,对应 1 秒一个关键帧;关键帧间隔太大,对端首屏等待时间会显著拉长 -
-sc_threshold 0关闭场景切换自动插关键帧,保证 GOP 边界稳定,方便排查花屏问题 -
-b:v 800k -maxrate 800k -bufsize 800k是三参数配合,限制码率波动范围
3.3 音频流与双路媒体端口规划
视频打通后接音频,命令参数要多留意采样率与通道数:
ffmpeg -re -i test.mp4 \
-vn -c:a pcm_alaw -ar 8000 -ac 1 \
-f rtp -payload_type 8 -ssrc 10003 \
rtp://10.10.10.3:5006
PCMA 的 payload type 固定是 8,8000 Hz 采样率、单声道是 SIP 音视频通话中最基础的语音配置。在上一节的视频命令里,视频走到了 5004 端口,音频这里单独用了 5006,两个媒体流必须分别占端口和 payload type,SDP 里也要对应写成两行 m= 。用两个 ffmpeg 进程分别推视频和音频,比用一个进程同时推两路简单得多,因为 SIP 通话里音视频端口本来就是分开协商的。
ffmpeg 推流关键参数汇总:
| 参数 | 作用 | 容易出现的问题 |
|---|---|---|
-payload_type | 设置 RTP PT 值 | 与 SDP 不一致时报 codec mismatch |
-ssrc | 固定同步源标识 | 不固定时重启流会有旧帧残留 |
-re | 按原速读取输入 | 不加则文件瞬间推完 |
-c:v copy | 视频透传不重编码 | 源分辨率码率过高时下游带宽吃紧 |
-tune zerolatency | 低延迟编码模式 | 不加延迟明显但画质略好,按场景取舍 |
出现「ffmpeg 不是内部或外部命令」这类终端错误时,先确认 ffmpeg 安装后加入了系统 PATH,Windows 下直接把 ffmpeg 的 bin 目录加进去即可。这条看起来基础,但联调时因为终端环境不同而反复踩的并不少。
提示:
-payload_type的取值范围是 96~127 的动态值,PCMA/PCMU 这种固定编码(8/0)除外。摄像头 H264 流统一取 96,音频取 8,是最省心的组合。
4. ffplay 收流与信令媒体时序:端口、SDP 与进程编排
4.1 ffplay 播放 RTP 流的参数组合
推流端准备好了,收流端用 ffplay 可以直接播放 RTP 流。前提是得有一个 SDP 文件描述这条流,ffplay 不会像 VLC 那样自动探测裸 RTP 的编码和端口。SDP 可以用 ffmpeg 的 -sdp_file 导出,也可以按第 2 章样例手动维护。
ffplay -protocol_whitelist file,rtp,udp \
-fflags nobuffer -flags low_delay -framedrop \
-analyzeduration 0 -probesize 32 \
video.sdp
-protocol_whitelist file,rtp,udp 是必须的,新版 ffplay 默认白名单不含本地文件,少了它直接报 Protocol whitelist does not include file 。 -fflags nobuffer 关闭输入缓冲, -framedrop 允许丢帧追同步,这两个参数组合下首帧显示时间能控制在几百毫秒,对讲场景比默认参数的体验强很多。
对应第 2 章的 SDP 内容,ffplay 读取的 video.sdp 长这样:
v=0
o=- 0 0 IN IP4 10.10.10.3
s=SIP Call
c=IN IP4 10.10.10.3
t=0 0
m=video 5004 RTP/AVP 96
a=rtpmap:96 H264/90000
a=fmtp:96 packetization-mode=1
注意 m= 行的端口是推流端绑定的端口, c= 行的地址是推流端的网卡地址。把这两个值改成对端 IP 和端口,就是第 2 章发给对方的应答 SDP。
4.2 信令事件驱动的进程编排顺序
媒体进程的启动顺序直接决定呼叫成功率。常见错误是先回 200 OK 再启动 ffmpeg,对端媒体包到达时本地没有进程监听,UDP 直接丢包,表现就是已经接通但完全无画面。
正确的顺序是:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 1 | 收到 INVITE | EXOSIP_CALL_INVITE 事件触发 |
| 2 | 解析对端 SDP | 从 m= 行拿到对端媒体端口 |
| 3 | 启动 ffmpeg 推流进程 | 第 2 章 SDP 中的端口,此刻已绑定 |
| 4 | 发送 200 OK 带本地 SDP | 对端收到后立即开始推流 |
| 5 | 收到 ACK | 信令确认,媒体流已经互通 |
| 6 | 收到 BYE | EXOSIP_CALL_CLOSED ,终止媒体进程 |
这里补充一个方向性问题:作为被叫时解析的是 INVITE body 里的 SDP,作为主叫时解析的是最终 200 OK 响应里的 SDP,媒体编排逻辑完全相同,都是拿到对端声明的地址端口,再决定 ffmpeg 往哪推。
如果媒体进程需要额外时间就绪,比如从摄像头拉流有握手过程,可以在发送 200 OK 前轮询端口监听状态作为就绪信号。
import socket
import time
def wait_port_ready(port: int, timeout: float = 3.0) -> bool:
"""轮询 UDP 端口是否已有进程监听"""
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(0.1)
try:
sock.bind(("0.0.0.0", port)) # 能绑定说明端口空闲
except OSError:
return True # 已被 ffmpeg 占用,就绪
time.sleep(0.05)
return False
这段轮询能解决 ffmpeg 进程创建和 socket 绑定之间的竞态。先启动 ffmpeg,再 wait_port_ready(5004) ,确认端口已被占用后才发 200 OK。如果 ffmpeg 启动失败,此时可以及时返回 486 Busy 或直接丢弃呼叫,而不是让对端等一个假 SDP。
4.3 链路不通时的定位方法
媒体链路不通时,按端口、SDP、编码三个层次排查。端口层先确认本地监听和实际收包:
# 确认本地 RTP 端口是否被 ffmpeg 进程监听
ss -lunp | grep 5004
# 抓包确认对端媒体包是否到达
tcpdump -i eth0 udp port 5004 -c 20
# 用 debug 级别日志启动推流并过滤 RTP 相关输出
ffmpeg -loglevel debug -re -rtsp_transport tcp \
-i "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101" \
-an -c:v copy -f rtp -payload_type 96 \
rtp://10.10.10.3:5004 2>&1 | grep -i rtp
ss -lunp 输出里如果 5004 端口没有对应进程,说明 ffmpeg 没起来或者命令行参数有误。 tcpdump 抓包能看到对端发来的 RTP 包源地址和序列号,若一片空白说明对端根本没发,检查应答 SDP 里的地址端口是否可达。
- 有画面卡在首帧:GOP 太长或关键帧丢失,调小
-g或用-force_key_frames强制周期插关键帧 - 花屏绿屏:H264 参数集(SPS/PPS)缺失,检查 SDP 中
a=fmtp行是否包含参数集信息;从 MP4 文件推流时先加-bsf:v h264_mp4toannexb确保参数集进入 RTP 流 - 有视频无音频:音频的 PT、采样率、声道数与对端 SDP 不一致,PCMA 固定 8 / 8000 Hz / 单声道
- 信令正常但媒体双向不通:确认 SDP 里的地址不是 0.0.0.0,多网卡机器要指定实际网卡地址
5. 从命令行落地到 ffmpeg API:把验证过的参数固化进代码
命令行链路通之后,真正的产品化要做的是把 ffmpeg 命令行替换为 API 调用。原因有两个:一是命令行进程的起停、日志管理在长时间运行时比较脆弱,二是 SIP 通话的媒体参数要求动态协商,每次呼叫的 RTP 端口和 SSRC 都可能不同,命令行方式要反复重建进程,时延不可控。
ffmpeg API 的最小推流骨架如下:
extern "C" {
#include <libavformat/avformat.h>
}
bool open_rtp_sender(const char *rtsp_url, const char *rtp_url,
int payload_type)
{
AVFormatContext *in_fmt = nullptr;
AVFormatContext *out_fmt = nullptr;
// 输入:摄像头 RTSP 流
if (avformat_open_input(&in_fmt, rtsp_url, nullptr, nullptr) < 0) {
return false;
}
avformat_find_stream_info(in_fmt, nullptr);
// 输出:RTP muxer,目标地址来自对端 SDP 的 c= 和 m=
avformat_alloc_output_context2(&out_fmt, nullptr, "rtp", rtp_url);
if (!out_fmt) {
return false;
}
AVStream *out_stream = avformat_new_stream(out_fmt, nullptr);
out_stream->id = payload_type; // 与 SDP 中 a=rtpmap 的 PT 对齐
out_stream->codecpar->codec_type = AVMEDIA_TYPE_VIDEO;
out_stream->codecpar->codec_id = AV_CODEC_ID_H264;
// 转发场景还要把输入流的 codecpar->extradata 拷贝过来
avformat_write_header(out_fmt, nullptr);
AVPacket pkt;
while (av_read_frame(in_fmt, &pkt) >= 0) {
if (pkt.stream_index == 0) {
pkt.stream_index = out_stream->index;
av_interleaved_write_frame(out_fmt, &pkt);
}
av_packet_unref(&pkt);
}
return true;
}
out_stream->id 对应 RTP payload type,命令行里的 -payload_type 96 在这里就是一行赋值。 avformat_alloc_output_context2 的第三个参数固定传 "rtp" ,它会自动注册 RTP muxer。代码里 avformat_write_header 是 SDP 生成的触发点,ffmpeg 的 RTP muxer 在写头时会把 payload type、SSRC、编码参数固化进 SDP,这一点和命令行行为完全一致。
命令行参数与 API 的映射关系:
| 命令行参数 | API 位置 |
|---|---|
-payload_type 96 | out_stream->id = 96 |
-ssrc 10001 | av_opt_set(out_fmt->priv_data, "ssrc", "10001", 0) |
-g 25 | 编码器 AVCodecContext 的 gop_size = 25 |
-tune zerolatency | 编码器私有参数 av_opt_set(enc_ctx->priv_data, "tune", "zerolatency", 0) |
如果沿用了命令行的 -c:v copy 方式,GOP 由源流决定,API 侧不需要设置 gop_size 。如果转成编码模式,要在打开编码器前设置 gop_size 和 max_b_frames 。用 av_opt_set 设置 SSRC 时密钥名在 RTP muxer 的文档里查得到,写错不会报错但 SDP 里 SSRC 字段会缺失。
迁移验证保持和命令行相同的闭环:用 ffmpeg API 程序生成 SDP 文件,再用 ffplay 打开播放,对比命令行版本的首帧延迟和 CPU 占用。若两者一致,说明媒体层参数迁移完成,剩下的工作就是把 eXosip 的事件循环和 open_rtp_sender 的调用时机接起来,收到 INVITE 启动、收到 BYE 停止,这一步第 4 章的时序表已经给出了完整顺序。
更多推荐

所有评论(0)