# SDP 与 JSEP · 会话怎么被描述和协商

> SDP 是一段描述「这通电话怎么打」的文本，JSEP 是围绕它的提议/应答模型。这一章讲清 SDP 长什么样、SdpType 的四种取值（offer/pranswer/answer/rollback）、api 层的 SessionDescriptionInterface 与内部 SessionDescription 的区别，以及那个由 6 个状态构成的信令状态机。

- 作者：David（道雾轩）
- 专栏：音视频与 WebRTC 详解（https://daiw.org/manual/webrtc.md）
- 最后更新：2026-07-14
- 原文：https://daiw.org/manual/webrtc/sdp-and-jsep
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# SDP 与 JSEP · 会话怎么被描述和协商

会话建立的核心，是两个端点就「这通电话怎么打」达成一致——用什么编解码器、走什么传输、密钥指纹是什么。承载这份「约定」的，是一段叫 **SDP** 的文本；而围绕它进行提议 / 应答的规则，叫 **JSEP**。读懂[下一章的 `SdpOfferAnswerHandler`](https://daiw.org/manual/webrtc/offer-answer-handler) 之前，得先弄明白这两个词。

## SDP：一段描述会话的文本

**SDP（Session Description Protocol，会话描述协议）** 是一段**纯文本**，一行一个 `键=值`，描述一次媒体会话。WebRTC 的 SDP 大致长这样（极简片段）：

```text
v=0
o=- 46... 2 IN IP4 127.0.0.1
m=audio 9 UDP/TLS/RTP/SAVPF 111        ← 一条媒体行:音频,用 payload type 111
a=rtpmap:111 opus/48000/2              ← 111 是 Opus,48kHz 立体声
a=fingerprint:sha-256 AB:CD:...        ← DTLS 证书指纹(防中间人)
a=ice-ufrag:F7g...                     ← ICE 用户名片段
a=ice-pwd:x9k...                       ← ICE 密码
a=setup:actpass                        ← DTLS 角色(谁当客户端)
a=sendrecv                             ← 收发方向
```

每条 **`m=` 行（media line，简称 m-line）** 描述一路媒体（一路音频、一路视频……），下面的 `a=`（attribute）行给出细节：**编解码器**（`rtpmap`）、**DTLS 指纹**（[第 3 部分](https://daiw.org/manual/webrtc/source-map)验证身份用）、**ICE 凭据**（[第 2 部分](https://daiw.org/manual/webrtc/source-map)连通性检查用）、**收发方向**（`sendrecv`/`sendonly`/`recvonly`/`inactive`）。一句话：**SDP 把「我这端能做什么、想怎么做」编码成文本**，发给对端，对端据此回应。

## JSEP：提议 / 应答的模型

WebRTC **不自己规定**怎么交换 SDP，而是遵循 IETF 的 **JSEP（JavaScript Session Establishment Protocol,RFC 8829）**。JSEP 的核心，就是[我们见过的](https://daiw.org/manual/webrtc/call-lifecycle)**提议 / 应答（offer/answer）**：一端生成 offer 描述，另一端回 answer 描述，双方各自「设本地 / 设远端」，协商即成。

JSEP 有意做了一个**关键切分**：

<Callout type="info">
  **JSEP 把「协商媒体」和「传递信令」分开。** WebRTC 负责**生成和消化 SDP**（`createOffer`、`setLocalDescription`、`setRemoteDescription`）；而**把 SDP 从一端送到另一端**，是[应用的责任](https://daiw.org/manual/webrtc/call-lifecycle)（信令服务器）。这个切分让 WebRTC 不绑定任何信令协议——你用 WebSocket、SIP、还是自定义协议传 SDP，它都不关心。JSEP 只定义「描述的语义」和「状态怎么变」，不定义「怎么传」。
</Callout>

## SdpType：描述的四种身份

一段 SDP 描述，拿到手要**当成什么来处理**？`api/jsep.h` 里的 `SdpType` 枚举给出四种真实取值：

```cpp
// api/jsep.h
enum class SdpType {
  kOffer,     // 当作 SDP 提议
  kPrAnswer,  // 当作「临时应答」(provisional,还不是最终)
  kAnswer,    // 当作最终应答
  kRollback   // 回滚:撤销待定的提议,信令状态退回上一步
};
```

- **`kOffer`**：提议——「我想这么打，你看行不行」。
- **`kAnswer`**：最终应答——「行，就这么定」。协商到此敲定。
- **`kPrAnswer`**：临时应答（provisional answer）——「大概行，但还没最终确认」，用于需要提前建立部分状态的场景，后面还会跟一个最终 `kAnswer`。
- **`kRollback`**：回滚——把一个**还没敲定的提议撤掉**，信令状态退回 `kStable`。这是「协商反悔」的机制，在双方几乎同时发 offer（glare，抢话）时尤其有用。

## 两个 SessionDescription：api 层 vs 内部

「一段 SDP 描述」在源码里有**两副面孔**，别混淆：

| | `SessionDescriptionInterface` | `SessionDescription` |
| --- | --- | --- |
| 位置 | `api/jsep.h`（公共 API） | `pc/session_description.h`（内部） |
| 是什么 | API 层的**包装**：类型 + SDP 文本 + 候选 | 解析后的**结构化**媒体描述 |
| 你拿到的 | `createOffer` 回调给你的就是它 | 内部协商操作的对象 |
| 关键内容 | `type()`、`ToString()`、`AddCandidate()` | 各条 m-line 的 `ContentInfo`、编解码器列表等 |

简单说：**`SessionDescriptionInterface` 是「给外面看的信封」**（带着类型标签和可序列化的 SDP 文本），**`SessionDescription` 是「拆开信封后结构化的内容」**（[下一章](https://daiw.org/manual/webrtc/offer-answer-handler)和[媒体协商](https://daiw.org/manual/webrtc/source-map)真正操作的是它）。`createOffer` 给你的是前者，你 `ToString()` 拿到文本发给对端；对端 `setRemoteDescription` 又把文本解析回结构。

## 信令状态机：6 个状态

JSEP 的协商过程，是一台**状态机**。`api/peer_connection_interface.h` 里的 `SignalingState` 定义了它的 6 个真实状态：

```cpp
// api/peer_connection_interface.h:SignalingState
kStable,               // 稳定态:没有进行中的协商
kHaveLocalOffer,       // 已设本地 offer,等对端 answer
kHaveLocalPrAnswer,    // 已设本地临时应答
kHaveRemoteOffer,      // 已设远端 offer,待本端 answer
kHaveRemotePrAnswer,   // 已设远端临时应答
kClosed,               // 连接已关闭
```

典型的一轮 offer/answer，就是在这些状态间迁移。发起方（设本地 offer）和应答方（设远端 offer）走两条对称的路，最后**双双回到 `kStable`**：

```mermaid
flowchart LR
    S1["kStable"] -->|"setLocalDescription(offer)"| LO["kHaveLocalOffer"]
    LO -->|"setRemoteDescription(answer)"| S2["kStable ✅"]
    S1 -->|"setRemoteDescription(offer)"| RO["kHaveRemoteOffer"]
    RO -->|"setLocalDescription(answer)"| S3["kStable ✅"]
    LO -.->|"setLocalDescription(rollback)"| S1
    RO -.->|"回滚"| S1
```

<Callout type="info">
  **一次成功的协商，必须回到 `kStable`。** 只有在 `kStable`，双方才算就「这通电话怎么打」达成完整一致，媒体才能[真正开跑](https://daiw.org/manual/webrtc/call-lifecycle)。中间态（`kHaveLocalOffer` 等）是「协商进行中」，此时若出岔子（[抢话 glare](https://daiw.org/manual/webrtc/sdp-and-jsep) 或配置冲突），可以用 `kRollback` 退回 `kStable` 重来。这台状态机的**驱动者**，正是下一章的主角 `SdpOfferAnswerHandler`——每次 `setLocalDescription` / `setRemoteDescription`，它都会检查「当前状态允不允许这个操作」，再推进状态。
</Callout>

## 小结与下一章

**SDP** 是描述「这通电话怎么打」的文本（m-line 列媒体、a= 行给编解码器 / DTLS 指纹 / ICE 凭据 / 方向）；**JSEP**（RFC 8829）是围绕它的**提议 / 应答**模型，把「协商媒体」（WebRTC 负责）与「传递信令」（[应用负责](https://daiw.org/manual/webrtc/call-lifecycle)）分开。`SdpType` 四种：offer / pranswer / answer / **rollback**（回滚）。描述有两副面孔：api 层 `SessionDescriptionInterface`（信封）与内部 `SessionDescription`（结构化内容）。协商是一台 **6 状态的信令状态机**，一轮成功后回到 `kStable`。

这台状态机的司机，是 `PeerConnection` 那个 `sdp_handler_` 成员——**`SdpOfferAnswerHandler`**。下一章读它：`createOffer` / `setLocalDescription` / `setRemoteDescription` 到底做了什么。👉 [SdpOfferAnswerHandler：协商的驾驶舱](https://daiw.org/manual/webrtc/offer-answer-handler)。

## 参考资料

- [1] libwebrtc 源码.`api/jsep.h`（`SdpType`、`SessionDescriptionInterface`）、`pc/session_description.h`（内部结构）。
- [2] libwebrtc 源码.`api/peer_connection_interface.h`（`SignalingState` 六状态）。
- [3] IETF RFC 8829「JSEP」、RFC 8866「SDP」—— 协议原文。
