从SIP协议栈选型到Osip/eXosip实战:事务状态机与呼叫流程解析
发布时间:2026/9/7 7:46:34 作者:尧图编辑部 阅读量:1,286

简介面向希望借助实际工程理解SIP协议的C/C开发者这是一套基于Osip库的SIP通信示例代码包含sipSendDemo和sipRecvDemo两个独立工程清晰演示了SIP消息的创建、发送、接收和解析全流程可有效降低Osip入门门槛并可直接为SIP客户端或软交换二次开发提供参考。包内共166个文件以h头文件为主辅以lib库、cpp源文件、vcproj/sln工程文件以及Makefile.am构建脚本压缩包仅1.64MB结构精简便于快速定位源码、库文件和工程配置。目前已有292人学习。示例将Osip的初始化、消息构建、网络收发和消息解码等操作封装为易用类覆盖INVITE、ACK、BYE等常用请求/响应并重点展示了基于msg结构的通信方式对理解SIP信令状态机、头域填充和事务处理都有直接帮助适合具备基础C/C背景、正在做VoIP或IMS相关开发的读者研读。1. 为什么把通信层压在Osip上做SIP协议栈选型这件事我踩的坑比你想象的要多。早年做软电话终端时用的是某商业SDK功能倒是齐全但一到定制化协议头、非标准UA字段、嵌入式裁剪这些场景闭源包就像一堵墙你根本不知道它内部怎么处理事务状态机出了问题只能提交工单等回复。后来切到开源方案评估了PJSIP和Sofia-SIP最后才认真审视GNU oSIP这个老牌库。先说结论OsipGNU oSIP不是最上层的SIP协议栈而是一个非常纯粹的信令解析与事务处理库。它不关心音视频传输不管RTP不提供高层的注册/呼叫封装也不包含任何策略逻辑。它做的事情只有两件一是把SIP消息解析成可操作的结构体二是实现RFC 3261定义的事务层Transaction Layer状态机。想要完成一个完整的UAUser Agent你需要搭配它的兄弟库eXosip——后者在Osip之上封装了注册、呼叫、订阅、IM等对话层逻辑。这恰恰是它最值钱的地方。因为协议栈的“策略”和“机制”被拆开了内置的回调机制允许你拿到事务状态迁移的每一个瞬间自定义扩展头字段、处理未解析的body、甚至修改出站消息的任意字节这些在PJSIP里反而要绕很多弯子。对做嵌入式网关、IMS终端、自定义SIP服务器的人来说这个自由度是决定性的。我用一个不恰当的比喻PJSIP像装修好的精装房拎包入住但你想拆堵墙就得找物业Osip像毛坯房水电管线裸露但你随便改格局。demo跑通只是第一步真正要产品化的时候你会感谢当初选择了那个你能完全掌控的库。2. 环境准备编译Osip和eXosip时容易忽略的细节2.1 源码获取与configure参数Osip和eXosip都在GNU官方站点和GitHub镜像仓库里建议直接拉取eXosip项目源码因为它会捆绑对应版本的Osip核心库。有一个坑需要注意eXosip和Osip的版本必须配套混用主版本会造成ABI兼容性问题。我见过有人把系统自带的libosip2和源码编译的eXosip链接在一起结果在消息解析时直接段错误查了一天最后发现是版本不匹配。编译顺序是先Osip后eXosip命令很简单./autogen.sh ./configure --prefix/usr/local/osip make -j4 sudo make install但这里有三个值得留意的细节第一configure阶段可以用--enable-debug打开调试日志开发阶段强烈建议开启它在消息收发时会打印完整的事务状态迁移比你在代码里插printf高效得多。产品化时再去掉。第二默认情况下Osip会编译出共享库和静态库但如果你做的是嵌入式交叉编译记得用--disable-shared只出静态库避免目标板上动态链接器版本不一致导致的加载问题。第三Osip依赖libc之外几乎没有任何第三方库这是它移植性极好的原因。但eXosip会依赖libosip2和Belledonne Communications的bctoolbox用于日志和枚举工具如果你只想要最精简的UA可以不编译eXosip而直接裸用Osip但这意味着要自己处理鉴权、注册刷新、dialog管理工作量陡增。2.2 最容易出问题的链接参数编译完安装后写demo的编译命令常常踩坑gcc -o sip_demo sip_demo.c -I/usr/local/osip/include -L/usr/local/osip/lib -leXosip2 -losip2注意链接顺序-leXosip2必须写在-losip2前面因为eXosip符号依赖osip。这个顺序问题在gcc的静态链接阶段特别容易爆出“未定义的引用”动态链接时反而有时候能蒙混过关。另一个问题是-losip2的库名——它对应的是Osip核心库不是-losip那是老版本命名如果你系统里同时装了别的SIP库务必检查ldconfig -p | grep osip确认实际加载的是哪个路径下的版本。我个人习惯是把/usr/local/osip/lib写进/etc/ld.so.conf.d/osip.conf然后执行ldconfig省得每次跑demo都设LD_LIBRARY_PATH。3. 核心机制事务状态机是理解一切的基础3.1 SIP事务到底是什么很多人调SIP demo总是盯着消息的收发看以为协议栈就是把一个请求发出去、等一个响应回来。但SIP协议栈的核心抽象是事务Transaction它负责匹配请求与最终响应、处理重传、超时和ACK确认。不用事务层做一次INVITE呼叫你会在重传和状态混乱中迷失。RFC 3261定义了四类事务客户端事务ICTInvite Client Transaction和NICTNon-Invite Client Transaction服务器端事务ISTInvite Server Transaction和NISTNon-Invite Server Transaction。Osip对每一类都实现了独立的有限状态机。以最简单的NICT为例你发出一个REGISTER请求后状态从TRYING迁移到PROCEEDING。如果服务器没有及时响应定时器E每500ms触发一次重传请求直到定时器F64*T1超时才最终放弃此时事务进入TERMINATED状态。如果没有这层状态机你就得自己在应用层维护重传逻辑而且不能正确处理服务器提前响应时取消重传的情况。3.2 Osip的transaction回调设计Osip的事件驱动机制核心在三个回调osip_set_cb_send_message发送消息、osip_set_cb_transition事务状态迁移、osip_set_cb_rtpRTP相关基本用不到。demo里最常打交道的是前两个。osip_t *osip; osip_new(osip); osip_set_cb_send_message(osip, my_send_message_cb); osip_set_cb_transition(osip, my_transition_cb); // 任何收到的原始SIP消息都需要喂给Osip osip_message_t *sip_msg NULL; osip_message_init(sip_msg); osip_message_parse(sip_msg, SIP/2.0 200 OK\r\n...); osip_transaction_t *tr NULL; osip_find_transaction_and_add_event(osip, sip_msg, tr);这里有一个新手极其容易忽略的点Osip的事务状态机是吃“事件”的不是直接吃“消息”的。每个事件由发生的transaction条件触发而osip_find_transaction_and_add_event会根据消息的CSeq方法、branch参数、响应代码自动找到事务并投递事件。手动管理这个流程时每个收到消息都要做一次消息复制和分支判断这也是为什么大多数demo会选择用eXosip而不是裸Osip。3.3 为什么说“裸Osip”适合学习“eXosip”适合实战如果只用Osip写UAC你需要自己解决大量问题注册请求要带Expires头、鉴权返回401时要计算Authorization摘要、REGISTER刷新要定时重发、消息线程和事件循环要自己协调。这些在eXosip里都已经被处理得比较完善它维护了UA的注册会话和呼叫会话并且通过eXosip_event_wait提供了统一的异步事件模型。我建议的学习路线是先用Osip跑一个UT单元测试级别的demo只为了把SIP消息的解析、构造和事务回调理解透彻然后用eXosip做实际的通信demo验证注册和呼叫。这样你既不会被顶层封装屏蔽了协议细节也不会被底层状态机拖死产品进度。4. demo代码拆解从注册到拨号的完整流程4.1 工程文件结构我用一个最小化的UA demo来说明文件结构如下sip_demo/ ├── Makefile ├── main.c # 初始化、事件循环 ├── ua_register.c # 注册与注销逻辑 ├── ua_call.c # 发起/接听/挂断呼叫 └── net_socket.c # UDP消息收发封装这个demo基于eXosip库用UDP不支持ICE和TLS对公网环境够用重点是把流程跑通。4.2 初始化与UA上下文#include eXosip2/eXosip.h struct eXosip_t *excontext NULL; int sip_init(void) { int res; // 1. 创建上下文 excontext eXosip_malloc(); if (excontext NULL) return -1; // 2. 初始化内部Osip实例 res eXosip_init(excontext); if (res ! 0) { eXosip_free(excontext); return -1; } // 3. 监听UDP 5060端口 res eXosip_listen_addr(excontext, IPPROTO_UDP, NULL, 5060, AF_INET, 0); if (res ! 0) { eXosip_quit(excontext); eXosip_free(excontext); return -1; } // 4. 设置User-Agent eXosip_set_user_agent(excontext, MySipDemo/1.0.0); // 5. 设置鉴权回调可选 struct osip_negotiation_t *negotiation NULL; eXosip_get_negotiation(excontext, negotiation); if (negotiation) osip_negotiation_set_default_connect(negotiation, 60); return 0; }eXosip_init会创建全局OSIP实例和事务集合它是eXosip一切操作的基础。注意在单例模式下全局只有一个excontext多线程调用时需要用eXosip_lock/unlock对会话数据加锁demo里可以暂不处理但产品化时这一步绝不能省。4.3 注册流程的实现注册是UA与SIP服务器建立绑定关系的过程。用eXosip实现如下int sip_register(const char *from, const char *proxy, int expires) { osip_message_t *reg NULL; int reg_id; // 1. 构建REGISTER请求 eXosip_lock(excontext); reg_id eXosip_register_build_initial_register(excontext, from, proxy, NULL, expires, reg); if (reg_id 0 || reg NULL) { eXosip_unlock(excontext); return -1; } // 2. 设置额外的Contact参数可以加instance、sip.instance等 osip_contact_param_t *contact NULL; osip_message_get_contact(reg, 0, contact); if (contact) { osip_contact_param_set_parameter(contact, expires, 3600); } // 3. 发送 eXosip_register_send_register(excontext, reg_id, reg); eXosip_unlock(excontext); return reg_id; }这里有一个很重要的逻辑eXosip_register_build_initial_register只构建消息不负责发送。发送由eXosip_register_send_register完成而它内部会为这个REGISTER创建客户端事务并把消息交给Osip的状态机管理。收到401/407挑战时eXosip会自动在内部处理吗答案是不会。你需要收到事件后手动添加鉴权信息并重发注册。这部分的正确姿势是case EXOSIP_REGISTRATION_FAILURE: // 在此获取服务器返回的挑战头计算Authorization并重发注册不过更省事的做法是用eXosip_add_authentication_info预设账号密码eXosip会在收到401后自动重发带有Authorization的请求。这对demo来说足够但它隐藏了SigComp计算和摘要重放攻击的一些细节想深入理解还是建议抓包看一遍你主动处理鉴权的完整流程。4.4 拨号呼叫的完整状态流拨号的核心是INVITE请求和它的应答。高端封装让代码简洁但这不意味着理解被简化int sip_call(const char *callee_uri, char *sdp_body, int sdp_len) { osip_message_t *invite NULL; int call_id; eXosip_lock(excontext); call_id eXosip_call_build_initial_invite(excontext, invite, callee_uri, MySipDemo/1.0.0, NULL, application/sdp); if (call_id 0 || invite NULL) { eXosip_unlock(excontext); return -1; } // 填充SDP体 osip_message_set_body(invite, sdp_body, sdp_len); osip_message_set_content_type(invite, application/sdp); eXosip_call_send_initial_invite(excontext, invite); eXosip_unlock(excontext); return call_id; }eXosip_call_build_initial_invite会预先为你填好From、To、Call-ID、CSeq初始值1、Via和Max-Forwards。千万注意INVITE是唯一一个需要两次应答的请求第一次是100 Trying然后是最终响应180/200/486等最终响应到达后客户端事务会进入COMPLETED状态接着需要发送ACK确认。eXosip已经自动处理了ACK的生成你不用自己造ACK但如果你用裸Osip这一步是必坑。事件循环里处理振铃和接通for (;;) { eXosip_event_t *ev eXosip_event_wait(excontext, 0, 50); if (ev NULL) continue; switch (ev-type) { case EXOSIP_CALL_INVITE: // 收到来电应该回180然后根据用户行为决定是否200ACK eXosip_call_send_answer(excontext, ev-cid, ev-did, 180, NULL); break; case EXOSIP_CALL_ANSWERED: // 远端接听准备RTP break; case EXOSIP_CALL_CLOSED: // 通话结束释放资源 break; default: break; } eXosip_event_free(ev); }这里有个细节eXosip_event_wait的第二个参数表示阻塞时间demo传0是不会阻塞的配合50ms超时轮询适合嵌入到UI刷新或控制循环里。但注意eXosip_event_wait返回的事件需要eXosip_event_free释放不释放会造成内存泄漏跑一晚上demo内存涨几百MB的罪魁祸首往往就是它。4.5 Makefile的一个简洁版本CC gcc CFLAGS -Wall -g -I/usr/local/osip/include LDFLAGS -L/usr/local/osip/lib -leXosip2 -losip2 SRCS main.c ua_register.c ua_call.c net_socket.c OBJS $(SRCS:.c.o) sip_demo: $(OBJS) $(CC) -o $ $(OBJS) $(LDFLAGS) clean: rm -f $(OBJS) sip_demo如果你要加TLSSIPS支持CFLAGS里要加-DHAVE_OPENSSLLDFLAGS里加-lssl -lcrypto同时configure阶段要带--enable-openssl。这个我建议在demo跑通之后再折腾别一开始就上难度。5. 实测中绕不开的坑消息、线程与调试5.1 消息体被截断和SDP解析失败UDP承载的SIP消息理论上受MTU限制超过512字节建议走TCP或TLS。但实际demo环境里很多人图省事一直用UDP结果在测试带较大SDP的呼叫时发现对端收不到完整body。排查方式很简单抓包看原始报文如果UDP包超过了1500字节在传输中被分片某些NAT设备会直接丢弃ICMP分片错误报文导致对端永远等不到完整消息。我的经验是demo阶段可以不管但如果你要接真实的SIP trunk务必在传输层做协议适配回调——Osip的osip_set_cb_send_message里看到的发送请求小于MTU直接UDP大了自动切TCP。这个逻辑在裸Osip里自己写大概两百行借助eXosip可以用eXosip_set_option设置传输协议。5.2 多线程模式下的事件竞争很多demo一开始是单线程事件循环跑着跑着遇到UI阻塞就想改成多线程一个线程跑eXosip_event_wait循环一个线程做呼叫控制。结果就是各种偶发的崩溃和状态错乱。原因在于eXosip内部维护的dialog和transaction集合不是线程安全的没有加锁的情况下并发调用eXosip_call_*和eXosip_event_wait轻则丢事件重则野指针。解决方式有两种一是传统做法所有eXosip调用包裹在eXosip_lock/excontext和eXosip_unlock/excontext之间二是更优雅的方案所有业务请求通过一个控制队列投递给唯一的事件循环线程处理业务线程不直接碰eXosip API。后者我觉得值得推荐因为它把并发问题从根源上隔离了后续排查bug也容易得多。5.3 定时器参数和注册刷新策略RFC 3261默认T1500msT24s定时器D最长32s。这些值在Osip里可以通过osip_set_timers调整。实际测试中如果SIP服务器和客户端都部署在弱网环境丢包率高、延迟上200ms默认的重传策略会导致注册和呼叫建立时间拉到好几秒。此时可以适当调大T1到1s甚至2s减少无效重传。注册刷新也是demo里常被忽略的坑。Expires头的时间到了服务器会清掉绑定UA必须提前刷新。合理做法是设置一个注册提前量比如Expires设3600s那么每隔3300s就主动重新注册留出300s的余量应对网络抖动。在eXosip里这可以用定时器在业务层实现没有内置的自动刷新机制。5.4 调试三板斧日志、抓包、状态转储日志方面Osip提供OSIP_TRACE级别的输出eXosip库层面也能打开bctoolbox的日志。但实话说它打印的东西偏底层不适合直接定位业务问题。我习惯在应用层每个事件处理分支里加一行简短的日志事件类型、call_id、dialog_id、时间戳。一条通话的完整生命周期一目了然。抓包工具优先用tcpdump或Wireshark重点看信令的时序是否符合预期。很多终端侧的“对方没响应”问题抓包一看才发现是请求根本没发出去或者是Via头里缺失branch参数被服务器拒了。状态转储是最后的大招osip_transaction_t结构体里记录了当前状态、起始时间、重传计数写一个dump函数把所有活跃事务的状态打印出来基本能定位80%的事务状态机卡死问题。这部分代码不复杂但很值得加到debug版本里。6. 从demo到产品的进阶路线跑通demo之后接下去的路你可能会面对这几个问题身份认证和HTTP Digest的安全实现、RTP媒体路径的协商NAT穿透、ICE、掉线重注册和心跳机制、并发多呼叫的资源管理等。这些在eXosip之上没有银弹每个都要结合自己的业务场景设计。我的建议是先做一次信令层的压力测试用开源工具如SIPp构造多路并发呼叫观察Osip的事件循环吞吐量和内存占用变化。一般来说单核下Osip处理纯信令可以扛住几百路每秒的消息解析但加上业务逻辑、数据库和媒体处理之后瓶颈往往在应用层而不是协议栈。如果你能把事务处理和应用逻辑完全解耦后期的扩展空间会大得多。还有一点想提醒的是不要在demo阶段的代码上直接叠加功能。demo的首要目标是验证协议栈可用、跑通信令流它的错误处理、内存管理和配置管理都不够健壮。我见过太多人把demo当生产框架用最后代码里到处是goto和全局变量。正确做法是用demo验证方案可行性然后重新组织代码架构把Osip/eXosip作为底层模块接入而不是被demo的写法框住。本文还有配套的精品资源点击获取