跳到主要内容

14.12 PPPoE Header —— 把协议钉在以太网上

上一节我们聊完了 PPPoE 的两个阶段——发现和会话,就像看完了两个人先握手问好,再开始聊天的全过程。

但这只是协议层面的流程图。在内核里,这些「握手」和「聊天」最终都得变成一个个 bit,塞进以太网帧里。

这一节,我们要把视角从「协议流程」切换到「内核实现」。我们来看内核是如何定义那个 6 字节的头,如何处理这些包,以及最关键的——当用户空间的 pppd 守护进程发起调用时,内核是怎么把这一切串起来的。

14.12.1 那个 6 字节的头:pppoe_hdr

先别管那些复杂的处理逻辑,任何协议的第一步都是定义它的「身份证」。在 Linux 内核里,PPPoE 的身份证长这样:

struct pppoe_hdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ver : 4;
__u8 type : 4;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u8 type : 4;
__u8 ver : 4;
#else
#error "Please fix <asm/byteorder.h>"
#endif
__u8 code;
__be16 sid;
__be16 length;
struct pppoe_tag tag[0];
} __packed;

这个结构体虽然短,但里面的每一个比特都有它的说法。

我们可以把它拆开来看:

  1. ver (4 bits): 版本号。RFC 2516 白纸黑字写着,必须是 1
  2. type (4 bits): 类型号。同样,RFC 2516 规定也必须是 1
    • 注意这里的位域处理vertype 挤在一个字节里。内核这里用了条件编译来处理大小端(__LITTLE_ENDIAN_BITFIELD vs __BIG_ENDIAN_BITFIELD),说明这个字段在内存里的摆放顺序跟你机器的架构有关——这点在写跨平台代码时非常容易踩坑。
  3. code (1 byte): 也就是上一节我们提到的那些包类型。0x09 (PADI), 0x07 (PADO), 0x19 (PADR), 0x65 (PADS), 0xa7 (PADT)。
  4. sid (2 bytes): Session ID。一旦发现阶段结束,分配好了 ID,之后所有的包都得带着这个「通行证」。
  5. length (2 bytes): 这里的长度比较讲究——它指的是 PPPoE payload 的长度,不包括 PPPoE 头本身,更不包括前面的以太网头。
  6. tag[0]: 这是一个零长度数组(Flexible Array Member),后面跟着的是 TLV (Type-Length-Value) 格式的标签。这就是发现阶段用来交换信息的地方,比如「我是 AC-Name」、「我要的服务是 Service-Name」。

为了让你脑子里有个图,这个结构在内存里(抓包视野里)大概长这样:

  • 前 4 字节Ver/Type + Code + Session ID
  • 后 2 字节Length
  • 再往后:Payload 或 Tags。

14.12.2 内核启动时的准备:pppoe_init()

协议定义好了,内核得知道「来了一个以太网帧,如果 Type 是 0x8863 或 0x8864,该交给谁」。

这是 pppoe_init() 的任务。它位于 drivers/net/ppp/pppoe.c

核心动作是注册两个协议处理器:

static struct packet_type pppoes_ptype __read_mostly = {
.type = cpu_to_be16(ETH_P_PPP_SES), // 0x8864
.func = pppoe_rcv, // 会话包处理函数
};

static struct packet_type pppoed_ptype __read_mostly = {
.type = cpu_to_be16(ETH_P_PPP_DISC), // 0x8863
.func = pppoe_disc_rcv, // 发现包处理函数
};

static int __init pppoe_init(void)
{
int err;

// 向内核注册协议处理器
dev_add_pack(&pppoes_ptype);
dev_add_pack(&pppoed_ptype);

// ... 其他初始化代码 ...

return 0;
}

这里有个细节值得回味:为什么分两个 handler?

因为在发现阶段,内核根本不知道你是谁,你也没有 Session ID。那时候你是一个「无头苍蝇」,到处广播 PADI。而到了会话阶段,你已经有了 Session ID,链路已经建立了,处理逻辑完全不同。

dev_add_pack() 是我们在前面章节见过的老朋友了,它把这两个钩子挂到了全局的协议处理链上。以后网卡收到包,一看类型是 ETH_P_PPP_DISC,直接甩给 pppoe_disc_rcv

除此之外,pppoe_init() 还做了两件杂事:

  1. 导出 procfs 接口:在 /proc/net/pppoe 里你会看到当前的会话列表(Session ID, MAC 地址, 设备名)。
  2. 注册通知链:调用 register_netdevice_notifier(&pppoe_notifier)。这样如果网卡突然被拔了或者 Down 了,PPPoE 模块能第一时间收到通知,把会话断了,别傻等着。

14.12.3 PPPoX Sockets:通用的封装层

这是一个容易让人晕的设计。

PPPoE 是运行在以太网上的。但为了和 PPP 协议栈无缝对接,Linux 内核搞了一个叫 PPPoX 的东西(PPP over Anything)。它是一个通用的 Socket 家族(AF_PPPOX),不仅支持 PPPoE,还支持 PPTP 等其他协议。

这个通用的结构体长这样:

struct pppox_sock {
/* struct sock must be the first member of pppox_sock */
struct sock sk;
struct ppp_channel chan;
struct pppox_sock *next; /* 用于哈希表 */

union {
struct pppoe_opt pppoe;
struct pptp_opt pptp;
} proto;
__be16 num;
};

这里有几个关键点:

  1. struct sock sk 必须在第一位。这是内核网络栈的惯用伎俩,这样 struct sock * 指针可以直接强转成 struct pppox_sock * 使用。
  2. union { ... } proto:这就是它的通用之处。如果是 PPPoE,就用 proto.pppoe;如果是 PPTP,就用 proto.pptp
  3. struct ppp_channel chan:这是通往 PPP 核心层的通道。PPPoE 只是负责把数据包运过去,真正的 PPP 协商(LCP, IPCP 等)是靠这个 channel 交给 PPP 核心处理的。

当我们讨论 PPPoE 时,我们关心的是 proto.pppoe。它包含一个 pppoe_opt 结构,里面最关键的就是 papppoe_addr),也就是我们这次会话的「通话记录」:

struct pppoe_addr {
sid_t sid; /* Session identifier */
unsigned char remote[ETH_ALEN];/* Remote address (对方 MAC) */
char dev[IFNAMSIZ]; /* Local device to use (比如 eth0) */
};

这三个字段唯一确定了一个 PPPoE 会话:你在哪张网卡上,跟谁(MAC)说话,用的是哪个 ID。


14.12.4 用户空间的连接:从 socket()connect()

前面全是内核的数据结构铺垫,真正的戏肉在于用户空间怎么用这些东西

通常我们不会自己写代码调这些 Socket,而是用 pppd(PPP daemon)配合 rp-pppoe 插件。当你运行拨号脚本时,流程是这样的:

第一步:建立 Socket (socket())

pppd 会调用 socket(AF_PPPOX, SOCK_STREAM, PX_PROTO_OE)

这会触发内核里的 pppoe_create() 方法。它的任务主要是分配内存,并把一系列回调函数挂上去:

static int pppoe_create(struct net *net, struct socket *sock)
{
struct sock *sk;

sk = sk_alloc(net, PF_PPPOX, GFP_KERNEL, &pppoe_sk_proto);
if (!sk)
return -ENOMEM;

sock_init_data(sock, sk);

sock->state = SS_UNCONNECTED;
sock->ops = &pppoe_ops; // <--- 关键:挂上操作集

sk->sk_backlog_rcv = pppoe_rcv_core;
sk->sk_state = PPPOX_NONE;
// ... 设置 family, protocol 等 ...

return 0;
}

注意这行 sock->ops = &pppoe_ops;pppoe_ops 是一个巨大的函数指针结构体,定义了这个 Socket 的行为:

static const struct proto_ops pppoe_ops = {
.family = AF_PPPOX,
.owner = THIS_MODULE,
.release = pppoe_release,
.bind = sock_no_bind, // PPPoE 不需要 bind
.connect = pppoe_connect, // <--- 连接时调用这个
.sendmsg = pppoe_sendmsg,
.recvmsg = pppoe_recvmsg,
.ioctl = pppox_ioctl,
// ...
};

第二步:发起连接 (connect())

Socket 建好了,接下来用户空间会调用 connect(),把我们刚才通过 Discovery 阶段拿到的信息(Session ID, 对方 MAC, 接口名)传给内核。

这会触发内核里的 pppoe_connect()。这里有一大段逻辑,我们分段拆解。

1. 防止重复连接

struct sock *sk = sock->sk;
struct sockaddr_pppox *sp = (struct sockaddr_pppox *)uservaddr;
struct pppox_sock *po = pppox_sk(sk);
// ...

// 如果已经是 Connected 状态,且 Session ID 不为 0(说明是会话阶段),
// 那就别折腾了,直接返回 EBUSY。
if ((sk->sk_state & PPPOX_CONNECTED) &&
stage_session(sp->sa_addr.pppoe.sid))
goto end;

2. 绑定网卡

既然是 PPPoE,必须在某个具体的物理设备(或虚拟设备)上跑。

if (stage_session(sp->sa_addr.pppoe.sid)) {
// 根据名字找到 net_device
dev = dev_get_by_name(net, sp->sa_addr.pppoe.dev);
if (!dev)
goto err_put;

po->pppoe_dev = dev;
po->pppoe_ifindex = dev->ifindex;
// ...

3. 状态检查

这里有个很现实的检查:

// 网卡必须是 UP 状态,否则免谈
if (!(dev->flags & IFF_UP)) {
goto err_put;
}

如果网卡没启动,你也拨不上去。

4. 插入哈希表

这是网络编程里为了快速查找包属于哪个会话的经典操作。

write_lock_bh(&pn->hash_lock);

// 以 (sid, remote_mac, ifindex) 为 key 插入哈希表
// 如果重复了,返回 -EALREADY
error = __set_item(pn, po);
write_unlock_bh(&pn->hash_lock);

内核维护了一张全局表,以后收到 sid=123 的包,一查表就知道应该交给哪个 Socket 处理。

5. 注册 PPP 通道

这是最后一步,也是把「以太网驱动」和「PPP 协议栈」打通的一步。

// 设置通道的头部预留空间(给 PPPoE header 和以太网头)
po->chan.hdrlen = (sizeof(struct pppoe_hdr) + dev->hard_header_len);

// 设置 MTU:物理 MTU 减去 PPPoE 头长度
po->chan.mtu = dev->mtu - sizeof(struct pppoe_hdr);

po->chan.private = sk;
po->chan.ops = &pppoe_chan_ops;

// 向 PPP 核心层注册这个通道
error = ppp_register_net_channel(dev_net(dev), &po->chan);

一旦 ppp_register_net_channel 成功,这个 Socket 就正式连入了 PPP 体系。以后上层发下来的 IP 包,会被 PPP 封装,然后通过 pppoe_chan_ops 回调,最终变成以太网帧发出去。


14.12.5 实战验证:跑起来看看

理论讲完了,最后看看怎么在现实中操作。

通常我们会用 rp-pppoe 这个开源项目(或者直接用 pppd 的插件)。

服务端示例

假设你想在一台 Linux 机器上模拟一个运营商的设备(AC),你可以这样启动 PPPoE 服务端:

pppoe-server -I p3p1 -R 192.168.3.101 -L 192.168.3.210 -N 200
  • -I p3p1:在 p3p1 这张网卡上监听。
  • -L 192.168.3.210:给客户端分配的本地 IP(服务器侧)。
  • -R 192.168.3.101:给客户端分配的远端 IP 起始值。
  • -N 200:最多允许 200 个并发会话。

客户端(用户侧)

客户端通常通过 pppd 配置文件来调用。配置文件里大概长这样:

plugin rp-pppoe.so
nic-eth0
user "myname@isp"
password "mypassword"

pppd 启动时,它会调用 rp-pppoe.so 插件里的 PPPOEConnectDevice() 函数。这个函数在用户空间完成了 Discovery 阶段(发 PADI,收 PADO...),拿到 Session ID 后,就调用我们刚才分析的 socket()connect() 系统调用,把连接的接力棒交给了内核。

从此,内核的 PPPoE 模块就接管了数据通道。


14.12.6 收尾

回想一下,这一节我们走完了从结构体定义Socket 建立的全过程。

如果说上一节我们是在看「剧本」(协议流程),这一节我们就是在看「舞台搭建」(内核实现)。

  • struct pppoe_hdr 定义了对话的语言。
  • pppoe_init 在后台挂好了收发的钩子。
  • pppoe_connect 把用户空间的请求变成了内核里的哈希表条目和 Channel 注册。

这就是 PPPoE 在 Linux 内核中的全景图。虽然现在光纤入户普及了,PPPoE 用得比以前少,但它这种「把一个点对点协议硬塞进以太网广播网络」的设计,依然是一个非常精妙的工程案例。

下一章,我们会把目光投向另一个移动通信的巨兽——Android 网络栈,看看在智能手机上,这些机制是如何被重新封装和使用的。