基于GNU oSIP从零搭建轻量SIP服务器:注册、呼叫与媒体协商全解析
发布时间:2026/10/7 4:41:55 锦皓数字建站

很多人第一次接触SIP协议是被“SIP服务器”这四个字吸引来的结果一搜资料一半在讲苹果电脑系统里的SIP安全功能怎么关另一半是Asterisk、FreeSWITCH怎么安装配置。真正想从协议底层把SIP服务器搞明白亲手把注册、呼叫、媒体协商这一整套流程跑通资料反而少得可怜。这篇文章我拿实际做过的一个方案来聊基于GNU oSIP 2.3.1.0也就是常说的osip2库从零写一个轻量SIP服务端能完成UA注册、呼叫建立并把SIP信令和RTP媒体流的分工配合讲清楚。先说清楚定位OSIP不是一个装完就能跑的服务器程序它是一个C语言的SIP协议栈库。你可以基于它实现自己的UA用户代理、Registrar注册服务器、Proxy代理服务器。这篇文章要做的就是用它搭一个可以跑起来的最小SIP Server框架。适合这么几类人想深入理解SIP协议流程的开发者、需要在嵌入式设备或定制环境里集成SIP能力的人以及搭了Asterisk但总被诡异问题折腾得想搞懂底层原理的同学。1. 先搞清楚你要搭的是什么OSIP能帮你做什么1.1 一句话说清Registrar、Proxy和UA的区别很多新手把“SIP服务器”当成一个黑盒子其实这个说法很模糊。SIP体系里有几个角色经常被混着讲UAUser Agent是终端负责发起和接收呼叫Registrar是注册服务器负责记录“某个号码当前在哪个地址”存成一张号码与联系地址的映射表Proxy是代理服务器负责把INVITE请求转发给正确的被叫方B2BUA则是把整个会话拆成两段来控制的服务器Asterisk和FreeSWITCH本质上都是B2BUA。用OSIP做服务端最常见的组合是“Registrar 轻量Proxy”。客户端把自己的号码和IP端口注册到服务器别人呼这个号码时服务器去注册表里查到这个号码当前所在的地址再把INVITE转发过去。媒体流建立后RTP可以直接在终端之间跑也可以强制经过服务器转发后者通常用在NAT穿透或者录制场景里。这篇文章实现的框架就是用OSIP搞定信令部分媒体部分先做直连。1.2 为什么不直接用Asterisk或者FreeSWITCH这个问题我每次讲OSIP都会被问。如果你只是要快速搭一套电话系统那确实应该用Asterisk或者FreeSWITCH它们开箱即用、功能齐全别自己折腾协议栈。但如果你面临的是以下场景要做一个嵌入式软电话模块、要在自己的程序里嵌入SIP注册和呼叫能力、要在一个已有C/C服务里集成SIP信令或者你就是想彻底搞懂SIP消息该怎么构造、怎么解析、状态机怎么跳那么OSIP这种协议栈级库才是正解。打个比方Asterisk是已经装修好的精装房你拎包入住OSIP是建材和图纸你得自己砌墙。前者适合普通用户后者适合需要定制户型的人。OSIP的价值在于它把SIP的语法解析、消息构造、事务状态机、dialog管理都封装好了你不用自己拼字符串、写状态机但业务逻辑收到REGISTER怎么办、收到INVITE要不要转发全得自己写。这正是理解SIP协议最好的方式。1.3 搭建前需要具备的基础用OSIP写服务端必要的知识准备有这么几块一是扎实的C语言指针和结构体基础OSIP大量使用链表和回调函数不熟悉的话读代码会很吃力二是基本的Socket编程概念能理解UDP和TCP的差异就行三是对SIP协议的基本术语有了解比如INVITE、REGISTER、200 OK是什么意思。没有第三点也能对着代码慢慢查但有了之后效率会高很多。另一个容易被忽视的点是Linux环境。虽然OSIP支持跨平台但eXosip在Linux上的兼容性最好调试工具也多。我建议直接用Ubuntu/Debian这类系统做实验后面的命令和路径都以它们为例。Windows下折腾MinGW或者MSVC也不是不行只是会多花很多时间在环境问题上没必要。2. 搭建前必须懂的SIP与RTP配合原理2.1 一个“约会议室”的类比要理解SIP和RTP怎么配合先记住一句话SIP管信令RTP管媒体。信令是“见面前的沟通”媒体是“见面后的对话”。我用约会议室的场景类比SIP就像是前台帮你约会议室的过程——你要告诉前台谁参会、几点、在哪个房间前台再去联系对方确认RTP则是你俩进了会议室之后的实际讲话声一旦开始对话前台就不再参与你们说的内容了。这个类比能解释很多现象。比如为什么SIP服务器挂了已经建立的电话还能继续通——因为媒体流是端到端的不需要经过SIP服务器。再比如为什么NAT环境里经常出现“电话能拨通但听不到声音”——因为SIP信令顺利到达了但RTP媒体包找不到对方两个人“约上了会议室但彼此听不见对方说话”。后面排查问题的时候这个思路特别有用。2.2 一次完整通话的SIP消息流程用OSIP抓一个真实呼叫你会看到这样一组消息主叫UA先发INVITE里面带着主叫的媒体能力描述SDP服务器或者被叫立即回100 Trying表示正在处理被叫振铃回180 Ringing被叫接听回200 OK里面带着被叫的SDP主叫收到200 OK后回一个ACK双方确认无误RTP媒体流开始传输通话结束后任何一方发BYE对方回200 OK整个会话结束。这个流程里最值得留意的是ACK。业内常说SIP的 INVITE 三次握手是 INVITE / 200 / ACK而不是 TCP 的三次握手。因为INVITE的应答可能不是最终的比如先回180、183最终才回200所以需要一个显式的ACK来确认最终响应。用OSIP时如果你不处理ACK或者交给协议栈自动处理有时候会导致媒体流已经建立但UAC还在重传INVITE这个问题后面细聊。2.3 SDP协商才是SIP和RTP之间的“接头暗号”SIP和RTP之间靠SDPSession Description Protocol来桥接。INVITE里的SDP是主叫的“Offer”200 OK里的SDP是被叫的“Answer”一应一答就商量好了用什么IP、什么端口、什么编码格式来传媒体。典型的一段SDP长这样v0 oalice 2890844526 2890844526 IN IP4 192.168.1.100 cIN IP4 192.168.1.100 maudio 49170 RTP/AVP 0 101 artpmap:0 PCMU/8000 artpmap:101 telephone-event/8000我来拆开解释cIN IP4这一行告诉对方RTP包发到什么IPmaudio 49170告诉对方RTP包发到什么端口49170artpmap:0 PCMU/8000说明支持G.711 μ-law音频编码格式编号是0101号格式是DTMF按键事件。协商的逻辑就是双方对比各自支持的编码选一个交集用。有几个约定俗成的规则你得记住SIP信令默认走UDP或TCP的5060端口RTP媒体一般走偶数UDP端口比如10000、10002而RTCP控制报文走相邻的奇数端口10001、10003。RTP端口范围传统上推荐在40000到50000之间但实际部署时完全可以自己配置关键是防火墙要记得放开。用OSIP搭服务器时你可以在这条信息链路上做文章比如把SDP里的IP改成自己的公网IP做代理这就能实现媒体中转和NAT穿透。3. 环境准备与编译部署3.1 版本选择为什么是2.3.1.0我选OSIP 2.3.1.0不是随手挑的。GNU oSIP当前有多个分支2.x系列是经典稳定分支API成熟、文档和示例代码多、依赖极少非常适合学习。4.x系列主要变化是内部框架重构API差异不小如果你去看老帖子照着敲代码会遇到不少编译错误。在我们国内社区的讨论里2.x用的人最多遇到问题也好找到参考。和OSIP配套的通常是eXosip库。eXosip可以理解为OSIP的上层封装它把注册、呼叫、订阅这些常用流程封装成更简单的API同时内部处理了鉴权、重传、超时这些脏活。我这里用的是eXosip2 3.3.0和osip2 2.3.1能较好匹配。如果你选择直接用裸OSIP也是可以的但代码量会大很多本文以eXosip为主来写。3.2 编译安装OSIP和eXosip的完整过程先装依赖。在Ubuntu/Debian上执行下面这串命令sudo apt-get update sudo apt-get install -y build-essential libtool pkg-config然后从GNU官网或者GitHub镜像下载osip 2.3.1的源码包解压后按经典三步走wget https://ftp.gnu.org/gnu/osip/osip-2.3.1.tar.gz tar -xzf osip-2.3.1.tar.gz cd osip-2.3.1 ./configure --prefix/usr/local make sudo make install--prefix/usr/local是让它把头文件和库装到系统默认搜索路径省得后面编译出各种找不到头文件的幺蛾子。如果你想装在自定义目录后面编译eXosip时就得通过环境变量告诉编译器去哪儿找osip的头文件和库麻烦不少新手建议直接用默认路径。接着编译eXosip步骤几乎一样wget https://download.savannah.gnu.org/releases/exosip/exosip-3.3.0.tar.gz tar -xzf exosip-3.3.0.tar.gz cd exosip-3.3.0 ./configure --prefix/usr/local make sudo make install编译完成后需要刷新一下动态库缓存否则运行时可能报找不到libosip2.sosudo ldconfig3.3 验证安装是否成功验证其实很简单用pkg-config查询pkg-config --modversion libosip2如果输出2.3.1就说明库装好了。再写一个最简单的测试程序确认头文件和库都能正确链接#include stdio.h #include osip2/osip.h int main(void) { osip_t *osip NULL; int ret osip_init(osip); if (ret ! 0) { printf(osip_init failed: %d\n, ret); return 1; } printf(osip initialized successfully, version %s\n, osip_get_version()); osip_release(osip); return 0; }编译命令gcc test_osip.c -o test_osip -losip2能打印出版本号就说明环境OK。这一步别看简单很多人后面编译不过都是因为跳过了验证装完库就写大段业务代码最后报错根本分不清是库的问题还是自己的问题。4. 核心代码从UA注册到呼叫转发4.1 初始化协议栈并监听5060端口用eXosip写服务端起点是初始化协议栈和绑定端口。完整的最小初始化代码长这样#include stdio.h #include stdlib.h #include eXosip2/eXosip.h static struct eXosip_t *ctx; int init_sip_server(const char *local_ip, int port) { ctx eXosip_malloc(); if (!ctx) { printf(eXosip_malloc failed\n); return -1; } int res eXosip_init(ctx); if (res ! 0) { printf(eXosip_init failed: %d\n, res); return -1; } res eXosip_listen_msg(ctx, local_ip, port, AF_INET, IPPROTO_UDP); if (res ! 0) { printf(eXosip_listen_msg failed: %d\n, res); return -1; } printf(SIP server listening on %s:%d\n, local_ip, port); return 0; }eXosip_listen_msg这一步把UDP 5060套接字建好并进入监听状态。这里有个小坑如果你监听的IP填0.0.0.0eXosip有时会在回应消息时选错出口IP导致对方收到INVITE但回给你的地址不可达。多网卡机器上尤其明显建议直接填具体的内网IP比如192.168.1.10。下面的事件循环里所有的事件都是从这个监听套接字上收上来的。4.2 处理REGISTER注册请求在SIP服务器里收到REGISTER是每个UA上线要做的第一件事。在eXosip里REGISTER请求由事件队列抛给我们处理。服务器的核心任务是解析消息体把用户标识和联系人地址记入注册表然后回一个200 OK。这里展示一个注册表存储和应答的关键逻辑#include osip2/osip.h #include osipparser2/osip_uri.h static osip_list_t user_bindings; typedef struct user_binding_s { char username[128]; char contact_uri[256]; int expires; } user_binding_t; int handle_register(osip_message_t *reg) { osip_from_t *from reg-from; osip_uri_t *from_uri from-url; char *username from_uri-username; osip_contact_t *contact NULL; osip_message_get_contact(reg, 0, contact); if (!username || !contact) { return -1; } user_binding_t *binding (user_binding_t *)osip_malloc(sizeof(user_binding_t)); snprintf(binding-username, sizeof(binding-username), %s, username); snprintf(binding-contact_uri, sizeof(binding-contact_uri), %s, contact-url-host); binding-expires contact-expires ? atoi(contact-expires) : 3600; osip_list_add(user_bindings, binding, -1); printf([REGISTER] user%s contact%s expires%d\n, binding-username, binding-contact_uri, binding-expires); return 0; }这个简化实现里有个细节值得说真正的注册服务器必须按username去重同一个号码只保留最新一条记录旧的contact要覆盖。而且注册表要带过期时间过期后自动删除绑定。你可以在事件循环里加一个定时器每分钟扫一遍注册表把expires到期的条目清掉。否则一个UA异常离线服务器上还会残留它的注册信息别人呼叫就会送到一个死地址去。回200 OK也很直接osip_message_t *answer NULL; eXosip_lock(ctx); eXosip_message_build_answer(ctx, answer, ev-tid, 200, NULL); eXosip_message_send_answer(ctx, ev-tid, answer); eXosip_unlock(ctx);注意eXosip_lock/eXosip_unlock这对方法。eXosip内部有一个全局上下文多线程环境下必须加锁否则并发REGISTER时链表操作可能崩溃。官方回调里经常会漏写但实测下来高并发场景必炸这个坑我踩过。4.3 收到INVITE后如何把呼叫转发给被叫这是整个SIP服务器最核心的地方。收到INVITE后服务器要去注册表里查被叫号码的当前绑定地址找到后作为代理把INVITE转发出去。简化的做法是提取Request-URI里的被叫号码查询注册表如果查不到就回404或者480查到了就把INVITE转发给绑定地址。代码如下int handle_invite(osip_message_t *invite) { osip_uri_t *req_uri invite-req_uri; char *callee req_uri-username; user_binding_t *target find_binding_by_username(callee); if (!target) { printf([INVITE] no binding found for %s\n, callee); return -1; /* 上层回404 */ } osip_message_t *invite_copy NULL; osip_message_clone(invite, invite_copy); osip_uri_t *new_uri osip_uri_clone(target-contact_uri); osip_message_set_uri(invite_copy, new_uri); eXosip_lock(ctx); eXosip_call_send_invite(ctx, invite_copy); eXosip_unlock(ctx); return 0; }这里只做了Request-URI的替换实际生产环境的代理还要处理Via头添加、Record-Route插入、鉴权检查等。用OSIP做这些操作要小心很多初学者直接在原消息上动手改结果导致后续的重传和ACK对应不上因为SIP要求每个分支transcation有独立的branch参数。最稳妥的做法是克隆消息再改像上面代码一样。4.4 事件循环的最终骨架把所有事件串起来一个最小服务器的主循环长这样int main(int argc, char **argv) { if (init_sip_server(0.0.0.0, 5060) ! 0) return -1; osip_list_init(user_bindings); while (1) { eXosip_event_t *ev eXosip_event_wait(ctx, 0, 200); if (ev NULL) continue; eXosip_lock(ctx); switch (ev-type) { case EXOSIP_REGISTRATION_SUCCESS: printf([EVENT] registration success\n); break; case EXOSIP_MESSAGE_NEW: if (MSG_IS_REGISTER(ev-request)) { handle_register(ev-request); send_200_to_tid(ev-tid); } break; case EXOSIP_CALL_INVITE: if (handle_invite(ev-request) ! 0) { send_404_to_tid(ev-tid); } break; default: break; } eXosip_unlock(ctx); eXosip_event_free(ev); } return 0; }别看这个循环简单它已经包含了SIP服务器的主要骨架事件监听、REGISTER处理、INVITE转发。把这个跑通再用两个软电话注册上来互拨你会发现呼叫流程居然能走通了这一刻对SIP协议的理解会上一个台阶。5. 实操中常见问题与排查技巧5.1 SIP 480 Temporarily Unavailable 的真相480是新手最常撞上的SIP错误码每次都让人抓狂。字面意思是“暂时不可用”但实际场景里它出现的频率高到不合理。最常见的原因是被叫方根本没注册上来服务器在注册表里查不到对应记录于是按既定策略回480另一种是被叫UA软件还在启动中、或者网络不通代理联系不上被叫的真实地址还有一种是运行中的服务器策略故意对某些号码回480用于限制呼叫时段或者做防骚扰拦截。排查480要有顺序。第一步查服务器的注册表确认被叫号码是否注册、过期时间是否已经过了第二步在服务器上用tcpdump抓包看INVITE是否真的被转发给了被叫被叫有没有回任何响应第三步检查代理转发时构造的Request-URI和Route头有没有写错很多480其实是服务器把INVITE转发到了一个错误端口对方直接丢弃。我自己的经验是有一半以上的480是因为注册过期没有清理服务器拿着过期的contact去转发对方当然不理你。如果你是在自己写的OSIP服务里遇到480建议在你的代理转发逻辑里加详细日志把“查注册表命中结果”和“最终转发的目标地址”都打出来基本一眼就能定位问题。5.2 小心macOS里的“SIP”和SIP协议不是一回事搜“SIP怎么关闭”搜进来的同学注意了这里必须澄清一个天大的误会。macOS系统里的SIP全称是System Integrity Protection是苹果的系统安全机制防止恶意软件篡改系统文件。它和本文讲的Session Initiation Protocol会话初始协议除了简称相同没有任何关系。如果你在搜索引擎里查“m4 macos怎么关闭sip”那你要关闭的是macOS的系统完整性保护需要在恢复模式里操作跟VoIP开发完全是两条赛道。在终端执行csrutil disable改的是系统安全设置不会让你的SIP电话有任何变化。如果你要做的其实是VoIP开发、搭SIP服务器那请回到这篇文章我们聊的是同一个简称下完全不同的技术领域。5.3 端口、NAT与防火墙SIP服务器最常见的坑SIP服务跑在UDP 5060端口但真正让你头疼的不是5060而是RTP端口。很多人的服务器在局域网里一切正常一上公网就出现“能呼通但听不见对方说话”的经典问题。原因往往是RTP端口被防火墙挡住了。SIP信令到了对方但音频媒体包在防火墙那被丢弃两边拿着SDP里协商好的端口互相ping不通。解决方案是在SDP协商阶段解决地址改写。如果你的服务器在NAT后面UA发给你的SDP里是192.168.x.x这类私网地址被叫方拿到这个地址根本无法回包。常见的做法有两种一是在代理服务器中转嫁改写——收到INVITE时把SDP里的cIN IP4 192.168.x.x改成服务器的公网地址再把RTP媒体流经过服务器中转到被叫二是给UA配置STUN服务让UA拿到自己的公网映射地址填进SDP。前者适合服务器代转发后者适合媒体直连。防火墙规则里至少要放开这些UDP 5060SIP信令以及RTP媒体端口范围比如UDP 10000到20000。别只开一个端口RTP协商是动态端口开少了照样通不了。还有一个容易被忽视的坑多网卡环境下SIP响应包走了错误的出口接口对方收到响应时IP地址对不上。这类问题可以通过在初始化时把监听IP明确指定为对外的那个IP来解决。5.4 消息重传和定时器参数的实际调优经验SIP在UDP上的可靠性靠重传机制代价是傻等。默认参数下T1是500毫秒重传间隔按2倍递增总超时可能拖到30秒以上。你调测的时候如果发现一个请求失败要等30秒才能收到最终错误别急这是SIP的“稳健性”在惩罚你。在OSIP里可以通过定时器配置改变默认值。开发调测阶段我把INVITE事务的最终响应等待时间缩短到3秒这样UA侧能快速收到失败响应日志循环也跑得快osip_t *osip NULL; osip_init(osip); /* 调整重传定时器 */ osip_set_transaction_timers(osip, 200, 1600, 3200);200毫秒的T1在局域网内足够用重传4次还收不到响应就直接判定事务失败。但在公网环境这个参数要改回默认值否则丢包率稍高就会误判。压缩包和NAT超时也是老话题UDP的NAT映射通常60秒就过期了如果你想让UA长时间待机还能被叫到注册周期建议设置在120秒以内服务器端通过定时刷新机制让映射不过期。5.5 常见问题速查表把实际运维中遇到的高频问题整理成了一张表方便对着查现象可能原因排查方向注册成功但呼叫返回480注册表过期/被叫不在线查注册表绑定和expires能打通但无声音防火墙挡RTP端口或SDP地址错误检查RTP端口放行和SDP改写呼叫建立后掉线NAT映射超时/注册过期缩短注册周期开启NAT keepaliveACK后INVITE重传服务器没回ACK或UA没收到抓包确认ACK发送方部分用户无法拨打号码格式不匹配检查URI用户名提取和路由表多网卡环境偶发不通响应包走错网卡绑定具体监听IP这张表覆盖了我在用OSIP搭建过程中遇到的80%问题。剩下的20%通常是代码自己的逻辑bug需要靠日志定位。6. 从最小框架到产品级服务器的扩展路线6.1 加入RTP转发与媒体控制前面说过最小框架让RTP直连这在大多数公网场景里行不通因为穿透问题。把服务器升级为媒体代理核心是拿到SIP信令里协商好的SDP参数然后自己开一对RTP端口把收到的媒体包从一端搬到另一端。实现上可以接jrtplib这类现成的RTP库也可以直接用裸socket收发包复制。要注意的是必须同时修改INVITE和200 OK两侧的SDP让双方都把媒体发到服务器上然后服务器再把媒体流双向转发这样NAT穿透问题基本解决。媒体代理最头疼的是开销和延迟。每路通话要占两份带宽和内存CPU开销也不小。你可以在代码里对RTP包做批量拷贝优化也可以直接引入GStreamer来做更精细的媒体处理比如录音、混音、语音识别。OSIP负责信令GStreamer负责媒体这是很多定制SIP服务端常用的组合。6.2 增加摘要鉴权避免服务器变成“免费电话亭”没有鉴权的SIP服务器在公网上就是一台免费电话亭谁都能借用它拨打任意号码甚至被用来做话务批发后果很严重。OSIP自带的摘要鉴权方法足够解决这个问题服务器在收到REGISTER或INVITE后先判断消息有没有Authorization头没有就回401/407挑战UA用用户名密码算出摘要返回服务器校验通过才继续处理。在osip里做摘要校验不需要自己实现MD5和HA1用osip_negotiation相关接口就行。要点是先给UA回复一个带realm参数的挑战保存客户端发来的nonce和cnonce校验时把数据库里存的密码和其他参数一起算一遍比对结果。这个流程初看繁琐但做一次就懂了它是SIP安全的地基。6.3 生产环境选型建议OSIP适合什么场景什么时候该换方案最后说点掏心窝子的建议。OSIP适合做嵌入式UA、定制化信令网关、学习协议实现这些场景下它轻量、可控、源码可读非常合适。但如果你要搭建一个面向大量用户的公网语音平台需要高可用、分布式路由、媒体服务器集群、计费系统那么直接用FreeSWITCH、Asterisk或者Kamailio这类成熟产品才是正道别在协议栈上重复造轮子。我自己在做一个内网语音调度项目时用过OSIP规模不大、定制需求多用起来很顺手。但后来项目扩容到几万并发时最终还是把信令层换成了Kamailio媒体层交给FreeSWITCH。这个决策不是OSIP不好而是工具选型要匹配场景。记住OSIP是积木产品是房子用积木搭个模型没问题真要住人还是得靠建筑队。说到实际体会我在用OSIP搭建这套最小SIP服务器时最花时间的不是编译也不是写代码而是理解SIP的异步模型。它和普通HTTP请求响应的思维完全不同每个消息都可能重传每个事务都有自己的状态机一开始总忍不住用“一问一答”的思路去想问题结果到处碰壁。后来把时序图打出来逐行对着代码过了一遍才终于理清INVITE、100 Trying、180 Ringing、200 OK、ACK这条链路上每一步的转换。熬过这一关再去看任何SIP服务器哪怕是几万行代码的Kamailio心里都有底了。如果你是刚开始接触这套东西我的建议是先别追求功能完整先把“注册呼叫挂断”这条最基本的路跑通哪怕代码丑、没有鉴权都没关系。跑通一次你对SIP生态的理解就和纸上谈兵完全不一样了。最后再分享一个小技巧调试时至少准备两个软电话客户端一个Linphone一个MicroSIP遇到诡异问题时两个客户端交替测试比较容易区分是服务器的问题还是UA兼容性的问题。这套小框架跑通之后你想往哪扩展都行加鉴权、加RTP转发、加WebRTC网关路已经铺好了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。