先聊句题外话。很多人搜“SIP服务器”教程,搜出来的全是Asterisk、FreeSWITCH怎么装、怎么配分机,确实这些是生产环境里的主流方案。但如果你是为了学习SIP协议本身,或者想在嵌入式设备、内网小工具里塞一个可控性极强的信令入口,那直接用重型交换机往往有点“杀鸡用牛刀”。这时候就绕不开oSIP——一个纯C实现、专注于SIP协议栈的库。我折腾了一遍oSIP 2.3.1.0之后,最大的感觉是:它不是终点,但它是理解SIP协议最好的起点。这篇就从编译环境、核心架构到代码实现,完整记录我搭一个轻量级SIP注册服务器的全过程,最后附带我在实际排查中遇到的高频坑。适合有C语言基础、想深入SIP协议细节,或者在受限环境里需要自建信令服务的开发者参考。
1. 先搞清楚oSIP是什么,再说“搭建”
1.1 oSIP不是服务器软件,是协议栈库
这个名字很容易误导人。初次接触的时候我也以为oSIP是一套类似OpenSIPS的完整SIP服务器,下载编译完就能跑起来,结果折腾半天发现连个可执行文件都没有。实际上oSIP全称是GNU oSIP,底层库提供的是SIP消息的解析、构造、事务状态机(ICT、NIST、IST、NICT四种事务)、对话管理等协议层能力,它不负责业务逻辑,更不负责给你存账号、做路由。你想让服务器做什么——收到REGISTER回200、收到INVITE转给谁——那是你自己的代码该决定的。
这种设计的好处和坏处都很明显。好处是它把协议最复杂的部分封装好了,你不需要手工拼接SIP报文、不需要自己维护事务重传定时器、不需要抠Via/Contact头域的顺序,oSIP全部帮你处理。坏处是你得自己写一个事件循环,自己定义注册表结构,自己在回调里决定业务的下一步。这正好是学习协议的最佳路径:协议栈干协议栈的活,业务逻辑你亲手写,每一步都看得见摸得着。
1.2 和Asterisk、OpenSIPS、PJSIP怎么选型
我用一张表来说明oSIP的定位区别,这在实际项目里非常重要。
| 方案 | 形态 | 适合场景 | 上手难度 |
|---|---|---|---|
| Asterisk / FreeSWITCH | 完整通信平台 | PBX、IVR、媒体处理、外呼线路对接 | 低(配置为主) |
| OpenSIPS / Kamailio | 高性能SIP服务器 | 大规模软交换、路由、计费、负载均衡 | 中高(脚本配置) |
| PJSIP | SIP协议栈(C/C++/Python绑定) | 嵌入式终端、客户端SDK | 中 |
| oSIP + eXosip | SIP协议栈(纯C) | 学习协议、定制协议行为、极简信令入口 | 中高(需写业务) |
如果你要快速上线一个公司内部的电话系统,直接装Asterisk,别折腾oSIP。但如果你要做一个极简的SIP注册服务器、要在路由器上跑一个信令代理、或者你想彻底搞懂SIP的REGISTER/INVITE事务是怎么回事,oSIP这条路非常值得走。它不依赖庞大的运行时,编译出来的库体积小,纯C接口在嵌入式平台也好集成,这些是它在今天仍然没有过时的原因。
1.3 基于oSIP搭配eXosip,才是搭建服务器的合理组合
直接用裸oSIP做服务器不是不行,但你得自己管理socket收发、自己把收到的数据变成osip_message_t结构、再自己调度事务层,代码量非常大。实际开发中几乎没人这么干,都会搭配eXosip——它是基于oSIP的上层封装库,负责socket通信、事件生成、注册和会话的简化处理。eXosip把oSIP的细节包裹起来,向上层提供eXosip_event_t事件,你的工作就是在事件循环里不断取事件、判断事件类型、调用业务逻辑。
我搭的这个注册服务器就是“oSIP 2.3.1.0 + eXosip 2”的组合。下载的源码包里还会看到libosip2和libeXosip2两个目录,前者是协议栈本体,后者是我们主要使用的API库。官网发布的版本很多,2.3.1.0属于稳定老版本,兼容性不错,编译时也没遇到特别离谱的问题,适合教学和内部系统使用。
2. 编译环境准备:从源码构建oSIP 2.3.1.0及配套扩展库
2.1 先装一套干净的基础编译工具
我建议在Linux环境(Ubuntu 22.04/24.04,或者WSL2)下操作,千万别在Windows原生环境里硬啃,osip的configure脚本在MSVC下折腾起来太难受。基础依赖清单如下:
sudo apt update sudo apt install -y build-essential autoconf automake libtool pkg-configpkg-config很关键,后面链接eXosip的时候会用到它找头文件和库文件的路径。libtool和autoconf也是必须的,oSIP源码包里的autogen.sh就是靠它们生成configure脚本的。如果你只装过gcc,直接跑./autogen.sh会疯狂报“command not found”,那不是源码的问题,是你缺工具。
另说一句,如果你是Ubuntu用户,其实也可以用apt直接装现成的开发包:libosip2-dev和libexosip2-dev,装完就能写代码免去编译。但为了精确控制版本(题目里的2.3.1.0),同时想看清编译过程,我还是建议手动从源码走一遍。编译的时候遇到错误是好事,能让你看到库内部是怎么组织的。
2.2 下载源码并编译安装
去GNU的官方镜像站下载libosip2-2.3.1.0.tar.gz。我习惯先建一个目录统一管理:
mkdir -p ~/sip && cd ~/sip wget https://ftp.gnu.org/gnu/osip/libosip2-2.3.1.0.tar.gz tar -zxvf libosip2-2.3.1.0.tar.gz cd libosip2-2.3.1.0 ./autogen.sh ./configure --prefix=/usr/local make -j4 sudo make install到这一步,oSIP协议栈库已经装到/usr/local/lib和/usr/local/include下面了。如果你只下载了libosip2,没有下载配套的libeXosip2,那你要走的路会比想象中长得多——所有的SIP消息都得自己用dialogue和transaction来拼凑,写起来和手写状态机差不多。所以建议把libeXosip2-2.x.x的源码也一并下载编译:
cd ~/sip wget https://download.savannah.gnu.org/releases/exosip/libeXosip2-2.2.2.tar.gz tar -zxvf libeXosip2-2.2.2.tar.gz cd libeXosip2-2.2.2 ./autogen.sh ./configure --prefix=/usr/local make -j4 sudo make install sudo ldconfig编译完最好验证一下系统能不能找到这两个库。一个常用的方法是先确认pkg-config的搜索路径是否包含/usr/local/lib/pkgconfig/,如果configure出来的代码找不到osip2.pc,十有八九是这个原因。可以把下面这行加到~/.bashrc里:
export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH然后分别执行pkg-config --modversion osip2和pkg-config --modversion eXosip2,能输出版本号就说明环境OK。
2.3 我在这步踩过的编译坑
第一个坑是缺libtool。跑autogen.sh的时候报“libtoolize: command not found”。解决方式就是装libtool-bin,不仅仅是libtool。Debian/Ubuntu上经常有这个坑,装完libtool还要确认libtoolize存在。
第二个坑是undefined reference把整个链接阶段打断了。这通常是链接顺序导致的:GCC链接库时,右边的库不能依赖左边的库。你用-losip2 -leXosip2还是反过来,取决于你代码里的调用关系。我习惯的写法是-lleXosip2 -losip2,因为eXosip依赖osip。当然,实际工程里用pkg-config --libs eXosip2自动展开最稳妥,不会写错。
第三个坑是权限。make install时如果不加sudo,哪怕configure时指定了/usr/local,也会因为/usr/local目录没写权限而失败。所以要么全加sudo,要么把/usr/local改成当前用户可写,别混着来,混着来容易遇到头文件或库文件只装了一半的情况,后续编译会找得莫名其妙的诡异错误。
3. 最小可用服务器怎么设计:模块划分与事件循环模型
3.1 需求定死:先只做“能注册”,再做“能路由”
刚开始别想一口气把INVITE、BYE、CANCEL、REFER全部实现,那样代码很难收敛。我给自己定的第一个里程碑是:一个SIP终端可以用任意账号注册到我的服务器,服务器能正确返回200 OK,并且服务器端能观察到这个注册事件。第二个里程碑才是收到INVITE后能转发到另一个已注册的终端。
这种“先注册后路由”的做法背后是有道理的。SIP终端注册成功了,意味着服务器的网络监听、消息解析、事务层应答都处于健康状态;再去加路由逻辑,你只需要关注业务层,不用再纠结底层协议问题。把大问题拆成两个小问题,每个阶段都能验证。
3.2 事件循环是这批代码的中枢神经
eXosip的编程模型是事件驱动。主循环里不断阻塞等待eXosip_event_wait返回,拿到事件后根据ev->type分发。这种模型有点像写GUI程序——你不用自己控制消息什么时候来,只需要准备一堆回调函数,告诉框架“这个消息来了你叫这个函数处理“。
事件循环的大致骨架如下:
while (!g_stop) { eXosip_event_t *ev = eXosip_event_wait(0, 50); if (ev == NULL) { continue; } switch (ev->type) { case EXOSIP_REGISTRATION_SUCCESS: // 本UA作为客户端注册成功(如果服务器也要作为UA外呼或注册到上级) break; case EXOSIP_REGISTRATION_FAILURE: // 注册失败 break; case EXOSIP_MESSAGE_NEW: // 收到一个新的SIP请求或响应 break; default: break; } eXosip_event_free(ev); }注意,这里的事件可分为两类。一类是服务器自身作为SIP客户端向其他服务器发起的注册/呼叫的结果事件,比如EXOSIP_REGISTRATION_SUCCESS;另一类是外部终端发给本服务器的请求事件,最典型的就是EXOSIP_MESSAGE_NEW,里面可能带REGISTER、INVITE、OPTIONS等业务类型。我们要关心的是后者。
3.3 注册表的数据结构设计
服务器必须有地方记录“谁注册了,联系方式是什么,什么时候过期”。我设计了一个简单的内存注册表:
#define MAX_REG_USER 256 struct reg_info { char aor[256]; // Address of Record,即SIP URI,形如 sip:1001@192.168.1.10 char contact[512]; // Contact头域,形如 sip:1001@192.168.1.20:6000 int expires; // 过期时间,单位秒 time_t updated_at; // 最后更新时间 int valid; // 该表项是否有效 }; static struct reg_info g_reg_table[MAX_REG_USER];为什么用数组而不是链表?因为对教学版来说,固定上限可以省去大量内存管理的繁琐细节,编译和运行都更稳。生产环境当然要换哈希表或者数据库,但这里的重点是展示注册逻辑,不是展示内存分配技巧。注册的语义也很简单:收到REGISTER后,取出To头域作为aor,取出Contact头域和Expires头域,写入数组,然后回200 OK。如果Expires为0,那就把表项标为无效——这是SIP协议规定的“注销”动作,很多初学者容易忽略。
3.4 设计决定先聊到这儿:为什么代码能跑起来才做优化
这里多说一点我在项目实践中养成的习惯:第一版程序不要追求优雅,能跑通就赢了。内存数组也好、全局变量也好、单线程也好,只要能让端到端流程跑通,后续怎么改都有方向。很多人一开始就上线程池、上数据库、上配置文件解析器,结果代码还没跑起来就被自己写的复杂度难倒了。
我的开发顺序是这样:先有一个死循环能接收外部事件,然后处理REGISTER打印事件,然后回200 OK,等确认全链路通了,再慢慢加入鉴权、路由、日志、配置化。这个顺序保证了每个阶段都有可验证的结果,调试成本低得多。
4. 核心代码手把手:注册服务器实现与关键回调逻辑
4.1 初始化:绑定监听地址和端口
SIP服务端默认监听UDP 5060端口,也可以监听TCP 5060,但初期建议只用UDP,逻辑简单,排查也容易。初始化eXosip的代码段如下:
#include <eXosip2/eXosip.h> #include <osip2/osip_mt.h> struct eXosip_t *ctx; int sip_server_init(const char *local_ip, int port) { int res; ctx = eXosip_malloc(); if (!ctx) return -1; res = eXosip_init(ctx); if (res != 0) { printf("eXosip_init failed: %d\n", res); return -1; } res = eXosip_listen_addr(ctx, IPPROTO_UDP, local_ip, port, AF_INET, 0); if (res != 0) { printf("eXosip_listen_addr failed: %d\n", res); return -1; } printf("SIP server listening on %s:%d (UDP)\n", local_ip, port); return 0; }这里的local_ip建议传一个明确的IP地址,比如192.168.1.10,而不是NULL。传NULL会让库去自动探测本机地址,在多网卡的机器上可能绑定到不正确的接口,之后终端注册就会莫名其妙地超时。再有就是如果你用127.0.0.1测试,能通,但换到局域网终端就不行,很多新手在这里卡很久。
4.2 处理REGISTER请求的核心逻辑
在EXOSIP_MESSAGE_NEW分支里,先判断方法名是不是REGISTER。这里用到osip_message_t结构体,取方法名、URI、头域的方法有不少现成API。我写的一个简化处理函数:
static void handle_register(eXosip_event_t *ev) { osip_message_t *msg = ev->request; osip_uri_t *to_uri = NULL; osip_from_t *from = NULL; osip_contact_t *contact = NULL; osip_to_t *to = NULL; char aor[256] = {0}; char contact_str[512] = {0}; int expires = 3600; int i; if (msg == NULL) return; // 取To头域,用于确定用户的AoR身份 to = (osip_to_t *) msg->to; if (to && to->url) { osip_uri_to_str(to->url, (char **)&to_uri); snprintf(aor, sizeof(aor), "%s", to_uri ? to_uri : ""); } // 取Contact头域 if (!osip_list_eol(&msg->contacts, 0)) { contact = (osip_contact_t *) osip_list_get(&msg->contacts, 0); if (contact && contact->url) { osip_uri_to_str(contact->url, (char **)&contact_str); } } // 取Expires头域或Contact中的expires参数 osip_message_get_expires(msg, 0, &expires); if (contact) { osip_generic_param_t *exp_param = NULL; osip_contact_param_get_byname(contact, "expires", &exp_param); if (exp_param && exp_param->gvalue) { expires = atoi(exp_param->gvalue); } } if (aor[0] && contact_str[0] && expires > 0) { // 存入注册表,返回200 OK upsert_reg_table(aor, contact_str, expires); eXosip_default_action(ctx, ev); printf("[REGISTER] %s -> %s (expires=%d)\n", aor, contact_str, expires); } else if (aor[0] && expires == 0) { // 注销 remove_reg_table(aor); eXosip_default_action(ctx, ev); printf("[UNREGISTER] %s removed\n", aor); } else { // 解析失败,返回400 eXosip_message_build_answer(ctx, ev->tid, 400, &msg); eXosip_message_send_answer(ctx, ev->tid, 400, msg); } }上面这个函数有几个细节要注意。第一,osip_message_get_expires取的是消息级Expires,如果某个Contact头域自己有expires参数,参数优先级更高,SIP协议里规定Contact的expires会覆盖头域级Expires。所以代码里先用头域给默认值,再检查contact参数。第二,eXosip_default_action是个好东西,它会根据请求方法自动生成正确的200、404、405等响应。对REGISTER来说,空Contact也会生成401之类的内容,不过我们这个版本先不做Digest鉴权。第三,打印日志一定要有,注册服务器调试全靠日志驱动,看到一条REGISTER打出来,你才敢确认解析没出问题。
4.3 完善INVITE路由,让两台终端真正通话
如果只做REGISTER,服务器只能算一个定位系统,谈不上“自己搭建的SIP服务器”。要打通呼叫,需要处理INVITE。处理的方式因场景而异:如果想做一个纯代理型SIP服务器,最简单的做法是在收到INVITE后,从请求URI里取目标AoR(比如sip:1002@192.168.1.10),然后到注册表里查这个AoR对应的Contact地址,再用eXosip_call_send_invite把呼叫转出去。
下面是简化版代理转发的思路,没有加入分叉、鉴权、3xx跟随等高级逻辑:
static void handle_invite(eXosip_event_t *ev) { osip_message_t *msg = ev->request; struct reg_info *target = NULL; osip_from_t *from = NULL; char target_aor[256] = {0}; char from_uri[256] = {0}; int call_id; if (!msg || !msg->req_uri) return; // 提取Request-URI作为目标AoR osip_uri_to_str(msg->req_uri, (char **)&target_aor); target = find_reg_table(target_aor); if (!target || !target->valid) { // 未注册用户,回404 osip_message_t *answer = NULL; eXosip_message_build_answer(ctx, ev->tid, 404, &answer); eXosip_message_send_answer(ctx, ev->tid, 404, answer); return; } // 从From头域记录主叫 from = (osip_from_t *) msg->from; if (from && from->url) { osip_uri_to_str(from->url, (char **)&from_uri); } // 直接使用原始请求转发,这是最简单的方式 osip_message_t *invite = NULL; eXosip_call_build_initial_invite(ctx, &invite, target->contact, NULL, NULL, NULL); if (!invite) return; // 把原始SDP body复制过去 if (msg->body && msg->body->body) { osip_body_t *body = NULL; osip_body_init(&body); osip_body_set(body, msg->body->body, strlen(msg->body->body), NULL, NULL); osip_message_set_body(invite, body); } call_id = eXosip_call_send_initial_invite(ctx, invite); printf("[INVITE] %s -> %s, proxy call_id=%d\n", from_uri, target_aor, call_id); }这段代码的实际含义是“代理转发”,不是背靠背(B2BUA)。新呼叫的Call-ID和标签都会和原始呼叫不同,终端能正常通话,但计费和精细控制能力都很弱。如果你要的是B2BUA行为,那就得把每个呼叫的INVITE处理成两个独立的SIP会话。对理解SIP协议来说,先跑通代理转发就够用了。等你要做录音、转IVR、条件路由的时候,再换成Media-Server方案或B2BUA方案不迟。
4.4 Makefile与启动脚本:一套可直接照抄的工程骨架
代码写好了,得能编译运行。我的Makefile很简单,关键是链接参数不要写错:
CC = gcc CFLAGS = -Wall -O2 -g PKGS = eXosip2 CFLAGS += $(shell pkg-config --cflags $(PKGS)) LIBS = $(shell pkg-config --libs $(PKGS)) all: sip_server sip_server: main.o reg_table.o $(CC) -o $@ $^ $(LIBS) main.o: main.c $(CC) $(CFLAGS) -c main.c reg_table.o: reg_table.c $(CC) $(CFLAGS) -c reg_table.c clean: rm -f *.o sip_serverpkg-config --libs eXosip2展开后实际是-leXosip2 -losip2,顺序已经是正确的,不会出现undefined reference。启动方式:
./sip_server 192.168.1.10 5060程序起来以后,屏幕上会打印监听端口,然后进入事件循环。此时用软电话注册,你应该能看到类似[REGISTER] sip:1001@192.168.1.10 -> sip:1001@192.168.1.20:6000 (expires=3600)的日志。
这里有一个很多人会忽略的点:程序默认是前台运行,Ctrl+C直接退出,注册表里的所有数据也就丢了。如果要常驻,可以使用nohup或写一个systemd服务单元。日志建议直接打到stdout,配合journalctl或重定向文件查看,别自己搞复杂的日志库,初期真的没必要。
5. 启动、联调与验证:用软电话真实注册一次
5.1 选择软电话并配置账户
验证环节我建议用MicroSIP或Zoiper。MicroSIP非常轻量,用起来没有负担。配置时需要注意几个字段:账号名填1001,SIP服务器填你运行sip_server那台机器的局域网IP,比如192.168.1.10,端口默认5060,SIP域名可以填写192.168.1.10,用户名和认证名都填1001。密码留空或随便填,因为我们这个版本没做鉴权,任何REGISTER都会被打上200。
这里有个坑是“SIP域名”和“SIP服务器”的区别。软电话注册时,To头域的URI用的是SIP域名,而发送请求的网络目的地址是SIP服务器地址。两者可以一样,也可以不一样。如果不一样,你的服务器取To头域和Request-URI时就要特别小心。从零开始的版本建议把两者填成同一个IP,减少变量。
5.2 抓包验证:亲眼看到SIP事务成不成立
用tcpdump抓UDP 5060端口的包:sudo tcpdump -i eth0 -nn -vv -XX udp port 5060。你也可以用sngrep -d eth0 -d lo udp port 5060实时查看SIP流。正常注册流程应该是这样:
- 终端发送REGISTER,To/AoR都是1001,Contact为软电话IP和端口,Expires=300或3600。
- 服务器回401(如果你开了鉴权)或直接200 OK(我们这里回的是200)。
- 终端收到2xx后认为注册成功,界面上显示Registered。
如果看到REGISTER之后没有响应,或者在终端上显示“授权失败”,先别急着怀疑代码。用sngrep确认服务端有没有把包发回去,网络上有没有NAT把源地址改掉。很多时候不是SIP逻辑错了,是网络层的问题。
5.3 双终端互拨验证:INVITE转发测试
注册成功后,拿第二个终端注册1002。然后让1001拨打1002,观察服务端日志。正常能看到:
[REGISTER] sip:1001@192.168.1.10 -> sip:1001@192.168.1.20:6000 [REGISTER] sip:1002@192.168.1.10 -> sip:1002@192.168.1.30:6000 [INVITE] sip:1001@192.168.1.10 -> sip:1002@192.168.1.10, proxy call_id=xxx然后看1002有没有响铃。如果只是注册成功、INVITE不通,最可能的两个原因是SDP里的IP问题(NAT地址)和路由表查不到目标。先看日志打印出的target_aor到底长什么样,是sip:1002@192.168.1.10还是sip:1002@其他域名,如果是后者,就去改软电话的SIP域名配置,让Request-URI和注册表中的aor保持一致。
6. 常见问题排查链路:从编不过到收不到包
6.1 编译链接阶段的问题
编译报错中最常见的是头文件找不到,比如fatal error: eXosip2/eXosip.h: No such file or directory。原因一般是没安装开发包或者pkg-config没有生效。逐个检查:先看/usr/local/include/eXosip2下面有没有头文件,再看pkg-config --cflags eXosip2输出什么,最后确认Makefile里PKGS变量的写法。几乎都能定位到是路径或环境变量的问题。
还有一个诡异的编译期错误是osip_mt.h: No such file or directory,这个文件不在eXosip包里,而在osip包里。说明eXosip头文件引用了osip的头文件,但osip没装。很多人只装了libeXosip2-dev忘了装libosip2-dev,就会卡在这里。手动编译版本的话,先确认make install osip已经执行。
6.2 运行时崩溃与注册失败
运行后程序崩溃,先看是不是监听地址参数传错了。eXosip_listen_addr里如果传了一个本机不存在的IP,返回值不是0,你打印错误后退出即可。比较隐蔽的问题是多次init导致socket句柄泄漏,或者之前用SIP服务器占着5060端口没释放,新起进程bind失败。排查端口占用用这个命令:
ss -lunp | grep 5060注册请求收到了但回200不成功,比如终端提示超时,重点检查eXosip_default_action之前是否真的把事件上下文传对了。事件循环里如果用ev->tid去构造响应,而tid在异步事件里已经失效,也会有问题。我的建议是事件循环每个分支只做解析和状态修改,应答动作尽量在拿到ev后同步完成,不要跨线程保存ev。
6.3 SIP应用层的问题,对照表格快速定位
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 终端显示“注册超时” | 服务器没有收到REGISTER,或响应没回给终端 | 抓包确认是否有包到达5060;检查防火墙UDP 5060是否放行 |
| 注册时收到401/407循环 | 服务器启用了鉴权但终端没有密码 | 暂时关闭Digest鉴权,或给终端配好密码 |
| 注册成功但几十秒后又掉线 | Expires过期且没有定时刷新 | 检查终端注册刷新周期;服务器端可考虑UDP keepalive |
| REGISTER的Contact为空 | 软电话“使用出站代理”配置错误 | 把出站代理设为SIP服务器IP和端口 |
| 互拨听到回铃音但对方不响 | SDP里的IP是内网地址,或RTP端口不通 | 查看双方SDP,确认RTP端口范围是否开放;NAT环境下配置STUN/TURN |
| 收到INVITE但服务端日志没打印 | 事件类型判断有误,没有走到handle_invite分支 | 在EXOSIP_MESSAGE_NEW分支打印方法名,确认确实是INVITE |
这里面最容易被忽略的是防火墙和RTP端口。SIP信令走UDP 5060没毛病,但媒体流RTP用的是动态端口,多数终端默认在10000-20000之间。如果你在云服务器上测试,安全组只开了5060,那两个终端大概率信令通了但听不到声音。排查时不要只盯着信令,RTP不通会出现“呼叫建立成功但完全没声音”的诡异现象。
6.4 由浅入深的一个建议
我在这个项目里花了不少时间在“信令通了但媒体不通”这个问题上,最后发现是SDP里的c行填的是终端的私网地址,而两个终端又在不同NAT后面,于是只能靠STUN去协商公网映射。这个和oSIP本身没关系,但一旦你搭了自己的SIP服务器,这些网络问题就都压到你头上了。建议学习阶段尽量在同一个局域网内测试两个软电话,通了之后再逐步加NAT、防火墙、鉴权这些复杂度。
我个人还有几个习惯可以分享一下。第一,未启用鉴权前,注册表里的aor和contact用printf全部打出来,肉眼确认数据和分析软件看到的一致。第二,代码里加上#define DEBUG开关,关键分支打了日志再删,不要等到出问题了才重新加。第三,多熟悉sngrep这个工具,它按呼叫维度把SIP消息聚合成一个个会话流,比wireshark的纯按包列表看SIP要直观得多。
做完这套东西,你会发现SIP协议里最核心的REGISTER、INVITE、200、事务层重传这些概念都有了实感。后续要继续扩展的话,可以在我的代码基础上加上Digest摘要认证、注册表持久化到SQLite、定时清理过期表项、把INVITE转发到FreeSWITCH做媒体处理,甚至把oSIP协议栈的日志回调接到你的log系统。每一步改动都有明确的验证手段,只要保持“先跑通再优化”的节奏,很快你就能有一台真正可控的自建SIP服务器,而不是只能照着文档配一台Asterisk。