PJSIP C++开发实战:从核心类解析到可运行SIP客户端构建

发布时间:2026/7/16 8:42:42
PJSIP C++开发实战:从核心类解析到可运行SIP客户端构建 1. 项目概述PJSIP库的C世界初探如果你正在C领域里折腾音视频通信无论是想做一个自己的软电话、一个带通话功能的IoT设备还是一个嵌入式的对讲系统那么PJSIP这个名字你大概率绕不过去。它是一个开源的、功能强大的SIP会话初始协议和多媒体通信库用C语言写成但提供了对开发者更友好的PJSUA2这一层C封装。网上资料虽然多但往往要么是零散的API文档要么是过于简单的“Hello World”让人看了之后还是不知道从何下手去构建一个真正可用的项目。我自己在几年前第一次接触PJSIP时就被它庞大的代码库和略显晦涩的文档给镇住了踩了不少坑才摸清门道。这篇文章我就以一个过来人的身份带你深入PJSIP的C层PJSUA2。我不会只给你看一个注册到测试服务器就退出的示例代码那只是冰山一角。我会拆解这个“Hello World”背后的每一个步骤告诉你每个类如Endpoint,Account,Call到底是干什么的、怎么用以及在实际项目中你会遇到哪些官方文档没细说的“坑”。我们的目标是看完之后你不仅能看懂示例更能自己动手搭建起一个具备基本呼叫能力的C客户端骨架并理解其背后的设计哲学。无论你是音视频通信的初学者还是有一定经验想系统了解PJSIP的开发者这篇内容都能给你提供一条清晰的路径。2. PJSUA2 C核心类深度解析PJSUA2的C API设计采用了面向对象的思想将复杂的SIP协议栈和媒体处理逻辑封装成几个核心的类。理解这些类的职责和生命周期是高效使用PJSIP的关键。很多人一开始就埋头写代码结果发现对象创建和销毁的顺序不对导致内存泄漏或程序崩溃。我们先来把这几个“主角”认识清楚。2.1 Endpoint通信栈的司令塔Endpoint类是PJSUA2世界的单例虽然从代码上看不是严格意义上的单例但实践中我们通常只创建一个实例。它是整个库的入口点和总控制器负责管理库的生命周期、线程池、传输层TCP/UDP/TLS以及全局配置。关键点与实操心得唯一性与生命周期虽然理论上可以创建多个Endpoint实例但在99%的应用场景下一个进程只需要一个。它的生命周期应该覆盖整个应用的通信周期。libCreate()和libDestroy()必须成对调用且通常发生在主线程。初始化的正确顺序libInit()必须在libCreate()之后transportCreate()和libStart()之前调用。libStart()会启动工作线程之后库才能处理网络事件和媒体流。一个常见的错误是在libStart()之前就去创建账号或发起呼叫这会导致操作失败。传输层Transport这是SIP信令的通道。示例中创建了一个UDP传输监听5060端口。在实际项目中你可能需要同时创建UDP和TCP传输端口可相同或不同以兼容不同的网络环境或服务器要求。TLS传输则用于加密的信令。// 创建UDP传输 TransportConfig udpCfg; udpCfg.port 5060; ep.transportCreate(PJSIP_TRANSPORT_UDP, udpCfg); // 创建TCP传输监听同一端口系统会分配不同套接字 ep.transportCreate(PJSIP_TRANSPORT_TCP, udpCfg);2.2 Account你的SIP身份Account类代表一个SIP账号或称为用户代理。你需要通过继承这个类并重写其虚函数回调函数来接收事件通知比如注册状态变化、来电等。关键点与实操心得必须子类化你不会直接使用Account类而是必须创建它的子类。最重要的回调是onRegState注册状态和onIncomingCall来电通知。这是PJSIP事件驱动模型的核心。配置的复杂性AccountConfig对象包含了账号的所有配置SIP URIidUri、注册服务器地址registrarUri、认证信息authCreds、媒体能力mediaConfig等。这里坑很多idUri格式必须是完整的sip:userdomain。AuthCredInfo的构造参数认证类型通常是“digest”、域名realm如果服务器未指定可以用“*”通配、用户名、数据类型0表示纯文本密码、密码。媒体配置如是否启用视频、使用的编解码器优先级也在这里设置它决定了你的终端能支持什么类型的通话。对象所有权与内存管理示例中使用new创建MyAccount对象并在最后delete。在PJSUA2中C对象是原生对象你需要自己管理生命周期。确保在Endpoint销毁之前所有相关的Account和Call对象都已被妥善销毁否则在libDestroy()时可能会访问已释放的内存导致崩溃。一种更现代的做法是使用std::unique_ptr来管理。2.3 Call一次通话的抽象当你有来电或去电时PJSIP会为你创建一个Call对象。和Account一样你需要子类化Call来接收该次通话的特定事件如媒体状态变化、呼叫状态变化振铃、接通、挂断、DTMF按键等。关键点与实操心得两种创建方式接收来电当Account::onIncomingCall回调被触发时参数中会包含一个Call对象这个对象已经由库创建好了。你只需要保存它通常用智能指针并调用answer()来接听或hangup()来拒绝。发起去电你需要自己new一个你的MyCall对象然后调用makeCall()方法传入目标SIP URI。媒体流管理通话建立后Call对象提供了访问其音频、视频媒体的接口。你可以通过getAudioMedia()获取音频流对象将其连接到会议桥进行混音或录音。这是实现通话录音、保持、转接等功能的基础。状态管理Call对象有复杂的生命周期状态PJSIP_INV_STATE_NULL,CALLING,INCOMING,EARLY,CONNECTING,CONFIRMED,DISCONNECTED。务必在onCallState回调中正确处理这些状态并在DISCONNECTED状态后安全地清理Call对象。2.4 AudioMedia 与 VideoMedia操控音视频的把手这两个类代表了抽象的音频和视频媒体流。它们本身不产生数据而是作为“端口”可以连接到会议桥或其他媒体端口形成媒体处理链路。关键点与实操心得会议桥Conference Bridge这是一个核心概念。PJSIP内部有一个虚拟的混音器。每个AudioMedia都可以连接到会议桥。你的麦克风采集的音频作为一个AudioMedia连接到桥扬声器播放的音频也从桥获取数据。通话对端的音频流也作为一个AudioMedia连接到同一个桥。这样所有音频就在桥里自动混合了。对于点对点通话你通常不需要手动操作桥。高级操作通过AudioMedia对象你可以实现录音创建一个AudioMediaRecorder并连接到通话的AudioMedia。放音创建一个AudioMediaPlayer播放WAV文件并连接到会议桥或通话的AudioMedia实现彩铃或语音提示。音频转发/IVR将多个AudioMedia相互连接构建复杂的音频处理流程。线程安全大部分AudioMedia和VideoMedia的操作不是线程安全的。它们应该在PJSIP库的工作线程上下文通常是在回调函数中或在你通过Endpoint的util工具类锁住内部锁之后进行。盲目地从GUI线程直接调用这些方法会导致随机崩溃。3. 从“Hello World”到一个最小化可运行客户端现在我们超越那个简单的注册示例构建一个具备基本呼叫功能的最小化C客户端。这个客户端能注册到SIP服务器能拨号能接听并处理基本的挂断。我会把每一步的意图和潜在陷阱讲清楚。3.1 项目结构与编译环境搭建首先你需要成功编译PJSIP库。官方推荐使用它们的configure和make系统。对于桌面Linux或macOS这相对直接。对于Windows你需要Visual Studio和它的NMake。这里以Linux为例但要点是通用的。编译要点# 1. 获取代码 git clone https://github.com/pjsip/pjproject.git cd pjproject # 2. 配置启用PJSUA2 ./configure --enable-shared --disable-sound --disable-video --prefix/usr/local # 解释参数 # --enable-shared: 生成动态库便于链接。 # --disable-sound: 首次编译可先禁用声音设备避免依赖问题。 # --disable-video: 首次编译可先禁用视频简化流程。 # --prefix: 安装路径。 # 3. 编译并安装 make dep make sudo make install编译后关键的头文件通常在/usr/local/include库文件在/usr/local/lib。你需要确保你的编译器能找到pjsua2.hpp和libpjsua2.so或.a文件。在CMakeLists.txt中链接示例cmake_minimum_required(VERSION 3.10) project(MySipClient) set(CMAKE_CXX_STANDARD 11) # 寻找PJSIP库这里假设你安装在了标准路径或通过环境变量指定 find_path(PJSUA2_INCLUDE_DIR pjsua2.hpp PATH_SUFFIXES pjlib-util pjsip) find_library(PJSUA2_LIBRARY NAMES pjsua2) include_directories(${PJSUA2_INCLUDE_DIR} ${PJSUA2_INCLUDE_DIR}/..) # 头文件路径可能有多层 add_executable(MySipClient main.cpp) target_link_libraries(MySipClient ${PJSUA2_LIBRARY} pthread dl) # 注意PJSIP可能依赖其他库如ssl、crypto如果启用TLS需要一并链接。3.2 实现核心事件处理类我们创建两个类MyAccount和MyCall。// my_account.hpp #include pjsua2.hpp #include iostream #include memory #include map using namespace pj; class MyCall; class MyAccount : public Account { public: std::mapint, std::shared_ptrMyCall activeCalls; // 管理当前活跃通话 virtual void onRegState(OnRegStateParam prm) override { AccountInfo ai getInfo(); std::cout [Account] Registration state changed. Code: prm.code , Reason: prm.reason std::endl; std::cout [Account] Is registered? (ai.regIsActive ? YES : NO) std::endl; // 这里可以更新UI状态或触发重连逻辑 } virtual void onIncomingCall(OnIncomingCallParam iprm) override { std::cout [Account] Incoming call from: iprm.callId std::endl; // 创建我们的Call对象来包装这个新呼叫 auto call std::make_sharedMyCall(*this, iprm.callId); CallInfo ci; try { ci call-getInfo(); } catch(Error e) { std::cout [Account] Failed to get call info: e.info() std::endl; return; } std::cout [Account] Remote URI: ci.remoteUri std::endl; // 保存到映射表用callId作为键 activeCalls[iprm.callId] call; // 在实际应用中这里应该弹出界面让用户选择接听或拒绝。 // 为了示例我们自动接听。 std::cout [Account] Auto-answering call... std::endl; CallOpParam op; op.statusCode PJSIP_SC_OK; call-answer(op); } };// my_call.hpp #include pjsua2.hpp #include iostream #include “my_account.hpp” class MyCall : public Call { private: MyAccount myAcc; // 持有所属账号的引用 public: MyCall(MyAccount acc, int call_id) : Call(acc, call_id), myAcc(acc) {} virtual ~MyCall() { std::cout [Call] Call object destroyed, ID: getId() std::endl; // 从账号的活跃通话列表中移除自己 myAcc.activeCalls.erase(getId()); } virtual void onCallState(OnCallStateParam prm) override { CallInfo ci; try { ci getInfo(); } catch(Error e) { std::cout [Call] Error getting call info: e.info() std::endl; return; } std::cout [Call] State changed to: ci.stateText ( ci.lastStatusCode ci.lastReason ) std::endl; if (ci.state PJSIP_INV_STATE_DISCONNECTED) { // 通话结束准备销毁对象。 // 注意我们不能在这里直接delete this因为可能还在库的回调栈中。 // 我们依靠shared_ptr的引用计数当外部不再持有该对象时它会自动被销毁。 // 更安全的做法是通知主线程或管理器来异步清理。 std::cout [Call] Call disconnected. Cleaning up. std::endl; // 例如可以发送一个事件到主循环 } else if (ci.state PJSIP_INV_STATE_CONFIRMED) { std::cout [Call] Call is answered and active! std::endl; // 通话接通可以开始处理媒体比如开始录音如果需要 } } virtual void onCallMediaState(OnCallMediaStateParam prm) override { CallInfo ci getInfo(); // 遍历通话中的所有媒体流 for (unsigned i 0; i ci.media.size(); i) { if (ci.media[i].type PJMEDIA_TYPE_AUDIO) { AudioMedia *aud_med (AudioMedia *)getMedia(i); if (aud_med) { // 获取通话的音频媒体并连接到会议桥这一步通常是自动的但这里演示如何获取 try { // 实际上对于简单的双向通话库已经自动处理了连接。 // 这里我们可以获取到它用于可能的扩展操作如录音。 std::cout [Call] Audio media index: i is active. std::endl; // 示例如果你想手动操作可以将它连接到另一个端口 // Endpoint::instance().audDevManager().getPlaybackDevMedia().startTransmit(*aud_med); } catch(Error e) { std::cout [Call] Error handling audio media: e.info() std::endl; } } } } } };3.3 主程序骨架与流程控制主程序需要初始化库、创建账号、并进入一个事件循环否则程序会直接退出。// main.cpp #include “my_account.hpp” #include “my_call.hpp” #include pjsua2.hpp #include iostream #include atomic #include signal.h #include unistd.h // for sleep using namespace pj; std::atomicbool g_quit{false}; void signal_handler(int sig) { g_quit true; std::cout \n[Main] Signal received, shutting down... std::endl; } int main(int argc, char *argv[]) { // 注册信号处理优雅退出 signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); Endpoint ep; try { // 1. 创建库实例 ep.libCreate(); // 2. 初始化端点配置 EpConfig ep_cfg; // 可以在这里配置日志级别、UA信息等 ep_cfg.logConfig.level 4; // 设置日志级别为INFO ep_cfg.uaConfig.userAgent MySipClient/1.0; ep.libInit(ep_cfg); // 3. 创建传输层 TransportConfig tcfg; tcfg.port 5060; // 本地SIP端口 // 允许在同一个端口上绑定UDP和TCP ep.transportCreate(PJSIP_TRANSPORT_UDP, tcfg); // ep.transportCreate(PJSIP_TRANSPORT_TCP, tcfg); // 如需TCP则取消注释 // 4. 启动库 ep.libStart(); std::cout [Main] PJSUA2 started successfully. std::endl; // 5. 创建并配置账号 MyAccount *acc new MyAccount; // 使用智能指针更好此处为演示 AccountConfig acfg; // !!! 重要这里需要替换成你真实的SIP服务器信息 !!! acfg.idUri sip:1001your.sip.server.com; acfg.regConfig.registrarUri sip:your.sip.server.com; acfg.sipConfig.authCreds.push_back( AuthCredInfo(digest, *, 1001, 0, your_password) ); // 媒体配置启用哪些编解码器优先级从高到低 acfg.mediaConfig.sndClockRate 16000; // 音频时钟率常用8000, 16000 acfg.mediaConfig.audioFramePtime 20; // 每帧音频时长(ms)影响延迟 // 编解码器优先级数字越小优先级越高 acfg.mediaConfig.codecCfgs.push_back(CodecConfig(pcma, 130)); // G.711 A-law acfg.mediaConfig.codecCfgs.push_back(CodecConfig(pcmu, 129)); // G.711 μ-law acfg.mediaConfig.codecCfgs.push_back(CodecConfig(opus/48000/2, 128)); // Opus acc-create(acfg); std::cout [Main] Account created, registering... std::endl; // 6. 主事件循环 std::cout [Main] Entering main loop. Press CtrlC to quit. std::endl; while (!g_quit) { // Endpoint的handleEvents()方法会处理pending的事件需要定期调用。 // 参数是最大等待时间毫秒。设为10ms是一个合理的值平衡了响应速度和CPU占用。 ep.handleEvents(10); // 在这里你可以添加自己的业务逻辑比如检查用户输入命令行。 // 例如一个简单的命令行拨号接口 static bool promptShown false; if (!promptShown acc-getInfo().regIsActive) { std::cout \n[Main] Registration successful! You can now make a call. std::endl; std::cout Enter destination SIP URI (e.g., sip:1002server.com) or q to quit: ; promptShown true; } // 注意在实际GUI应用中事件循环由GUI框架Qt, GTK等驱动 // 你需要在单独的线程或定时器中调用 handleEvents。 } // 7. 清理阶段 std::cout [Main] Shutting down... std::endl; // 先删除账号触发注销 delete acc; // 等待一小段时间让注销请求发出非必须但更安全 ep.handleEvents(100); // 显式停止并销毁库 ep.libDestroy(); } catch (Error err) { std::cerr [Main] Exception caught: err.info() std::endl; return 1; } std::cout [Main] Exit. std::endl; return 0; }3.4 编译与初步测试将以上三个文件放在同一目录使用之前的CMake或直接使用g编译g -stdc11 -o mysipclient main.cpp my_account.cpp my_call.cpp \ -I/usr/local/include \ -L/usr/local/lib -lpjsua2 -lpjsua -lpjsip-ua -lpjsip-simple -lpjsip \ -lpjmedia-codec -lpjmedia -lpjmedia-videodev -lpjmedia-audiodev \ -lpjnath -lpjlib-util -lpj -lssl -lcrypto -lsrtp -lresample -lspeex -lopus \ -luuid -lm -lrt -lpthread -ldl注意链接库的顺序和名称可能因平台和编译选项而异。使用pkg-config如果安装时生成了.pc文件可以简化此过程g ... $(pkg-config --cflags --libs libpjproject)。运行前请确保修改main.cpp中的SIP服务器地址、用户名和密码。然后运行./mysipclient。如果看到注册成功的消息说明基础链路已经打通。你可以用另一个SIP客户端如MicroSIP, Linphone呼叫你的账号这个程序会自动接听。目前还无法主动拨号我们将在下一部分扩展。4. 功能扩展与实战问题排查一个基本的客户端骨架有了但要投入实用还需要添加主动呼叫、挂断、DTMF发送、音量调节等功能。同时实际部署中会遇到各种网络和设备问题。4.1 实现主动呼叫与用户交互我们需要修改主循环使其能够接收用户输入并发起呼叫。同时完善MyCall类提供挂断等方法。首先在MyAccount中添加发起呼叫的方法// 在 MyAccount 类定义中添加 public: std::shared_ptrMyCall makeCall(const std::string destUri) { std::cout [Account] Making call to: destUri std::endl; auto call std::make_sharedMyCall(*this); CallOpParam prm(true); // true 表示默认使用音频 try { call-makeCall(destUri, prm); activeCalls[call-getId()] call; return call; } catch (Error err) { std::cerr [Account] Failed to make call: err.info() std::endl; return nullptr; } }然后修改主程序的事件循环加入简单的命令行解析// 在主循环中替换掉之前的简单提示 while (!g_quit) { ep.handleEvents(10); // 简单的非阻塞标准输入检查Linux/macOS示例 fd_set fds; struct timeval tv {0, 0}; FD_ZERO(fds); FD_SET(STDIN_FILENO, fds); int rv select(STDIN_FILENO 1, fds, NULL, NULL, tv); if (rv 0 FD_ISSET(STDIN_FILENO, fds)) { std::string input; std::getline(std::cin, input); if (input q || input quit) { g_quit true; } else if (input.substr(0, 4) call) { // 格式: call sip:1002server.com std::string dest input.substr(5); if (!dest.empty()) { auto call acc-makeCall(dest); if (!call) { std::cout Call initiation failed. std::endl; } } } else if (input.substr(0, 4) hang) { // 挂断当前活跃通话简化处理挂断第一个 if (!acc-activeCalls.empty()) { auto it acc-activeCalls.begin(); CallOpParam op; it-second-hangup(op); std::cout Hanging up call ID: it-first std::endl; } } else { std::cout Commands: call uri, hang, q std::endl; } } }4.2 常见问题与调试技巧实录在实际使用中你会遇到各种各样的问题。下面是我总结的一些常见坑点和排查方法。问题1编译时链接错误找不到pj_init等符号。原因链接库的顺序不对。PJSIP的库之间有依赖关系必须按照从高层到低层的顺序链接。解决使用官方推荐的链接顺序或者直接使用pkg-config。如果手动链接可以参考以下大致顺序从具体到抽象-lpjsua2 -lpjsua -lpjsip-ua -lpjsip-simple -lpjsip -lpjmedia-codec -lpjmedia-videodev -lpjmedia-audiodev -lpjmedia -lpjnath -lpjlib-util -lpj问题2运行时崩溃错误信息模糊。原因A对象生命周期管理错误。在Endpoint销毁后还尝试访问Account或Call对象。解决确保销毁顺序先销毁所有Call再销毁所有Account最后调用ep.libDestroy()。使用智能指针std::shared_ptr管理对象所有权并注意避免循环引用例如Call持有Account的引用而非shared_ptr。原因B跨线程调用PJSUA2 API。大部分PJSUA2方法不是线程安全的。解决所有对PJSUA2对象的操作除了Endpoint::handleEvents()和Endpoint::util里的工具方法最好都在同一个线程通常是主线程执行或者使用Endpoint::util()提供的锁机制。如果你在回调函数如onIncomingCall中你已经在库的工作线程中可以安全操作当前对象。问题3注册失败返回408超时或403禁止。排查步骤开启详细日志在EpConfig中设置ep_cfg.logConfig.level 5DEBUG级别ep_cfg.logConfig.consoleLevel 4INFO级别。查看控制台输出的SIP消息。检查网络连通性确认客户端能ping通SIP服务器地址和端口默认5060。使用telnet server.com 5060测试端口是否开放。检查账号信息idUri、registrarUri、用户名、密码、域名realm是否正确。抓包工具如Wireshark是终极武器可以看到原始的SIP REGISTER请求和服务器响应从中找到失败原因。检查防火墙/NAT如果客户端在NAT后可能需要配置STUN服务器。在EpConfig的uaConfig里设置stunServer。ep_cfg.uaConfig.stunServer.push_back(stun.stunserver.org:3478);问题4能注册但无法通话没有声音。排查步骤检查媒体协商在日志中搜索“SDP offer/answer”。确认双方有共同的编解码器如PCMA/PCMU/Opus。你的AccountConfig.mediaConfig.codecCfgs优先级列表可能和对方不匹配。检查音频设备PJSIP是否能找到可用的音频设备在初始化后可以尝试打印音频设备列表AudDevManager adm ep.audDevManager(); std::cout Capture devices: adm.getDevCount() std::endl;检查RTP流通话建立后是否在收发RTP包日志中会有RTP统计信息。如果收不到对方的RTP可能是NAT穿透失败。除了STUN可能还需要启用ICE或TURN。// 在EpConfig中启用ICE ep_cfg.uaConfig.iceConfig.enableIce true;会议桥连接虽然简单通话是自动的但如果你做了自定义的媒体操作如录音可能意外断开了音频流与会议桥的连接。确保接通后通话的音频流是连接到会议桥的。问题5在通话中程序占用CPU过高。原因Endpoint::handleEvents()的调用间隔太短或者在一个紧密循环中调用而没有延时。解决给handleEvents()传递一个合理的等待时间比如10-100毫秒。这个调用会阻塞等待事件减少空转。在上面的主循环中我们用了10ms。对于纯事件驱动的GUI程序你可以在一个定时器如100ms间隔中调用它而不是死循环。4.3 进阶功能点简介当基础通话稳定后你可以考虑添加更多功能通话保持Hold调用Call的setHold()方法。通话转移Transfer使用Call的xfer()方法。DTMF发送使用Call的dialDtmf()方法。音量调节通过AudioMedia的adjustRxLevel()和adjustTxLevel()方法。通话录音创建AudioMediaRecorder对象并将其连接到通话的AudioMedia流。视频支持在配置中启用视频ep_cfg.uaConfig.vidInAutoTransmit true并处理VideoMedia和VideoWindow对象。状态呈现Presence通过Buddy类订阅好友状态。5. 项目集成与部署思考将PJSIP集成到你的实际C项目中远不止是编译和链接。你需要考虑架构设计。线程模型PJSIP内部有自己的工作线程。你的UI线程或主逻辑线程与PJSIP的交互必须通过线程安全的方式。推荐的做法是主线程负责创建、初始化、销毁Endpoint并定期调用handleEvents()。所有从PJSIP回调如onIncomingCall,onCallState中触发的业务逻辑如果需要更新UI必须通过消息队列、事件总线或线程安全的方式通知到UI线程。所有从UI线程发起的主动操作如拨号、挂断应直接调用PJSUA2 API因为它们最终会向PJSIP的工作线程发送消息但要注意前面提到的对象生命周期。错误处理PJSUA2大量使用C异常pj::Error。务必用try...catch块包裹所有可能抛出异常的操作并进行合理的错误恢复而不是让整个程序崩溃。日志与监控将PJSIP的日志重定向到你自己的日志系统。你可以实现一个自定义的LogWriter并注册到Endpoint这样就能将日志统一管理并写入文件或网络。资源清理确保在程序退出前所有资源都被有序释放。特别是当程序被强制终止如CtrlC时应在信号处理函数中设置标志位让主循环优雅退出完成销毁流程。最后PJSIP是一个相当底层的库功能强大但也复杂。从“Hello World”到生产可用的产品中间有很长的路要走需要仔细阅读文档大量测试尤其是针对异常网络情况丢包、延迟、NAT类型变化的测试。希望这篇深入解析能为你点亮第一盏灯让你在音视频通信的C开发之路上少走一些弯路。