
简介一份网络电话VoIP的C实现源码重点面向对实时音视频通信和网络编程感兴趣的开发者也适合作为本科课程设计或毕业设计的参考项目。资源围绕VoIP核心技术展开覆盖音频编码与解码对比G.711、G.729、Opus等常见格式、UDP/TCP传输选择、RTP实时传输以及SIP会话控制等关键知识点源码中包含SocketServer与SocketClient两类通信封装并采用多线程处理音频采集与网络发送可直接感受并发编程在实时系统中的应用。整个压缩包共37个文件约634KB主要文件类型为5个cpp源文件和6个头文件另附1个exe可执行程序、10个bmp界面状态图和1个wav提示音方便运行和观察效果。已有314人学习下载适合有一定C基础、希望深入VoIP底层实现或二次开发的读者。通过阅读和调试可清晰梳理从音频采集、编码打包到网络传输、接收解码的完整链路还能学习错误检测与QoS保障等工程化思想。 开头约300字网络电话、C、源代码这三个关键词凑在一起很多人第一反应是不就是UDP传音频嘛。实际上真把一个软电话从零写到能稳定通话要踩的坑远比我最初想象的多。这篇文章从一个真正跑过的局域网点对点网络电话项目说起把C源代码里音频采集、RTP组包、多线程回调这些细节拆开讲适合正在写VoIP demo、音视频会议客户端或者对实时通信底层感兴趣的C开发者参考。这套代码的整体思路并不复杂麦克风采集到的PCM数据按20ms一帧打包成RTP报文通过UDP发给对端对端收到后先放进抖动缓冲再由声卡按自己的节奏拉去播放。通话质量好不好九成取决于缓冲区、线程还有数据拷贝这几块写没写对。1. 先把整体结构想清楚再谈代码实现1.1 别一上来就写socket先画数据流很多人搜网络电话的C源代码其实是想要一个能直接跑的成品。但拿回来一看代码往往只有两个文件一个窗口加一个socket。收发倒是通了声音却全是一卡一卡的噪声。问题不出在socket上而是压根没人说清楚音频不是一次性塞进网络就完事的。我采用的结构是四个模块加两条数据线。四个模块分别是音频采集播放AudioDevice、音频编码解码Codec、网络传输Transport、抖动缓冲JitterBuffer。两条数据线一条是发送链路声卡到采集回调再到编码、RTP组包、UDP发送另一条是接收链路UDP接收、RTP解包、抖动缓冲、解码最后交给播放回调送到声卡。画完这张图再动手写代码心里的底就完全不一样了。因为你清楚每一段代码属于哪条链路出了问题知道往哪一段查。至少在我做过的几个实时音视频项目里凡是架构画不清楚的后期基本都是崩了再说。1.2 模块边界定清楚后面维护才不痛苦第一版做的时候不要贪心不用上SIP信令不要做呼叫保持先把点对点的媒体流跑通。我最初做的demo没有加任何信令两台机器填好IP和端口点一下呼叫就直接开始收发音频等双向声音都稳定了再考虑加SIP、加通话状态机会轻松得多。模块边界的问题上C写这种系统最容易出现为省事把音频数据直接从socket回调丢给声卡播放。这样写的demo确实能响但回调互掐、锁竞争、卡顿这些问题会在你加一个暂停按钮之后全部爆发。我后来始终保持一个原则录制和播放是两条完全独立的线程谁都不直接调用对方的函数只通过队列交换数据。网络线程和音频回调之间也只用轻量锁或环形缓冲。模块之间是数据依赖而不是调用依赖后面加回声消除、加噪声抑制、换音频编码都只是替换某个模块内部的事情不需要动其他代码。这个道理放到生活里就像厨房和餐厅分开。炒菜的人不用等客人吃完再炒下一道菜只通过传菜口递出去。队列就是那个传菜口。2. 音频帧和缓冲区源代码里最值得抠的细节2.1 一个音频帧的数据结构怎么设计下面这份AudioFrame定义是我在实际项目里用过的后面所有代码都围绕它展开struct AudioFrame { std::vectorint16_t samples; // PCM采样数据 uint32_t sampleRate 8000; // 采样率先用电话音质8kHz uint16_t channels 1; // 声道数 uint32_t pts 0; // 时间戳单位ms uint32_t seq 0; // 序号用于丢包检测 };20ms的8kHz单声道PCM一帧就是160个采样点换算成字节是320字节。这个数据量不算大但是架构上必须把帧这个概念固定下来。后面无论是组RTP包、做抖动缓冲还是统计丢包率都以帧为最小单位处理。为了避免反复分配内存发送端可以做一个对象池把用过的AudioFrame回收复用实测下来对降低卡顿有一定帮助。2.2 三个容易忽略的点时间戳、序号、内存分配时间戳一定要用单调递增的时钟不要用墙钟时间。网络延迟、系统休眠都会让墙钟往回跳一旦回跳对端的抖动缓冲就会按错误顺序播放声音听起来像倒带。序号用来检测丢包接收端根据seq的跳变就能算出丢包率这个值对后面调抖动缓冲非常有参考价值。内存分配是很多人没意识到的隐藏杀手。别看一帧只有320字节如果每个包到了都new一次vector高频收发下分配器压力会很大还容易产生内存碎片。我在项目里通过对象池复用内存同一块缓冲区反复用新数据只是覆盖旧数据性能和流畅度都提升了一截。2.3 指针和多维数组音频处理里的经典坑搜多维数组 C 指针的人通常是在处理类似双声道PCM交叉存储的数据。很多人喜欢用int16_t buffer[frames][channels]来表示然后写一堆指针运算去翻转左右声道。这种做法非常容易踩坑二维数组名和int16_t**是两回事直接强转编译能过一运行就是段错误。更稳妥的方式是用一维数组加声道步长去访问int16_t* pcm frame.samples.data(); for (int i 0; i 160; i) { int16_t left pcm[i * 2]; int16_t right pcm[i * 2 1]; // 处理左右声道 }这样既避免了指错指针也保证了内存连续性。如果需要把整段数据交给memset或memcpy一维数组能直接用不会出现跨越行边界的问题。3. 音频采集与播放声音从哪来、到哪去3.1 选对音频库省掉80%驱动兼容性麻烦如果真要从声卡驱动开始写采集播放Windows下要碰WASAPI、DSoundLinux下要碰ALSA光是搞清楚不同设备默认格式就能耗掉一周。这个项目里我直接用了RtAudio一个跨平台的C音频库底层把WASAPI、ALSA这些封装好了代码里只需要指定设备参数和一个采集回调。初始化代码大致长这样RtAudio adc; RtAudio::StreamParameters param; param.deviceId adc.getDefaultInputDevice(); param.nChannels 1; unsigned int bufFrames 160; // 20ms 8kHz adc.openStream(nullptr, param, RTAUDIO_SINT16, 8000, bufFrames, onAudioInput, this); adc.startStream();回调onAudioInput是音频库在采集线程里每20ms调一次的函数把声卡里的PCM数据交出来。注意在这个回调里绝对不能做阻塞操作比如发网络包、写文件。实测如果在回调里直接sendto网络一抖动整个采集线程都会卡住声音直接断。3.2 回调里只做一件事把数据拷贝进队列正确的做法是回调里只构造一个AudioFrame塞进发送队列然后立刻返回。发送队列由另一条发送线程负责取走、组包、发送。下面这个回调函数签名是RtAudio的标准形式实际实现里只需要关注inputBufferint onAudioInput(void* outputBuffer, void* inputBuffer, unsigned int nFrames, double streamTime, RtAudioStreamStatus status, void* userData) { AudioDevice* self static_castAudioDevice*(userData); int16_t* pcm static_castint16_t*(inputBuffer); self-sendQueue.push(AudioFrame{pcm, nFrames}); return 0; }这个采集线程只生产、网络线程只消费的分工是整个系统不卡的基石。作为对比有的实现把采集和发送放在同一个线程结果就是网络慢时声卡也被拖住。分开之后即使网络拥塞、发送线程排队麦克风这边的采集节奏也不会被破坏数据只会暂存在队列里网络一恢复就继续发。播放端同理声卡播放回调每次要160个采样就从接收侧队列里取。如果没有数据就补静音而不是傻等这样即使偶尔丢包也不至于把整个播放线程卡死。4. UDP传输与RTP组包声音是怎么送到对方耳朵里的4.1 先从最基础的UDP收发写起在分析这种项目源码的时候UDP收发是绕不开的。给出一个Windows和Linux通用的socket初始化代码#ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); #endif int s socket(AF_INET, SOCK_DGRAM, 0); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(6000); addr.sin_addr.s_addr inet_addr(192.168.1.100); // 发送一帧语音 sendto(s, (const char*)frame.samples.data(), frame.samples.size() * sizeof(int16_t), 0, (sockaddr*)addr, sizeof(addr));为什么用UDP不用TCP这是老生常谈但还是要讲一遍。实时语音要求低时延TCP有重传机制丢一个包就等重传在弱网下时延会越来越大。而且TCP的拥塞控制会主动降速对实时音频是灾难。UDP丢包后只需要快速跳过用上一帧或静音填补听感上远远好于卡住等重传。语音质量靠快速恢复而不是绝对可靠。4.2 直接把PCM塞进UDP还不够加一个RTP头直接把PCM通过sendto发出去对端完全分不清哪一包对应哪个时间点所以要在数据前面加一个RTP头。标准RTP头12字节关键字段包括版本号、载荷类型、序号和时间戳。我用的RTP头结构体长这样#pragma pack(push, 1) struct RtpHeader { uint8_t cc:4; // CSRC计数 uint8_t x:1; // 扩展位 uint8_t p:1; // 填充位 uint8_t v:2; // 版本号固定为2 uint8_t pt:7; // 载荷类型 uint8_t m:1; // 标记位 uint16_t seq; // 序号 uint32_t timestamp; // 时间戳 uint32_t ssrc; // 同步源标识 }; #pragma pack(pop)组包的时候seq每发一包加一timestamp每20ms增加160也就是一帧的采样数ssrc用来标识来源防止多路流串扰。特别注意RTP头里的seq、timestamp、ssrc都是网络字节序写入前必须用htons和htonl转换读出来时用ntohs、ntohl。我早期在字节序上踩过坑对端声音听起来像加速了一样就是因为时间戳高低字节反了。4.3 抖动缓冲迟到的包不等于丢掉的包网络包走的路径不完全一样到达时间会有抖动可能先发的反而后到。如果每到一个包就播放声音会忽快忽慢。所以接收端一般会先缓冲几个包按时间戳排序再以稳定的节奏喂给播放器。这个缓冲大小需要反复调。缓冲太小会频繁出现无数据可播声音像结巴缓冲太大则每次说话有半拍延迟。我在局域网环境实测50ms左右比较平衡普通跨城网络可能要加到120ms以上。参考经验如下网络环境建议抖动缓冲时长说明局域网40~60ms抖动小缓冲尽量小以降低延迟普通宽带80~120ms消掉大部分网络抖动跨城/弱网150~250ms需要权衡延迟与卡顿的取舍抖动缓冲内部很像排队打饭窗口只按顺序叫人排错位的人插队会造成混乱。所以一般用有序结构管理每个包按时间戳插进去播放时从队头取时间最早的包。5. 多线程与回调C并发里最容易翻车的部分5.1 用条件变量实现生产消费队列网络电话天生多线程采集线程、发送线程、接收线程、播放线程再加上界面线程。C11之后我都是用std::thread加mutex、condition_variable来做队列。一个典型的安全队列templatetypename T class SafeQueue { public: void push(T item) { std::lock_guardstd::mutex lock(mtx_); queue_.push(std::move(item)); cv_.notify_one(); } bool pop(T out, int timeoutMs) { std::unique_lockstd::mutex lock(mtx_); if (!cv_.wait_for(lock, std::chrono::milliseconds(timeoutMs), [this] { return !queue_.empty(); })) return false; out std::move(queue_.front()); queue_.pop(); return true; } private: std::mutex mtx_; std::condition_variable cv_; std::queueT queue_; };发送线程就while (queue.pop(frame, 100)) { 组RTP包; sendto; }。接收线程类似recvfrom阻塞拿数据解析后压进播放队列播放回调再从队列取。这个模型的优点是线程之间完全解耦。但要注意notify_one只会唤醒一个等待线程如果有多个消费者同时等待应该用notify_all或者保证只有一个消费者。我早期就在这里出过问题收发的包偶尔闷在队列里没人取声音时有时无。5.2 回调函数的正确打开方式C回调在新代码里一般是std::function配合lambda。音频库需要往用户代码回传数据时如果直接用普通函数指针就没法捕获对象状态。用std::function加lambda是最自然的方式。但std::function调用有间接开销在音频回调这种高频路径上如果一帧只调用一次体感差异其实很小直接用即可。真正要小心的是回调函数里捕获了this万一对象在回调触发期间被销毁就会形成悬垂引用程序会莫名其妙崩溃。我养成的习惯是线程或音频流先停止并join完成才允许销毁相关对象。顺序如果反了十有八九会崩在析构函数附近。5.3 无锁队列什么时候才值得上很多网络电话的C源码一上来就用无锁队列、内存屏障看起来很硬核但中小型项目完全没必要。无锁队列最难的不是写出来而是证明它正确跨平台的无锁队列尤其容易在x86和ARM上表现出不同行为。我自己的原则是队列竞争不严重时先上mutex加condition_variable然后用性能工具看一下。实测在单路通话、一帧20ms的场景下互斥锁完全够用锁冲突微乎其微。为性能徒增复杂度是我在这个项目里学会不去做的事情之一。6. 调试实录卡顿、回声、崩溃到底怎么查6.1 声音像结巴第一步看缓冲而不是看网络我最早跑的demo声音断断续续。一开始怀疑UDP丢包结果在接收端加统计输出后发现丢包率只有0.1%。问题根本不是网络而是接收侧播放缓冲太小加上系统调度偶尔延迟声卡拿不到数据只能放静音。排查方法很简单在每个AudioFrame里记录从收到到播放的间隔时间打印出来看波动。如果波动很大说明抖动缓冲没有起到平抑作用要么调大缓冲要么检查是不是有别的线程抢占了CPU时间。后来我把播放线程优先级调高又在JitterBuffer里改成低于40ms就补静音高于200ms就快进追赶声音立刻正常了。6.2 回声其实是声学问题不全是代码问题第一次两个人通话测试对方能听到自己的回音。第一反应是写回声消除算法研究了两天AEC非常痛苦。后来发现原因是笔记本外放和麦克风离得太近声音外放后直接被麦克风采进去传输回去了。调这种问题要分两层如果是全双工扬声器通话就必须用WebRTC的AEC模块或音频库自带的回声消除如果只是耳机就先排查放音音量是否过大、麦克风增益是否过高。先做测试隔离再决定要不要上算法不然容易被带到算法坑里。6.3 崩溃八成在析构和线程生命周期这种多线程程序的崩溃我最常碰到两种一是回调里访问了已释放的对象二是两个线程同时操作同一个vector或map而没有加锁。前者是生命周期问题后者是数据竞争问题。用-fsanitizethread或Windows下的Application Verifier能很快暴露但这些工具要在项目早期就开着后期再开全是噪音。Debug模式下C标准库的迭代器调试也能帮上忙。我在MSVC下长期开着_ITERATOR_DEBUG_LEVEL2GCC下开_GLIBCXX_DEBUG性能受影响但排查期非常值得。日常做一次完整的流程检查所有线程启动后对象析构前是否确认线程已经结束两个线程访问共享数据时是否都加了同一把锁。把这两个问题盯住80%的偶发崩溃都能避免。我个人再回头看整个项目最大的体会是源代码只是骨架真正决定通话好不好用的是缓冲区、线程生命周期、对象复用这些看不见的部分。如果你也想写一个网络电话建议从点对点局域网通话开始跑通再一步步加功能。前期花时间把模块和数据流画清楚后面省的时间会远超预期。最后再分享一个小经验每改一次代码都跑一遍持续10分钟以上的通话测试两端各打印一份统计日志很多看似偶发的卡顿和崩溃都是这样被一点点抓出来的。本文还有配套的精品资源点击获取