
1. 为什么要在嵌入式系统里跑PJSIP第一次把PJSIP往STM32H7上搬的时候我心里其实没底。PC上跑SIP协议栈内存随便用线程随便开socket随便建但到了MCU上RAM按KB算任务栈按字算网络缓冲区还得跟DMA抢。PJSIP这个库本身是为通用平台设计的移植到嵌入式系统需要做不少裁剪和适配工作。PJSIP是一套开源的SIP协议栈实现它不只包含SIP信令还整合了SDP协商、RTP媒体传输、音频设备抽象等模块。换句话说它把VoIP通话所需的大部分协议层都封装好了。在嵌入式设备上集成PJSIP通常意味着你要做一个IP话机、对讲终端、语音网关或者带通话功能的行业设备。这类设备的特点是资源受限、实时性要求高、长期无人值守运行所以不能照搬PC端的配置。STM32H7系列是我个人比较推荐跑PJSIP的MCU平台。它主频能到480MHz带DTCM和AXI SRAM还有以太网MAC和SAI音频接口硬件底子够。FreeRTOS作为RTOS负责任务调度和同步PJSIP作为协议栈跑在上面底层用LwIP做TCP/IP音频走I2S或SAI加DMA。这套组合我在几个项目里都用过稳定性可以但前提是把几个关键点处理对。这篇文章适合谁看如果你正在做嵌入式VoIP相关的产品或者手头有个STM32H7的板子想跑SIP通话又或者你已经在移植PJSIP但卡在某个环节那下面的内容应该能帮到你。我会从整体设计思路讲起然后拆解核心细节再走一遍实操流程最后把踩过的坑整理出来。2. 整体架构设计与方案选型2.1 为什么选PJSIP而不是自己撸一套SIP有人会问SIP协议本身不算特别复杂自己实现一个精简版行不行。我试过结论是如果你只做注册和基本呼叫自己写确实能跑通但一旦涉及SDP协商、多编解码器支持、会话刷新、NAT穿透这些场景工作量会指数级上升。PJSIP经过多年迭代这些边界情况都处理过了而且它的内存池设计对嵌入式场景相对友好。PJSIP的内存管理用的是pool机制不是每次malloc/free而是一次性从池子里分配用完整个池子释放。这个设计在嵌入式系统里很关键因为频繁的malloc/free会导致堆碎片化跑几天可能就分配不出连续内存了。PJSIP的pool机制天然规避了这个问题你只需要在初始化时给每个会话分配一个pool会话结束时释放整个pool就行。另一个选PJSIP的理由是它的可裁剪性。通过config_site.h文件你可以关掉不需要的功能模块比如视频、会议、TURN服务器等只保留SIP和音频相关部分。裁剪之后Flash占用可以控制在300KB到500KB之间RAM占用取决于并发会话数单路通话大概需要200KB到400KB的动态内存。2.2 FreeRTOS与PJSIP的任务划分PJSIP在嵌入式环境下的运行需要至少两个线程一个负责协议栈的事件循环一个负责媒体处理。在FreeRTOS下我通常这样划分任务任务名称优先级栈大小职责pjsip_task中4KB处理SIP信令、注册、呼叫控制media_task高2KB音频采集、播放、RTP收发lwip_task中2KBTCP/IP协议栈处理app_task低2KB按键、LED、UI等应用逻辑优先级设置有个原则媒体任务的优先级要高于信令任务因为音频断流的后果比信令延迟严重得多。但媒体任务也不能无限高否则会饿死LwIP导致网络包处理不及时。我一般把媒体任务设为比LwIP高一级信令任务和LwIP同级或低一级。栈大小的分配需要根据实际编译结果调整。PJSIP的函数调用层次比较深尤其是SDP协商那部分栈需求可能超过2KB。我建议先用大栈跑通然后用FreeRTOS的栈溢出检测功能configCHECK_FOR_STACK_OVERFLOW设为2观察实际使用量再逐步缩小。2.3 STM32H7的内存布局规划STM32H7的内存区域比较多有DTCM、ITCM、AXI SRAM、SRAM1~SRAM4、Backup SRAM等。跑PJSIP的时候内存布局直接影响性能。我的分配方案是这样的DTCM (128KB)放FreeRTOS的堆和PJSIP的关键数据结构。DTCM速度最快但只能被CPU访问DMA访问不了。AXI SRAM (512KB)放LwIP的内存池和网络缓冲区。AXI SRAM可以被DMA访问适合做以太网收发。SRAM1/2 (256KB)放音频DMA缓冲区和RTP包缓冲。SRAM3 (64KB)放PJSIP的pool内存如果不够再从AXI SRAM划。这里有个坑STM32H7的DTCM地址是0x20000000起始AXI SRAM是0x24000000起始。链接脚本里要把不同段分配到不同区域否则DMA访问DTCM会直接HardFault。我当初就是没注意这点以太网DMA描述符放在DTCM里一收包就死机查了两天才定位到。2.4 音频通路的设计考量嵌入式VoIP的音频通路通常是麦克风 - ADC/I2S - DMA缓冲 - 编码器 - RTP打包 - 网络发送接收方向反过来。PJSIP的媒体框架支持自定义音频设备你需要实现一个pjmedia_aud_dev_factory把底层I2S/SAI的读写接口对接上去。采样率方面窄带通话用8kHz宽带用16kHz。STM32H7的SAI接口支持这两种速率配置起来不复杂。关键是要保证DMA双缓冲的切换时机和PJSIP的媒体时钟同步。我一般用SAI的DMA半满中断和全满中断来触发音频数据的读写每个缓冲块10ms的数据量这样延迟和CPU负载比较平衡。3. 核心细节解析与实操要点3.1 PJSIP的裁剪配置怎么写PJSIP的裁剪从config_site.h开始。这个文件放在你的工程include路径下PJSIP编译时会优先读取它。下面是我常用的一份配置#define PJ_CONFIG_MTU 1500 #define PJ_HAS_IPV6 0 #define PJ_HAS_TCP 1 #define PJSIP_HAS_TLS_TRANSPORT 0 #define PJSIP_HAS_DTLS 0 #define PJMEDIA_HAS_VIDEO 0 #define PJMEDIA_HAS_SPEEX_CODEC 0 #define PJMEDIA_HAS_G711_CODEC 1 #define PJMEDIA_HAS_G722_CODEC 1 #define PJMEDIA_HAS_ILBC_CODEC 0 #define PJMEDIA_HAS_OPUS_CODEC 0 #define PJMEDIA_AUDIO_DEV_HAS_PORTAUDIO 0 #define PJMEDIA_AUDIO_DEV_HAS_ALSA 0 #define PJMEDIA_AUDIO_DEV_HAS_WMME 0 #define PJMEDIA_AUDIO_DEV_HAS_NULL_AUDIO 1 #define PJ_HAS_SSL_SOCK 0 #define PJ_DEBUG_LEVEL 3几个关键点解释一下。PJ_CONFIG_MTU设为1500是匹配以太网MTU避免IP分片。PJ_HAS_TCP设为1是因为有些SIP服务器要求TCP传输虽然UDP更常用但保留TCP支持更灵活。PJMEDIA_HAS_VIDEO关掉能省不少Flash和RAM。编解码器只保留G711和G722G711是必选的G722提供宽带音质其他编解码器在嵌入式场景下要么授权问题要么算力不够。PJ_DEBUG_LEVEL设为3是输出错误和警告信息调试阶段可以设为4输出更多日志量产时设为1或2减少串口输出开销。注意修改config_site.h后必须重新编译整个PJSIP库不能只编译修改的文件因为很多宏会影响头文件的条件编译。3.2 内存池大小的计算方法PJSIP的内存池大小需要根据并发会话数和功能模块来估算。以单路通话为例主要内存开销包括SIP端点初始化约32KB每个SIP会话约16KB每个媒体会话约24KBSDP协商临时缓冲约8KBRTP接收缓冲每路约16KB音频设备缓冲约8KB单路通话总计约104KB加上LwIP的PBUF池约64KB和FreeRTOS堆约32KB总共需要约200KB的动态内存。如果要做两路并发再加约64KB。STM32H7的AXI SRAM有512KB跑两路通话绰绰有余。实际配置时我建议在pj_init之前先分配一个大块内存给PJSIP的pool factory大小按估算值的1.5倍留余量。比如单路通话分配300KB这样即使有临时峰值也不会分配失败。3.3 SIP注册与呼叫流程的代码实现PJSIP的API是事件驱动的你需要注册回调函数来处理各种事件。下面是一个简化的注册和呼叫流程static pjsua_acc_id acc_id; static pjsua_call_id current_call PJSUA_INVALID_ID; static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { pjsua_call_info ci; pjsua_call_get_info(call_id, ci); current_call call_id; pjsua_call_answer(call_id, 200, NULL, NULL); } static void on_call_state(pjsua_call_id call_id, pjsip_event *e) { pjsua_call_info ci; pjsua_call_get_info(call_id, ci); if (ci.state PJSIP_INV_STATE_DISCONNECTED) { current_call PJSUA_INVALID_ID; } } static void on_reg_state(pjsua_acc_id acc_id) { pjsua_acc_info info; pjsua_acc_get_info(acc_id, info); if (info.status 200) { // 注册成功 } } void sip_init(void) { pjsua_config cfg; pjsua_logging_config log_cfg; pjsua_media_config media_cfg; pjsua_transport_config trans_cfg; pjsua_config_default(cfg); cfg.cb.on_incoming_call on_incoming_call; cfg.cb.on_call_state on_call_state; cfg.cb.on_reg_state on_reg_state; cfg.max_calls 2; pjsua_logging_config_default(log_cfg); log_cfg.console_level 3; pjsua_media_config_default(media_cfg); media_cfg.clock_rate 8000; media_cfg.snd_clock_rate 8000; media_cfg.ec_tail_len 128; pjsua_transport_config_default(trans_cfg); trans_cfg.port 5060; pjsua_init(cfg, log_cfg, media_cfg); pjsua_transport_create(PJSIP_TRANSPORT_UDP, trans_cfg, NULL); pjsua_start(); pjsua_acc_config acc_cfg; pjsua_acc_config_default(acc_cfg); acc_cfg.id pj_str(sip:1001192.168.1.100); acc_cfg.reg_uri pj_str(sip:192.168.1.100); acc_cfg.cred_count 1; acc_cfg.cred_info[0].realm pj_str(*); acc_cfg.cred_info[0].scheme pj_str(digest); acc_cfg.cred_info[0].username pj_str(1001); acc_cfg.cred_info[0].data_type PJSIP_CRED_DATA_PLAIN_PASSWD; acc_cfg.cred_info[0].data pj_str(password); pjsua_acc_add(acc_cfg, PJ_TRUE, acc_id); }这段代码初始化PJSIP、创建UDP传输、注册账号。on_incoming_call里直接应答200 OK实际产品中可能需要先振铃等用户按键再接听。3.4 RTP媒体传输的底层对接PJSIP的媒体传输默认走UDP socket在嵌入式系统里你需要把socket操作对接到底层的LwIP。PJSIP提供了pj_sock抽象层你只需要实现pj_sock_socket、pj_sock_sendto、pj_sock_recvfrom等几个函数映射到LwIP的lwip_socket、lwip_sendto、lwip_recvfrom即可。音频设备的对接稍微复杂一些。你需要实现pjmedia_aud_dev_factory接口包括open、close、start、stop、read、write等方法。read方法从麦克风DMA缓冲取数据write方法把扬声器数据写入DMA缓冲。这两个方法会被PJSIP的媒体线程周期性调用调用间隔由media_cfg.audio_frame_ptime决定默认20ms。实操心得音频设备的read和write方法里不要做阻塞操作否则会拖垮整个媒体线程。如果DMA缓冲没准备好直接返回静音数据不要等待。4. 完整实操流程与关键环节4.1 开发环境搭建与依赖准备我用的工具链是STM32CubeIDE加GCCPJSIP源码从官方仓库拉取。编译PJSIP之前需要先准备好几个依赖LwIP 2.1.xSTM32CubeMX里可以直接勾选配置好以太网和PHY驱动。FreeRTOS 10.xCubeMX里也有配置好堆大小和任务。PJSIP 2.14从源码编译需要先写config_site.h。PJSIP的编译方式有两种一种是直接用它的Makefile交叉编译成静态库另一种是把源码文件加入你的工程一起编译。我推荐第一种因为PJSIP源码文件很多加入工程会让编译变得很慢。用Makefile编译成libpjsip.a、libpjmedia.a、libpjnath.a等几个静态库然后在工程里链接。交叉编译的命令大概是这样export CCarm-none-eabi-gcc export ARarm-none-eabi-ar export RANLIBarm-none-eabi-ranlib export CFLAGS-mcpucortex-m7 -mthumb -mfpufpv5-d16 -mfloat-abihard -O2 -ffunction-sections -fdata-sections ./configure --hostarm-none-eabi --disable-video --disable-libwebrtc --disable-opencore-amr --disable-speex-codec --disable-speex-aec --disable-ilbc-codec --disable-ssl --disable-sdl --disable-ffmpeg --prefix/path/to/install make dep make make install编译过程中可能会报一些头文件缺失的错误通常是sys/types.h、netinet/in.h这类POSIX头文件。PJSIP有嵌入式适配层在pjlib/include/pj/compat/下面有os_auto.h等文件你需要根据你的工具链选择合适的兼容头。4.2 FreeRTOS任务创建与PJSIP初始化顺序初始化顺序很关键搞错了会各种异常。我的做法是在main函数里按以下顺序执行初始化HAL库和时钟初始化FreeRTOS堆和任务初始化LwIPlwip_init创建LwIP的tcpip_thread初始化PJSIPpjsua_init创建PJSIP的worker线程启动PJSIPpjsua_start注册SIP账号PJSIP的worker线程需要你自己创建因为PJSIP默认用pthread在FreeRTOS下没有。你需要实现一个pj_thread_create的适配函数内部调用xTaskCreate。同样互斥锁、信号量、条件变量这些也要适配到FreeRTOS的对应原语。pj_status_t pj_thread_create_rtos(pj_pool_t *pool, const char *name, pj_thread_proc *proc, void *arg, pj_size_t stack_size, unsigned flags, pj_thread_t **ptr) { TaskHandle_t handle; BaseType_t ret xTaskCreate((TaskFunction_t)proc, name, stack_size / sizeof(StackType_t), arg, tskIDLE_PRIORITY 3, handle); if (ret ! pdPASS) return PJ_ENOMEM; *ptr (pj_thread_t *)handle; return PJ_SUCCESS; }4.3 音频DMA双缓冲的配置细节STM32H7的SAI接口配合DMA双缓冲是实现低延迟音频的关键。配置步骤如下在CubeMX里启用SAI1配置为I2S模式采样率8kHz或16kHz16位数据。启用DMA选择SAI1的TX和RX通道模式设为Circular双缓冲模式。设置DMA半满中断和全满中断在中断里切换缓冲块。每个缓冲块大小设为160个采样点8kHz下20ms或320个采样点16kHz下20ms。中断处理函数里不要做复杂操作只设置一个标志位让媒体任务去处理。我见过有人在DMA中断里直接调PJSIP的API结果就是随机死机因为PJSIP的API不是中断安全的。void DMA1_Stream0_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(hdma_sai1_a, DMA_FLAG_HTIF0_4)) { __HAL_DMA_CLEAR_FLAG(hdma_sai1_a, DMA_FLAG_HTIF0_4); audio_flag | AUDIO_FLAG_HALF; } if (__HAL_DMA_GET_FLAG(hdma_sai1_a, DMA_FLAG_TCIF0_4)) { __HAL_DMA_CLEAR_FLAG(hdma_sai1_a, DMA_FLAG_TCIF0_4); audio_flag | AUDIO_FLAG_FULL; } }4.4 回声消除与语音处理的参数调优嵌入式免提通话必须做回声消除否则对方会听到自己的回音。PJSIP内置了AECAcoustic Echo Cancellation但需要正确配置。关键参数是ec_tail_len它表示回声尾长单位是毫秒。小房间设64ms大房间设128ms或256ms。设得越大CPU占用越高。我一般先用128ms跑如果CPU占用超过50%再降到64ms。另外ec_options可以设置AEC的工作模式PJMEDIA_ECHO_USE_SW_ECHO是纯软件AECPJMEDIA_ECHO_USE_SPEEX是Speex AEC。STM32H7的算力跑软件AEC没问题Speex AEC效果更好但需要额外移植Speex库。注意AEC的效果和麦克风、扬声器的物理布局关系很大。麦克风离扬声器越远AEC越容易做。如果结构限制导致麦克风和扬声器很近再好的AEC也救不了需要考虑加硬件回声抑制电路。5. 常见问题与排查技巧实录5.1 注册失败问题排查SIP注册失败是最常见的问题。排查思路按以下顺序现象可能原因排查方法无任何响应网络不通或端口错ping服务器检查防火墙401/407认证失败检查用户名密码抓包看realm403服务器拒绝检查账号是否被禁用408超时检查NAT看是否需要保活500服务器内部错误联系服务器管理员抓包是最有效的排查手段。在STM32H7上抓包不太方便我通常先在PC上用同样的账号注册确认服务器和账号没问题再排查嵌入式端。如果PC能注册嵌入式不能那问题就在嵌入式端的网络配置或PJSIP配置上。5.2 音频断断续续的定位方法音频断续通常有三个原因网络丢包、DMA缓冲欠载、CPU负载过高。定位方法如下先看网络。在PJSIP的统计信息里看RTP的丢包率如果超过5%那就是网络问题。嵌入式设备通常用有线以太网丢包一般不是网络本身的问题而是LwIP的PBUF池不够或者DMA描述符不够。把PBUF_POOL_SIZE从16加到32ETH_RX_DESC_CNT从4加到8通常能解决。再看DMA。如果丢包率很低但音频还是断续那可能是DMA缓冲欠载。用示波器测SAI的帧同步信号看是否有间隙。如果有间隙说明DMA没及时填充数据需要检查媒体任务的优先级和栈大小。最后看CPU。用FreeRTOS的vTaskGetRunTimeStats看各任务的CPU占用。如果媒体任务占用超过70%说明编码器或AEC太耗算力需要降采样率或关掉AEC。5.3 内存分配失败的应急处理PJSIP在内存不足时会返回PJ_ENOMEM。如果频繁出现说明pool大小不够。但有时候pool够大还是失败那可能是内存碎片。PJSIP的pool机制本身不会产生碎片但如果你在pool之外还用了malloc/free就可能产生碎片。我的做法是所有动态内存都走PJSIP的pool不用标准库的malloc。FreeRTOS的堆只用于任务栈和队列不用于PJSIP的数据结构。这样内存使用是确定的不会因为碎片导致随机失败。如果确实需要临时分配用pj_pool_alloc从当前会话的pool里分配会话结束时自动释放。不要在pool之外单独malloc然后忘记free这是嵌入式VoIP最常见的死机原因。5.4 长时间运行后的稳定性问题嵌入式设备通常要求7x24小时运行跑几天后出问题很常见。我遇到过的稳定性问题包括TCP连接泄漏如果用了TCP传输PJSIP的TCP连接在异常断开后可能没有正确释放。解决方法是设置PJSIP_TCP_KEEP_ALIVE_INTERVAL定期发保活包。RTP端口耗尽每次通话会占用两个RTP端口如果通话频繁且端口没有及时回收端口会用完。检查pjsua_call_hangup后是否调用了pjsua_call_delete。看门狗复位如果某个任务卡死看门狗会复位。我一般给PJSIP的worker线程加一个心跳检测如果超过30秒没有心跳就主动重启PJSIP。实操心得量产固件里一定要加看门狗和异常重启机制。嵌入式设备部署在现场死机了没人去按复位键。我的做法是用独立看门狗IWDG超时设8秒PJSIP的worker线程和媒体线程都要定期喂狗。如果某个线程卡死看门狗复位整个系统重启后自动重新注册。5.5 与FreeRTOS中断优先级的配合STM32H7的中断优先级配置和FreeRTOS有冲突风险。FreeRTOS要求configMAX_SYSCALL_INTERRUPT_PRIORITY以上的中断不能调用FreeRTOS的API。以太网中断和SAI DMA中断的优先级必须设在这个阈值以下否则在中断里调用xSemaphoreGiveFromISR会触发断言。我一般把以太网中断设为5SAI DMA中断设为6configMAX_SYSCALL_INTERRUPT_PRIORITY设为5。这样以太网中断和SAI中断都可以安全调用FreeRTOS的ISR版本API。注意STM32H7的中断优先级数值越小优先级越高所以设5比设6优先级高。6. 性能优化与进阶方向6.1 CPU占用优化PJSIP在STM32H7上跑单路G711通话CPU占用大概在15%到25%之间。如果开了AEC和G722会上升到40%到60%。优化手段包括把PJSIP的编译优化等级设为-O2或-O3实测-O3能降5%到10%的CPU占用。把频繁调用的函数放到ITCM里执行STM32H7的ITCM是零等待周期的。关掉不需要的日志输出PJ_DEBUG_LEVEL设为1。用硬件CRC校验RTP包比软件CRC快很多。6.2 内存占用优化如果RAM紧张可以进一步裁剪关掉PJ_HAS_TCP只用UDP省约16KB。把PJMEDIA_MAX_SDP_MEDIA从16降到4省约8KB。把PJMEDIA_MAX_SDP_ATTR从32降到16省约4KB。音频设备缓冲从20ms降到10ms省一半缓冲内存但CPU中断频率翻倍。6.3 后续扩展方向跑通基本通话之后可以往几个方向扩展。一是加TLS加密PJSIP支持TLS传输但需要移植mbedTLSFlash占用会增加约200KB。二是加视频通话STM32H7的算力跑H264编码比较吃力但可以跑MJPEG。三是加会议功能PJSIP支持混音但多路混音对CPU和内存要求较高STM32H7最多支持3到4路。我个人在实际项目中的体会是嵌入式VoIP的难点不在协议栈本身而在资源管理和实时性保障。PJSIP已经把协议层做得很完善了你需要做的是把它和FreeRTOS、LwIP、音频硬件正确地粘合在一起。粘合层写好了系统就稳粘合层有漏洞就会各种随机问题。每次调试到深夜的时候我都会提醒自己嵌入式开发没有捷径每一个参数背后都有它的道理搞懂了再动手比盲目试错快得多。