
简介面向流媒体与网络编程开发者的RTSP服务器C语言实现源码包围绕实时流媒体协议的控制与传输展开适合希望从工程代码层面理解RTSP服务端工作机制的初中级开发者。包内共414个文件以167个.c源文件、45个.h头文件为主体辅以15个Makefile构建脚本、15个.a静态库及121个.o目标文件整体压缩包约1.26MB保留了较完整的编译产物与工程组织。已有1060人学习下载。内容覆盖RTSP方法解析、SDP响应生成、RTP/RTCP数据收发、会话状态机、多线程处理与锁同步等关键模块并包含服务器初始化、请求分发、错误处理及编译运行所需配置。读者可据此梳理协议报文处理流程对照源码理解DESCRIBE、SETUP、PLAY、PAUSE、TEARDOWN等交互学习网络服务中的并发与资源管理思路也可作为流媒体服务端二次开发与调试的参考工程。1. 从一次转圈的 ffplay 说起rtsp_server 到底解决什么问题很多人第一次拿到 rtsp_server 的 C 源码习惯先 make 再拿播放器连结果画面一直转圈日志里只有一条 OPTIONS。这个项目解决的就是把本地 H.264 文件或采集端数据按 RTSP/RTP 协议喂给播放器、NVR 或测试工具它负责监听 TCP、解析 DESCRIBE/SETUP/PLAY、生成 SDP、维护会话再把 NALU 打成 RTP 包发出去。适合想理解流媒体服务端最小闭环的 C 开发者也适合需要把算法输出快速包装成 RTSP 源做联调的人。它不追求大而全的媒体服务器功能重点在协议链路清楚、模块边界够薄方便二次开发。如果手里正好有一份 rtsp_server 的 C 源码先别急着改发送逻辑把请求链路和会话状态跑通后面加鉴权、多路流、RTCP 都会顺很多。2. 源码架构拆解目录、线程模型与配置入口这份源码包给我的第一印象是“薄”没有一上来就堆线程池和插件系统而是把 RTSP 控制面和 RTP 发送面拆成两层。常见源码包结构大致是这样不同版本文件名会有些出入但模块边界基本一致路径作用src/main.c参数解析、配置加载、启动入口src/rtsp_server.c监听、事件循环、连接生命周期src/rtsp_parser.c请求首行和头部解析、方法分派src/rtsp_response.c响应构造、CSeq 回显src/session.c会话创建、查找、超时回收src/sdp.cSDP 文本组装src/rtp_sender.cRTP 头、FU-A 分片、发送include/*.h公共结构体和接口声明etc/rtsp_server.conf监听地址、端口、媒体文件Makefilegcc 编译和清理2.1 目录结构与模块边界先看模块边界。RTSP 层只关心文本协议收请求、解析方法、查会话、回响应媒体层只关心“给我一帧 NALU我按 RTP 发出去”。两边通过rtsp_session_t里的 RTP 目标地址、SSRC、序列号和时间戳连接。这样拆的好处是后面把本地文件换成摄像头采集或者编码器输出只需要替换media_source的读取实现不用动 RTSP 解析。我一般会先盯三个结构体rtsp_server_t管监听和会话链表rtsp_session_t管单个客户端的状态和 RTP 参数media_source_t管帧来源。把这三个结构体看明白再看函数调用就不会迷路。目录里没有把 SDP 塞进 session.c也没有把 RTP 打包塞进 parser.c这种拆法对二次开发比较友好。2.2 线程模型与事件循环事件循环常见做法是单线程select连接数少的时候够用调试也直观。主循环负责 accept、读 RTSP 请求、处理会话超时RTP 发送可以放在定时器里也可以放到独立媒体线程。下面这段是主循环的骨架// src/rtsp_server.c int rtsp_server_run(rtsp_server_t *srv) { while (srv-running) { fd_set rfds; FD_ZERO(rfds); FD_SET(srv-listen_fd, rfds); int maxfd srv-listen_fd; for (rtsp_session_t *s srv-sessions; s; s s-next) { FD_SET(s-client_fd, rfds); maxfd s-client_fd maxfd ? s-client_fd : maxfd; } struct timeval tv {1, 0}; // 1 秒超时用来做会话清理 int n select(maxfd 1, rfds, NULL, NULL, tv); if (n 0 errno ! EINTR) break; if (n 0) { if (FD_ISSET(srv-listen_fd, rfds)) { accept_client(srv); } for (rtsp_session_t *s srv-sessions; s; s s-next) { if (FD_ISSET(s-client_fd, rfds)) { handle_rtsp_client(srv, s); } } } session_timeout_check(srv, time(NULL)); } return 0; }这里的tv是 1 秒不是随便写的select本身不处理超时把超时设短一点主循环每隔一秒能走到session_timeout_check清理掉已经断开的客户端。maxfd是当前监听 fd 和所有客户端 fd 里的最大值select第一个参数必须传maxfd 1。handle_rtsp_client里只做非阻塞读读到完整请求头就交给解析器别在这个函数里直接发大块 RTP否则一个慢客户端会把整个循环拖住。提示连接数一多select的 fd 集合拷贝和 1024 上限就会成为瓶颈常见做法是先保留这套接口后面把select换成epoll上层会话管理不用大改。2.3 配置加载与启动入口启动入口通常很短先把配置读进来再创建 server最后进事件循环。配置项不需要多够联调就行// src/main.c typedef struct { char listen_ip[64]; int listen_port; int max_sessions; int session_timeout; char media_file[256]; } rtsp_config_t; static int config_load(rtsp_config_t *cfg, const char *path) { // 先给默认值再用配置文件覆盖避免配置缺项直接崩 strcpy(cfg-listen_ip, 0.0.0.0); cfg-listen_port 8554; cfg-max_sessions 64; cfg-session_timeout 60; strcpy(cfg-media_file, media/sample.h264); // parse_ini(path, cfg) 逐项读取这里只保留关键流程 return parse_ini(path, cfg); } int main(int argc, char **argv) { rtsp_config_t cfg; const char *conf argc 1 ? argv[1] : etc/rtsp_server.conf; if (config_load(cfg, conf) ! 0) { fprintf(stderr, load config failed: %s\n, conf); return -1; } rtsp_server_t *srv rtsp_server_create(cfg); if (!srv) return -1; rtsp_server_run(srv); rtsp_server_destroy(srv); return 0; }config_load先写默认值再解析文件这个顺序很重要。很多联调翻车不是协议问题而是配置文件少写了一项端口变成 0 或者媒体路径为空。rtsp_server_create里做三件事创建监听 socket、绑定listen_ip:listen_port、初始化会话链表。media_file只是默认媒体源后面做多路流时这个字段会被 URL 路由替代。3. RTSP 请求处理链路从 OPTIONS 到 TEARDOWN 的状态机RTSP 是文本协议但真正写起来坑都在头部字段和状态迁移上。播放器连上来通常先发 OPTIONS再 DESCRIBE 拿 SDP然后 SETUP 协商传输方式PLAY 开始推流最后 TEARDOWN 结束。服务端要保证每个响应都带回对应的 CSeqSETUP 之后的响应要带 SessionPLAY 之后会话状态要切到播放中。3.1 RTSP 方法分派与请求解析方法分派一般用字符串比较别嫌土调试时一眼能看出走到哪个分支// src/rtsp_parser.c typedef enum { RTSP_OPTIONS 0, RTSP_DESCRIBE, RTSP_SETUP, RTSP_PLAY, RTSP_PAUSE, RTSP_TEARDOWN, RTSP_UNKNOWN } rtsp_method_t; rtsp_method_t rtsp_method_from_str(const char *m) { if (!strcmp(m, OPTIONS)) return RTSP_OPTIONS; if (!strcmp(m, DESCRIBE)) return RTSP_DESCRIBE; if (!strcmp(m, SETUP)) return RTSP_SETUP; if (!strcmp(m, PLAY)) return RTSP_PLAY; if (!strcmp(m, PAUSE)) return RTSP_PAUSE; if (!strcmp(m, TEARDOWN)) return RTSP_TEARDOWN; return RTSP_UNKNOWN; }请求解析先取首行再逐行扫头部。首行格式是METHOD URL RTSP/1.0头部里最需要关心的是CSeq、Session、Transport做鉴权时再加Authorization。下面这段保留了核心逻辑int rtsp_parse_request(char *buf, size_t len, rtsp_request_t *req) { char *line_end strstr(buf, \r\n); if (!line_end || (size_t)(line_end - buf) 1024) return -1; // 形如DESCRIBE rtsp://127.0.0.1:8554/live RTSP/1.0 if (sscanf(buf, %15s %1023s %15s, req-method, req-url, req-version) ! 3) return -1; req-method_id rtsp_method_from_str(req-method); char *p line_end 2; while (p buf len) { char *e strstr(p, \r\n); if (!e || e p) break; if (!strncasecmp(p, CSeq:, 5)) { req-cseq atoi(p 5); } else if (!strncasecmp(p, Session:, 8)) { // 提取 session id后续用 session_find 查会话 } else if (!strncasecmp(p, Transport:, 10)) { // 解析 RTP/AVP;unicast;client_port5000-5001 } p e 2; } return 0; }sscanf里把 method 限制在 15 字节、URL 限制在 1023 字节是为了防止超长请求把栈写穿。头部比较用strncasecmp因为不同播放器对CSeq、cseq的大小写并不完全统一。Transport头里可能带client_port也可能带interleaved0-1解析时要把这两种模式分开记录后面发送 RTP 才知道往 UDP 端口发还是往 RTSP TCP 连接里塞。3.2 SETUP、PLAY、TEARDOWN 的会话状态会话状态不用设计得很复杂四态足够INIT、READY、PLAYING、PAUSED。SETUP 成功进 READYPLAY 进 PLAYINGPAUSE 回 PAUSEDTEARDOWN 直接释放。关键代码在 SETUP 里// src/session.c typedef enum { SESSION_INIT 0, SESSION_READY, SESSION_PLAYING, SESSION_PAUSED } session_state_t; int handle_setup(rtsp_server_t *srv, rtsp_session_t *s, rtsp_request_t *req) { uint16_t rtp_port 0, rtcp_port 0; // 从 Transport 头拿客户端 RTP/RTCP 端口 if (parse_transport(req-transport, rtp_port, rtcp_port) ! 0) { return rtsp_reply(s, 461, req-cseq); // Unsupported Transport } s-rtp_port rtp_port; s-rtcp_port rtcp_port; s-state SESSION_READY; // 响应里必须带 Session 头后续请求靠它找会话 return rtsp_reply_with_session(s, 200, req-cseq, s-session_id); }parse_transport失败时回 461不要回 500播放器看到 461 会尝试换传输方式。rtsp_reply_with_session里除了状态行和 CSeq还要拼Session: id有些客户端还会要求Transport回显。PLAY 处理里把state切到SESSION_PLAYING同时刷新last_active不然后面超时清理会把正在播的会话误杀。TEARDOWN 里先从链表摘除再关 fd最后释放 RTP 发送缓冲顺序反过来容易野指针。3.3 响应构造与 CSeq 回显响应构造看着简单但 CSeq 漏回显、状态码拼错播放器就直接断连。下面是一个朴素的响应函数// src/rtsp_response.c static const char *rtsp_reason(int code) { switch (code) { case 200: return OK; case 400: return Bad Request; case 404: return Not Found; case 461: return Unsupported Transport; default: return Internal Server Error; } } int rtsp_reply(rtsp_session_t *s, int code, int cseq) { char resp[512]; int n snprintf(resp, sizeof(resp), RTSP/1.0 %d %s\r\n CSeq: %d\r\n Server: rtsp_server\r\n \r\n, code, rtsp_reason(code), cseq); if (n 0 || n (int)sizeof(resp)) return -1; return send(s-client_fd, resp, n, 0); }CSeq必须原样带回不能自增也不能省略。Server头写不写不影响播放但抓包时能一眼认出是自己的服务。snprintf的返回值要判断超过 512 字节说明头部拼太长直接返回错误比截断更安全。SETUP 的响应还要额外拼Session和Transport实现时可以在rtsp_reply外面再包一层别把基础响应函数写得太复杂。4. 媒体发送核心SDP 描述、RTP 打包与时间戳RTSP 控制面跑通之后真正决定能不能出画面的是 SDP 和 RTP。SDP 告诉播放器“我有什么流、编码是什么、参数集在哪”RTP 负责把 NALU 按 MTU 切好、加上序列号和时间戳发出去。这两块只要有一处对不上现象就是 PLAY 返回 200播放器一直黑屏。4.1 SDP 生成与 trackID 约定SDP 不用手写太花H.264 单轨常见字段就这些// src/sdp.c int sdp_build(const media_source_t *src, char *out, size_t out_len) { return snprintf(out, out_len, v0\r\n o- %u %u IN IP4 %s\r\n s%s\r\n t0 0\r\n acontrol:*\r\n mvideo 0 RTP/AVP 96\r\n bAS:2048\r\n artpmap:96 H264/90000\r\n afmtp:96 packetization-mode1;profile-level-id%s; sprop-parameter-sets%s,%s\r\n acontrol:trackID0\r\n, src-ssrc, src-ssrc, src-server_ip, src-name, src-profile_level_id, src-sps_base64, src-pps_base64); }mvideo 0里的端口写 0表示真正端口在 SETUP 阶段协商这是 RTSP 的常见做法。artpmap:96 H264/90000里的 96 是动态 payload type要和 RTP 头里的 PT 保持一致90000 是 H.264 的时钟频率后面算时间戳都用它。profile-level-id和sprop-parameter-sets来自 SPS/PPS播放器靠这两个字段初始化解码器。acontrol:trackID0是轨道的控制标识SETUP 请求的 URL 末尾通常要带trackID0服务端解析 URL 时要把这个值对上。4.2 H.264 NALU 到 RTP 包的封装RTP 头 12 字节H.264 负载分三种情况单个 NALU、FU-A 分片、STAP-A 聚合。源码里一般先实现单包和 FU-A够跑通大多数播放器。先看单包发送// src/rtp_sender.c #define RTP_MTU 1400 static int rtp_send_packet(rtsp_session_t *s, const uint8_t *payload, size_t len, uint32_t ts, int marker) { uint8_t pkt[RTP_MTU 12]; pkt[0] 0x80; // V2 pkt[1] marker ? 0xE0 : 0x60; // M 位 PT96 pkt[2] (s-seq 8) 0xFF; pkt[3] s-seq 0xFF; pkt[4] (ts 24) 0xFF; pkt[5] (ts 16) 0xFF; pkt[6] (ts 8) 0xFF; pkt[7] ts 0xFF; pkt[8] (s-ssrc 24) 0xFF; pkt[9] (s-ssrc 16) 0xFF; pkt[10] (s-ssrc 8) 0xFF; pkt[11] s-ssrc 0xFF; memcpy(pkt 12, payload, len); s-seq; return sendto(s-rtp_fd, pkt, 12 len, 0, (struct sockaddr *)s-rtp_addr, sizeof(s-rtp_addr)); }pkt[0] 0x80表示 RTP 版本 2没有填充、没有扩展、CSRC 数量为 0。pkt[1]低 7 位是 payload type这里用 96最高位是 marker一帧最后一个包置 1。seq每发一个 RTP 包加一16 位回绕。ts是 90kHz 时间戳同一帧的多个分片用同一个时间戳。ssrc每个会话独立别多个客户端共用一个不然后面做多路流会串。超过 MTU 的 NALU 走 FU-Aint rtp_send_h264_nal(rtsp_session_t *s, const uint8_t *nal, size_t len, uint32_t ts) { if (len RTP_MTU) { return rtp_send_packet(s, nal, len, ts, 1); } uint8_t nal_type nal[0] 0x1F; size_t off 0; while (off len) { size_t chunk len - off RTP_MTU - 2 ? RTP_MTU - 2 : len - off; uint8_t fu[2]; fu[0] (nal[0] 0xE0) | 28; // FU-A indicator fu[1] (off 0 ? 0x80 : 0x00) | // S 位 (off chunk len ? 0x40 : 0x00) | // E 位 nal_type; // 实际发送时把 fu 和 nal off 的 chunk 拼在一起 rtp_send_packet(s, fu, 2, ts, 0); off chunk; } return 0; }fu[0]的 type 固定为 28表示 FU-Afu[1]里 S 位标记第一个分片E 位标记最后一个分片低 5 位保留原始 NALU 类型。分片大小用RTP_MTU - 2因为要留两个字节给 FU 头。最后一包要把 marker 置 1但上面这个示例为了突出分片头把 payload 拼接简化了实际代码里应该把fu和nal off一次拷进发送缓冲别发两次。4.3 发送节奏与 RTCP 的取舍发送节奏常见做法是“按帧时间戳发”不是收到一帧就立刻全速灌出去。H.264 文件回放可以按帧率 sleep摄像头采集就跟着采集时间戳走。ts的计算方式是pts * 90000 / 1000如果 pts 单位是毫秒。序列号seq和时间戳ts都放在rtsp_session_t里每个会话独立维护这样多客户端同时播放不会互相污染。RTCP 在最小闭环里可以先不做播放器短时间收不到 RR 也能播。但要做长时间稳定推流血泪经验是至少把 Sender Report 加上SR 里带 NTP 时间和 RTP 时间戳的对应关系接收端做音画同步和抖动计算会稳很多。源码里如果只有一个rtp_sender.c可以先把 SR 发送挂在同一个定时器里每 5 秒发一次别一上来就做完整 RTCP 解析。5. 编译运行与常见问题排查端口、Transport 和会话超时拿到源码后第一步不是改代码而是把原始工程跑起来。编译命令通常就两条配置文件和媒体文件路径要对make clean make ./rtsp_server etc/rtsp_server.conf如果 Makefile 支持命令行参数也可以直接指定端口和媒体文件./rtsp_server -c etc/rtsp_server.conf -p 8554 -f media/sample.h264联调时先用 TCP 模式排除 UDP 端口问题ffplay -rtsp_transport tcp rtsp://127.0.0.1:8554/live配置文件里常改的几项如下配置项默认值说明listen_ip0.0.0.0监听地址联调可改 127.0.0.1listen_port8554RTSP 端口避免和系统服务冲突max_sessions64最大并发会话数session_timeout60无保活回收秒数media_filemedia/sample.h264本地 H.264 文件路径5.1 编译、配置与首轮联调首轮联调按 OPTIONS、DESCRIBE、SETUP、PLAY 的顺序看日志。OPTIONS 返回 200 只说明 TCP 通了DESCRIBE 返回 SDP 才说明 URL 路由和媒体源加载成功。SETUP 要看Transport回显PLAY 之后看 RTP 包有没有发到客户端端口。如果服务端有抓包开关先抓rtsp://的 TCP 流把 CSeq 和 Session 对一遍。注意本地文件回放和实时采集的时间戳基准不一样文件回放可以按帧率推进采集要按采集卡给的 pts 推进别混用。5.2 典型故障排查记录现象OPTIONS 有响应DESCRIBE 返回 404。原因URL 路径匹配用的是完整 URL但 SDP 里的acontrol:trackID0和 SETUP 请求里的 trackID 对不上或者媒体文件路径没加载成功。解决打印req-url和媒体源注册的 path统一用 path 部分比较确认media_file存在且能读到 SPS/PPS。现象PLAY 返回 200播放器一直黑屏。原因Transport 协商的是 UDP但服务端只实现了 TCP interleaved或者client_port解析后sendto的目标地址错了。解决先用ffplay -rtsp_transport tcp验证 TCP 模式再检查parse_transport的返回值UDP 模式下确认rtp_addr的 IP 和端口来自 SETUP 请求。现象多客户端一接就花屏或者串流。原因RTP 序列号、SSRC、发送缓冲写成了全局变量两个会话共用一套状态。解决把seq、ssrc、ts放进rtsp_session_t发送前拷贝当前会话的参数别在rtp_sender.c里用 static 全局缓冲。现象跑几分钟连接自动断开。原因session_timeout到了但 PLAY 期间没有刷新last_active或者事件循环里做了阻塞发送select迟迟回不来。解决PLAY 收到 RTSP 保活或 RTP 发送时刷新last_activeRTP 发送改成非阻塞或者放到独立媒体线程别在主循环里长阻塞。现象编译报 undefined reference。原因Makefile 里漏了-lpthread或者新增的.c没加进SRCS。解决检查SRCS和LDFLAGS头文件路径加-Iinclude先make clean再全量编译避免旧对象文件干扰。6. 二次开发改造从能跑到能用的几个落点第一刀建议加鉴权。RTSP 常见做法是在 DESCRIBE 之前校验Authorization没带或者校验失败就回 401并在响应里带WWW-Authenticate: Basic realmrtsp。实现时别把用户名密码写死在 parser 里放到配置或者回调里后面接数据库也方便。第二刀做 URL 路由和多路流。把media_source抽象成接口文件、采集卡、编码器输出都实现同一套open/read_frame/closeRTSP 层只按 URL 找 source不关心数据从哪来typedef struct media_source { int (*open)(struct media_source *src, const char *url); int (*read_frame)(struct media_source *src, uint8_t **nal, size_t *len, uint32_t *ts); void (*close)(struct media_source *src); void *priv; } media_source_t;这样加一路流就是注册一个 source不用改 SETUP/PLAY 的状态机。第三刀再考虑 RTCP 和发送队列每个会话一个环形缓冲媒体线程往里写发送线程按时间戳出队慢客户端丢旧帧而不是阻塞主循环。最后提醒一句改协议层之前先抓包把 CSeq、Session、Transport 三个头对一遍再动代码。从那以后我每次改 RTSP 层都强制走一遍 OPTIONS 到 TEARDOWN 的完整链路希望帮到你。本文还有配套的精品资源点击获取