简介:一份采用C语言编写的RTSP客户端源码包,面向音视频流媒体开发人员、嵌入式工程师及协议分析爱好者,可帮助快速理解实时流协议(RTSP)从会话建立、媒体协商到播放控制的完整交互链路。代码已经过编译验证,运行可用,核心模块覆盖DESCRIBE、SETUP、PLAY等请求报文的构造与发送、服务器响应状态码解析、SDP会话描述解码,以及基于TCP/UDP套接字的网络收发处理,逻辑分层较清晰,便于定位问题与二次开发。压缩包共28个文件,大小约157KB,以.c源文件、.h头文件和.o目标文件为主,另附Makefile构建脚本、.a静态库和说明文档。不同模块职责相对独立,既有协议栈核心实现,也有辅助打印与公共工具,目录结构适合作为C语言网络编程的学习样板。目前已有349人浏览学习,适合用作RTSP客户端开发的入门参考,也可在此基础上扩展暂停、快进、调度等控制逻辑,或移植至特定嵌入式平台。
1. 为什么还要用 C 写 RTSP 客户端:它解决的远不只是“拉流”
很多做过音视频的人第一反应是:RTSP 拉流直接调 ffmpeg 不就行了?但在机顶盒、门禁、IPC 网关这类资源受限设备上,ffmpeg 体积和依赖经常塞不进去,这时就得用 C 语言自己实现一个精简的 RTSP 客户端。这个 rtsp.rar 工程不是空壳演示,里面有完整的 rtsp.c 核心逻辑、rtsp_command.c 请求构造、sdp_decode.c SDP 解析器和能直接编译的 makefile,甚至带了 dtt7167_7105_ST40_OS21_32BITS 这种嵌入式平台目录。它适合两类人:一类是想搞懂 RTSP 控制面交互流程的嵌入式开发者,另一类是要在自有平台上做协议二次定制的工程师。下面按我的拆解顺序,从协议交互、SDP 解析、网络层到移植踩坑,一路讲清楚。
2. 从 DESCRIBE 到 TEARDOWN:rtsp_command.c 里的请求构造与状态机
2.1 先搞清 RTSP 客户端到底在干什么:控制面与数据面分离
RTSP 和 RTP 是两套完全不同的东西,这是新手最容易绕晕的地方。RTSP 本身不传输音视频数据,它只负责“控制”——告诉服务器我想看哪个流、用 TCP 还是 UDP 收、从第几秒开始播。真正承载音视频的是 RTP 包,走的是另一个端口或另一条通道。
所以客户端在逻辑上要维护一个状态机:连接建立后处于 INIT,发出 DESCRIBE 请求拿到 SDP 媒体描述后进入 DESCRIBED,SETUP 成功后拿到 Session ID 进入 READY,PLAY 成功进入 PLAYING,TEARDOWN 后再回到 INIT。rtsp.c 这个核心文件就是在维护这套状态流转,rtsp_command.c 负责把每个请求拼成合法的 RTSP 报文。
RTSP 协议定义了一组请求方法,客户端实际用到的主要是这四个:
| 方法名 | 作用 | 请求里必须带的头 |
|---|---|---|
| DESCRIBE | 向服务器要媒体描述(SDP) | Accept: application/sdp |
| SETUP | 协商传输通道,建立会话 | Transport、Session(第二次起) |
| PLAY | 开始发送 RTP 数据 | Session、Range(可选) |
| TEARDOWN | 停止会话,释放资源 | Session |
这里有个常见误解:很多初学者认为先 PLAY 再等数据回来就行,跳过 SETUP。结果服务器直接回 454 Session Not Found。原因是 RTSP 服务器必须先通过 SETUP 建立一个会话上下文,PLAY 指令里带的 Session ID 就是 SETUP 响应里返回的。
2.2 rtsp_command.c 的报文拼装:CSeq、URL 与头字段顺序
用 C 拼 RTSP 报文没有魔法,就是按 RFC 2326 的格式把行拼好,再保证每行以\r\n结尾。rtsp_command.c 里最核心的活就是根据当前状态决定发哪个方法,并用自增的 CSeq 标识每一次请求。CSeq 非常关键,服务器响应里会带上同一个 CSeq,客户端靠它对上号。
static int build_describe_request(char *buf, size_t buf_len, const char *rtsp_url, uint32_t *cseq) { int n = 0; n += snprintf(buf + n, buf_len - n, "DESCRIBE %s RTSP/1.0\r\n" "CSeq: %u\r\n" "Accept: application/sdp\r\n" "User-Agent: rtsp_client_c/1.0\r\n" "\r\n", rtsp_url, *cseq); (*cseq)++; return n; }这段代码有三个点需要注意。第一是第一个\r\n之前的方法行,格式是方法 + 空格 + URL + 空格 + RTSP/1.0,URL 通常是完整的rtsp://ip:port/stream1,但有些服务器允许只写相对路径,这个看服务端实现。第二是 CSeq 必须在每次请求后自增,否则并发场景下响应就乱套了。第三是最后一定要有一个空行\r\n,很多第一次写的人漏掉,导致服务器一直不回应。
rtsp_command.c 里除了 DESCRIBE,还会按状态生成 SETUP、PLAY、TEARDOWN 三个请求。SETUP 要多带一个 Transport 头,格式通常是:
Transport: RTP/AVP/TCP;unicast;interleaved=0-1interleaved=0-1表示 RTP 数据会复用在 RTSP 这条 TCP 连接上,channel 0 是 RTP,channel 1 是 RTCP。如果服务器支持 UDP,Transport 可能会协商成RTP/AVP/UDP,这时客户端还得再开一个端口收 RTP。
2.3 一次完整播放时序:状态怎么一步步推进
把四个请求串起来,就是一次完整的 RTSP 会话。以 TCP 传输为例:
- 客户端向 554 端口发起 TCP 连接,connect 成功进入 INIT。
- 发送 DESCRIBE,服务器回
RTSP/1.0 200 OK,响应体是 SDP 文本。 - 客户端解析 SDP,拿到媒体类型和 control 字段,然后发 SETUP。
- 服务器回
200 OK,Response 头里带Session: 12345678,客户端必须原样保存。 - 客户端发 PLAY,带
Session: 12345678,服务器回 200 后开始往通道里写 RTP 数据。 - 收到第一个 RTP 包,客户端进入 PLAYING 状态,之后的事就交给解码器了。
- 用户停止播放,发 TEARDOWN,服务器回 200,关闭连接。
把这段流程写成代码,在 rtsp.c 里对应的就是一个 switch 状态推进:
static int rtsp_state_machine(struct rtsp_session *session, const char *resp_body) { switch (session->state) { case RTSP_STATE_INIT: return build_describe_request(session); case RTSP_STATE_DESCRIBED: return build_setup_request(session); case RTSP_STATE_READY: return build_play_request(session); case RTSP_STATE_PLAYING: return build_teardown_request(session); default: return -1; } }这里面的逻辑不复杂,但每个状态之间的迁移条件必须严格:DESCRIBED 状态必须等 SDP 解析成功才能进,READY 状态必须等 SETUP 响应拿到 Session ID 才能进。rtsp_resp.c 里做的就是响应解析,把状态码、CSeq、Session 头、SDP body 分别摘出来,再喂给状态机。
3. 把 SDP 解析做扎实:sdp_decode.c 决定能不能 SETUP 成功
3.1 SDP 不只是一个文本块:从 v= 到 a=control 的职责分工
SDP(Session Description Protocol)是 RTSP 服务器用文本描述媒体信息的格式,一个典型响应长这样:
v=0 o=- 123456 2 IN IP4 192.168.1.64 s=Stream 1 m=video 0 RTP/AVP 96 a=control:trackID=0 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=42001F m=audio 0 RTP/AVP 8 a=control:trackID=1 a=rtpmap:8 PCMA/8000每个m=定义一路媒体流,后面的a=control告诉你在 SETUP 时该用哪个 URL。rtsp.rar 里这块拆得很细,专门做了几个源文件,我理了一下分工:
| 文件 | 职责 | 关键入口 |
|---|---|---|
| sdp_decode.c | 把 SDP 文本解析成结构化数据 | sdp_parse() |
| sdp_util.c | 字符串处理、control URL 拼接、参数提取 | sdp_get_media() |
| sdp_dump.c | 把解析结果打印出来,用于调试 | sdp_dump_print() |
| sdp_error.h | 解析错误码定义 | SDP_ERR_* |
| sdp_private.h | 解析器内部结构,外部不用管 | — |
这个拆分对做嵌入式非常友好,解析逻辑和解码逻辑分开,调试 SDP 时直接跑 sdp_dump,不用在业务代码里打一堆 printf。
3.2 哪些字段直接决定 SETUP 参数
解析 SDP 不是把文本存下来就完事,而是提取出几个关键值。最重要的三个:
m=行确定媒体类型和 RTP payload type,例如m=video 0 RTP/AVP 96,说明 video 流使用 RTP payload 96。a=control确定 SETUP 请求里用的 URL。有的服务器给完整地址rtsp://ip:port/stream1/trackID=0,有的只给相对路径trackID=0,后者需要与基本 URL 拼接。a=rtpmap确定 RTP 包的编码格式,H264 的 clock rate 是 90000,PCMA 是 8000。解码器初始化要靠这个。
a=fmtp里的参数同样重要,比如 H264 的packetization-mode=1表示采用非交错模式,sprop-parameter-sets里可能直接带 SPS/PPS。如果在代码里只挑m=和a=rtpmap,忽略 fmtp,解码器大概率黑屏或者花屏。
3.3 解析结果怎么被 rtsp_resp.c 消费
一个干净的解析流程是:网络层收到完整的 RTSP 响应体后,先判断状态码是不是 200,再取 body 里的 SDP,调 sdp_parse,最后把解析结果存进 session 结构体:
static int handle_describe_response(struct rtsp_session *session, const char *body, size_t body_len) { struct sdp_info *sdp = sdp_parse(body, body_len); if (!sdp) { return SDP_ERR_PARSE_FAILED; /* 参考 sdp_error.h 里的错误码 */ } /* 把第一路 video 和 audio 的 control URL 存下来,SETUP 要用 */ session->video_control = sdp_get_control(sdp, "video"); session->audio_control = sdp_get_control(sdp, "audio"); session->video_pt = sdp_get_payload_type(sdp, "video"); session->audio_pt = sdp_get_payload_type(sdp, "audio"); /* 调试用:sdp_dump_print(sdp, stdout); */ return 0; }sdp_parse 返回 NULL 的情况在实际开发里并不少见。常见的翻车原因有两个:一是 body 被截断,RTSP 响应可能被 TCP 拆成多个包,如果你在 recv 里只读了一次就丢给解析器,SDP 后半部分直接丢失;二是服务器在a=control:后面没有赋值,sdp_util.c 里做字符串拼接时没判空,strcat 直接写入非法地址。
检查 SDP 完整性最简单的方法,是把 sdp_dump 的打印结果和 Wireshark 里的响应体对比。如果 dump 出的媒体列表比抓包少,通常就是接收循环没读全;如果字段全但解析崩,再往判空逻辑上查。
4. 网络与平台层:networksvr.c 的 socket 管理,以及 OS21 平台适配
4.1 客户端 socket 的选型:TCP、超时和缓冲区
RTSP 客户端默认走 TCP 554 端口。networksvr.c 的名字看着像“网络服务器”,实际上在这个工程里承担的是连接管理和收发缓冲的公共封装,客户端和它共用一套收发逻辑也很正常。
连 TCP 本身不复杂,但要注意三个点:必须设置接收超时,避免 DESCRIBE 发出去后服务器不响应,recv 一直阻塞把整个线程卡死;必须确认 connect 之前把 sockaddr_in 清零;必须处理 EINTR 中断重试。下面是我一般在 C 工程里会写的连接函数。
int rtsp_connect(struct rtsp_conn *conn, const char *host, uint16_t port, int timeout_ms) { struct sockaddr_in addr; struct timeval tv; conn->fd = socket(AF_INET, SOCK_STREAM, 0); if (conn->fd < 0) return -1; /* 接收超时建议设 3~5 秒,太短容易误判,太长卡死用户体验差 */ tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; setsockopt(conn->fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, host, &addr.sin_addr); if (connect(conn->fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { close(conn->fd); conn->fd = -1; return -1; } return 0; }参数说明:host 是摄像头或流媒体服务器的 IP 字符串;port 不填 554 时,可以从 rtsp:// 的 URL 里解析出来;timeout_ms 建议 3000~5000,只对 recv 生效,不限制 connect 自身的阻塞时间。
networksvr.c 里如果实现了自己的 recv 封装,大概率会处理粘包问题。RTSP 响应和 RTP 数据复用在同一条 TCP 连接时,recv 拿到的可能是一段混合数据,有 RTSP 响应,也有二进制 RTP 帧。处理办法是先判断首字节:如果是数字(ASCII 字符),当作 RTSP 文本处理;如果是$(0x24),说明后面跟着的是 interleaved RTP 帧头,按 channel 和长度拆包。
4.2 rtsp_comm.c 与 rtsp.c 的分工:会话状态放在哪
rtsp_comm.c 这个名字容易让人误解,它不是“通信”而是“公共”的意思,代码里一般放公共的数据结构定义、内存分配、错误码统一封装。rtsp.c 才是业务状态机所在的地方,维护 Session ID、CSeq、当前状态和解析出来的 SDP 参数。
实际开发里我建议保持这个分层:rtsp.c 只做状态流转和业务决策,不要自己直接调 socket,所有收发都走 comm 层。这样后面换平台时,只需要改 comm 层和网络层,RTSP 状态机一行都不用动。
4.3 从 ST40/OS21 换到 Linux:平台相关要做哪些调整
工程里的 dtt7167_7105_ST40_OS21_32BITS 目录说明它原本面向 ST 的 ST40 主控和 OS21 RTOS,属于机顶盒/DVB 方案。想把这套代码挪到 Linux 上,核心要改三处:
| 平台项 | OS21 原版 | Linux 适配 |
|---|---|---|
| 线程创建 | OS_CreateThread 类接口 | pthread_create |
| Socket API | OS21 自带 socket 封装 | 标准 BSD socket |
| 字节序 | 大端小端由芯片决定 | 统一用 htons/ntohs 处理 |
socket 的 API 形态在 OS21 和 Linux 上很接近,主要是线程和定时器的差异。先把 makefile 里的平台库路径去掉,再把 thread 创建改成 pthread,一般两个小时内能跑通 DESCRIBE。
5. 编译与移植避坑:makefile、链接错误与五条实测踩坑记录
5.1 makefile 与 makefile.bak:先跑哪个,objs 目录是干什么的
压缩包里同时有 makefile 和 makefile.bak,前者应该是当前用的构建配置,后者是备份,通常改动前会保留一份。objs 目录放的是编译中间产物 .o 文件,如果压缩包里带了 .o,说明工程是编译过的,可以直接链接。
我的习惯是先跑make -n看看编译命令,确认交叉编译器路径对不对,再真正 make。如果直接 make 报找不到头文件,优先看 makefile 里的CFLAGS有没有包含平台目录:
make -n | head -30输出里重点看-I参数是否指向了 dtt7167_7105 所在目录,以及-L是否指向平台库路径。ST40/OS21 的库一般需要单独指定,缺失时会出现大量undefined reference。
5.2 五条实测踩坑记录
下面按现象到原因再到解决的顺序写,都是实际开发里最容易遇到的。
踩坑一:链接时报大量 undefined reference,指向 os 开头的符号
现象是 make 本身通过,但 ld 阶段报一堆undefined reference to os_create_thread之类的错误。原因是平台库没有加到链接参数里,或者顺序不对。解决办法是把 OS21 库放在对象文件之后重新链接,gcc 对静态库依赖顺序非常敏感,库必须在用到它的 .o 后面。
踩坑二:SDP 解析到一半程序崩溃,指向 strcpy/strcat
现象是 DESCRIBE 响应正常,但解析到第二路 media 时 Segmentation fault。原因是某些服务器不返回a=control,代码没判空直接拼接。解决办法是解析前统一过一层sdp_util.c里的安全拼接函数,先判断字符串是否为 NULL,再决定是拼接还是给默认值。
踩坑三:DESCRIBE 返回 401 Unauthorized
现象是 URL 没问题,但服务器要求认证。原因是摄像头或流媒体服务开了 basic 认证,裸发 DESCRIBE 被拒绝。解决办法是在请求头里加Authorization: Basic base64(user:pass),用户名密码冒号拼接后做 Base64,一次性加上后再重发。
踩坑四:设置了 SO_RCVTIMEO 但 recv 还是卡死
现象是断网之后 recv 迟迟不返回。原因是在多线程环境下,别的模块把 socket 的选项重置了,或者你用的 recv 封装是循环读取,内层超时返回后外层又继续读。解决办法是在 recv 外层判断 errno,超时返回 EAGAIN/EWOULDBLOCK 就立即退出,不要盲目重试。
踩坑五:PLAY 返回 454 Session Not Found
现象是 SETUP 成功,PLAY 失败。原因是 Session ID 没保存好,或者保存成了 int 类型导致被截断。解决方法是把 Session 头当成字符串原样保存,拼接在 PLAY 请求里。这块在处理响应时最容易翻车。
5.3 调试手段:sdp_dump 与抓包对比
遇到解析问题,不要猜。先调用 sdp_dump 输出,再打开 Wireshark 抓包对比同一段 SDP 文本。抓包时注意过滤 RTSP 端口,然后用Follow TCP Stream看完整的响应体。如果程序 dump 出的内容比抓包少,一定是接收循环没读完;字段全但崩溃,才往解析逻辑查。
6. 进阶:把这套代码改成可验证的 RTSP 拉流 DEMO
工程里的 API 不一定是rtsp_client_open这种形式,但 rtsp.h 里定义的原则是统一的:创建会话、设置 URL、打开连接、开始播放、停止释放。你可以把这些接口包装成下面的形式来验证整条链路:
struct rtsp_client *client = rtsp_client_create(); if (!client) return -1; rtsp_client_set_url(client, "rtsp://192.168.1.64:554/stream1"); if (rtsp_client_open(client) != 0) { printf("DESCRIBE/SETUP failed\n"); rtsp_client_destroy(client); return -1; } rtsp_client_play(client); /* 此时可以打印 state 和 RTP 收包计数,验证是否进入 PLAYING */ rtsp_client_close(client); rtsp_client_destroy(client);验证方法我用的是两步:先拿一个公共的 rtsp 测试流地址在 Ubuntu 上编译跑一遍,看 DESCRIBE 的 SDP 是否被正确 dump;第二步再用 Wireshark 同时抓包,确认 CSeq 递增、Session 头原样回传和 RTP 数据通道的$帧头。如果这些都正常,再把目标机切到板子环境,只改网络层。
有一件事我记得特别深:有一次移植到新的摄像头方案上,业务代码全部跑通,但画面每隔几秒就花一帧。查了两天才发现是 SDP 里a=fmtp的sprop-parameter-sets带了 SPS/PPS,而我解析时图省事没存,解码器每次都用旧的参数集。从那以后我每移植一套 RTSP 拉流,都强制先过一遍 sdp_dump + 抓包对比,再动业务代码。希望这些坑能帮你少走几步弯路。
本文还有配套的精品资源,点击获取