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;
这个结构体虽然短,但里面的每一个比特都有它的说法。
我们可以把它拆开来看:
ver(4 bits): 版本号。RFC 2516 白纸黑字写着,必须是1。type(4 bits): 类型号。同样,RFC 2516 规定也必须是1。- 注意这里的位域处理:
ver和type挤在一个字节里。内核这里用了条件编译来处理大小端(__LITTLE_ENDIAN_BITFIELDvs__BIG_ENDIAN_BITFIELD),说明这个字段在内存里的摆放顺序跟你机器的架构有关——这点在写跨平台代码时非常容易踩坑。
- 注意这里的位域处理:
code(1 byte): 也就是上一节我们提到的那些包类型。0x09(PADI),0x07(PADO),0x19(PADR),0x65(PADS),0xa7(PADT)。sid(2 bytes): Session ID。一旦发现阶段结束,分配好了 ID,之后所有的包都得带着这个「通行证」。length(2 bytes): 这里的长度比较讲究——它指的是 PPPoE payload 的长度,不包括 PPPoE 头本身,更不包括前面的以太网头。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() 还做了两件杂事:
- 导出 procfs 接口:在
/proc/net/pppoe里你会看到当前的会话列表(Session ID, MAC 地址, 设备名)。 - 注册通知链:调用
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;
};
这里有几个关键点:
struct sock sk必须在第一位。这是内核网络栈的惯用伎俩,这样struct sock *指针可以直接强转成struct pppox_sock *使用。union { ... } proto:这就是它的通用之处。如果是 PPPoE,就用proto.pppoe;如果是 PPTP,就用proto.pptp。struct ppp_channel chan:这是通往 PPP 核心层的通道。PPPoE 只是负责把数据包运过去,真正的 PPP 协商(LCP, IPCP 等)是靠这个 channel 交给 PPP 核心处理的。
当我们讨论 PPPoE 时,我们关心的是 proto.pppoe。它包含一个 pppoe_opt 结构,里面最关键的就是 pa(pppoe_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 网络栈,看看在智能手机上,这些机制是如何被重新封装和使用的。