首页
关于
Search
1
PacketForge 博客部署文档
16 阅读
2
深入 SONiC 与 SAI:从命令行到芯片的完整数据之旅
14 阅读
3
DPU 与 CPO:AI 时代的网络基础设施革命
11 阅读
4
Broadcom芯片
10 阅读
5
DPDK高性能网络
10 阅读
默认分类
交换芯片
高性能网络
AI算力网络
WebSocket高性能项目
DPDK 成长路线
①性能工程基础
②DPDK 源码深读
③用户态协议栈
④软件交换与 SONiC
⑤PCIe、虚拟化与 DPU
⑥面试与复盘
登录
Search
标签搜索
DPDK→DPU 学习路线
P0
P1
P2
P3
P5
PCIe
P4
P6
libmtcp
NUMA
mTCP
OVS
SONiC
SAI
DPU
perf
gdb
EAL
mempool
PacketForge
累计撰写
53
篇文章
累计收到
3
条评论
首页
栏目
页面
关于
DPDK 成长路线
共
39
篇
最新文章
热门文章
评论最多
点赞最多
2026-08-18
P2-03 · 🐧 对照:mTCP 与内核 TCP 状态机逐状态对比
对应路线图:Phase 2 (Day 50-91) | DPDK 24.11.6 LTS | 内核对照 v6.6.143代码出处:source/mtcp/mtcp/src/tcp_in.c vs source/linux-stable/net/ipv4/tcp_input.c,引用均标 文件:行号上一篇:P2-02 mTCP 源码架构 | 下一篇:P2-04 libmtcp-v3 设计一句话定位同一份 RFC 793 状态机,mTCP 用 65 行 switch 写完,内核用一个 switch + 一条快路径 + 半连接队列 + TW 子机写完。逐状态对照一遍,用户态栈"简化了什么、为什么敢简化"就全清楚了。两个分发器先摆一起mTCP 的 switch(tcp_in.c:1298-1363,节选): switch (cur_stream->state) { case TCP_ST_LISTEN: Handle_TCP_ST_LISTEN(mtcp, cur_ts, cur_stream, tcph); break; case TCP_ST_SYN_SENT: Handle_TCP_ST_SYN_SENT(mtcp, cur_ts, cur_stream, iph, tcph, seq, ack_seq, payloadlen, window); break; case TCP_ST_SYN_RCVD: /* SYN retransmit implies our SYN/ACK was lost. Resend */ if (tcph->syn && seq == cur_stream->rcvvar->irs) Handle_TCP_ST_LISTEN(mtcp, cur_ts, cur_stream, tcph); else { Handle_TCP_ST_SYN_RCVD(mtcp, cur_ts, cur_stream, tcph, ack_seq); if (payloadlen > 0 && cur_stream->state == TCP_ST_ESTABLISHED) { Handle_TCP_ST_ESTABLISHED(mtcp, cur_ts, cur_stream, tcph, seq, ack_seq, payload, payloadlen, window); } } break; // ... ESTABLISHED / CLOSE_WAIT / LAST_ACK / FIN_WAIT_1 / FIN_WAIT_2 // ... CLOSING / TIME_WAIT / CLOSED,共 11 个分支 }内核的 switch(tcp_input.c:6637-6684 + 6725 起,节选): switch (sk->sk_state) { case TCP_CLOSE: SKB_DR_SET(reason, TCP_CLOSE); goto discard; case TCP_LISTEN: if (th->ack) return 1; if (th->rst) { /* ... */ goto discard; } if (th->syn) { // ... acceptable = icsk->icsk_af_ops->conn_request(sk, skb) >= 0; // ... consume_skb(skb); return 0; } // ... case TCP_SYN_SENT: tp->rx_opt.saw_tstamp = 0; tcp_mstamp_refresh(tp); queued = tcp_rcv_synsent_state_process(sk, skb, th); if (queued >= 0) return queued; // ... }最关键的架构差:内核 ESTABLISHED 之后根本不进这个 switch——普通数据包走 tcp_rcv_established 快路径(tcp_input.c:6009),只有 SYN/FIN/异常才落到 tcp_rcv_state_process(tcp_input.c:6627)慢路径。mTCP 每包都过同一个 switch,靠 static inline 的 handler 把分支成本压到可接受。逐状态对比(只看有料的三组)LISTEN:mTCP 直接转态 vs 内核建 request_sockmTCP 的 Handle_TCP_ST_LISTEN(tcp_in.c:750-764)收 SYN 直接 state = TCP_ST_SYN_RCVD 并 AddtoControlList 回 SYN/ACK——没有半连接队列,没有 SYN cookie。内核在 LISTEN 收 SYN 调 icsk_af_ops->conn_request(tcp_input.c:6660)建 request_sock 挂半连接队列,由 tcp_check_req(tcp_minisocks.c:602)在三路握手最后校验。半连接队列 + cookie 是防 SYN flood 的,mTCP 的假设是"信任内网/测试环境流量"——这就是简化权。SYN_SENT:主动打开路径mTCP(tcp_in.c:767-812)三步走:① ACK 不可接受(TCP_SEQ_LEQ(ack_seq, iss))直接回 RST(:774-782);② RST 则进 CLOSE_WAIT 报错(:787-798);③ SYN+ACK 则 HandleActiveOpen 后置 ESTABLISHED、清 RTO(RemoveFromRTOList,:810)、RaiseWriteEvent 通知应用可写(:815)。内核把同时打开等边角情况交给 tcp_rcv_synsent_state_process(tcp_input.c:6675)单独处理,主 switch 只做收尾(tcp_input.c:6672-6683)。ESTABLISHED:mTCP 一个函数 vs 内核快慢双路径mTCP 的 Handle_TCP_ST_ESTABLISHED(tcp_in.c:906-953)顺序:SYN 异常回 ACK → payload 进 ProcessTCPPayload 并攒 ACK(ACK_OPT_AGGREGATE 延迟 / ACK_OPT_NOW 立即,:921-928)→ ACK 进 ProcessACK(:932)→ FIN 序号合法则转 CLOSE_WAIT 并 RaiseReadEvent(:937-947)。内核快路径 tcp_rcv_established(tcp_input.c:6009)只处理"序号连续、无标志异常"的段:校验 header pred、更新 ACK、直接进接收队列,全程不锁用户态锁、不做状态判断;一旦 tcp_validate_incoming(tcp_input.c:5862)判异常就跌回慢路径。TIME_WAIT:链表批查 vs TW 子机mTCP 收 FIN 转 TIME_WAIT 时挂进 timewait_list(tcp_in.c:1139),由主循环 CheckTimewaitExpire 每轮批查过期销毁(timer.c:444-487,ts_tw_expire 到期 → CLOSED + DestroyTCPStream)。TIME_WAIT 里再收到 FIN 重传就 re-arm 定时并补 ACK(tcp_in.c:1352-1360)。内核的 TW 是独立生命周期:tcp_time_wait(tcp_minisocks.c:280)把 sock 转成 inet_timewait_sock,由 tw_timer 定时回收,迟到的包走 tcp_timewait_state_process 专门处理。mTCP 一个链表搞定,内核一个子状态机搞定——复杂度差一个数量级。🐧 差异总表状态mTCP(tcp_in.c)Linux v6.6(tcp_input.c)差异本质LISTEN:750-764,SYN 直接转 SYN_RCVD:6642-6667,conn_request 建 request_sock无半连接队列/cookieSYN_SENT:767-812,ACK/RST/SYN+ACK 三分支:6672-6683,交 synsent_state_process内核单列同时打开SYN_RCVD:1308-1320,ACK 转 ESTABLISHED 且同包数据顺带处理:6726-6768,tcp_init_transfer + tcp_set_statemTCP 顺带处理是亮点ESTABLISHED:906-953,单函数全处理:6009 快路径 + 慢路径兜底快慢路径分离是内核最大工程FIN_WAIT_1:1033 起:6770-6779,先验 snd_una != write_seq内核多一层 FIN 序号防护TIME_WAITtimer.c:444 链表批查tcp_minisocks.c:280 TW 子机链表 vs 子状态机面试 3 问内核为什么需要 tcp_rcv_established 快路径? → ESTABLISHED 占 99% 包量;快路径省掉状态 switch、锁与校验分支,只做 header pred + ACK 处理(tcp_input.c:6009);异常段经 tcp_validate_incoming 跌回慢路径(tcp_input.c:5862)。mTCP SYN_RCVD 转 ESTABLISHED 时"顺带处理 payload"体现在哪? → tcp_in.c:1314-1318:ACK 完成后若 state == TCP_ST_ESTABLISHED 且 payloadlen>0,直接再调一次 ESTABLISHED handler——同一轮循环把三次握手和数据都吃掉,省一轮事件。mTCP 为什么敢不要半连接队列? → 定位是单机多核的 Web/LB 场景,SYN flood 交给前面的硬件/网关防;省掉 request_sock 结构、队列和定时器(对照 tcp_input.c:6660 与 tcp_minisocks.c:602)。🔧 动手实验用 tcpdump 抓一次完整的三次握手 + 四次挥手,对照本文行号逐包标出"哪个包触发了哪一行"。再做一个改动实验:把 mTCP tcp_timewait 配成 0(sample_mtcp.conf:71 默认就是 0)观察短连接压测下 TIME_WAIT 立即回收的吞吐差。数据落点 perf-data/P2/03-fsm.md。下一篇:P2-04 · libmtcp-v3 设计:RFC、模块图、内存模型
2026年08月18日
1 阅读
0 评论
0 点赞
2026-08-18
P2-04 · libmtcp-v3 设计:RFC、模块图、内存模型
对应路线图:Phase 2 (Day 50-91) | DPDK 24.11.6 LTS | 内核对照 v6.6代码出处:code/libmtcp-v3/(当前为骨架,本文如实标注"计划中"的部分),对照 source/mtcp/上一篇:P2-03 TCP 状态机对照 | 下一篇:P2-05 实现(上)RX/TX一句话定位libmtcp-v3 的目标:把 mTCP 的精华(每核私有、批量 I/O、批量事件)用 DPDK 24.11.6 的现代接口重写一遍,代码量控制在 mTCP 的十分之一——它是 uNOS 数据面 TCP 栈的预研,不是第二个 mTCP。先说实话:这个项目现在只有两个真实文件。Makefile 和 13 行的头文件。这正是"先定接口,再填实现"的 TDD 式开局。现状:两个真实文件的全部内容include/libmtcp_v3.h(全文,共 13 行):#ifndef LIBMTCP_V3_H #define LIBMTCP_V3_H #include <stdint.h> int mtcp_socket(int domain, int type, int protocol); int mtcp_listen(int sockfd, int backlog); int mtcp_accept(int sockfd, void *addr, void *addrlen); int mtcp_connect(int sockfd, const void *addr, void *addrlen); int mtcp_send(int sockfd, const void *buf, int len, int flags); int mtcp_recv(int sockfd, void *buf, int len, int flags); int mtcp_close(int sockfd); #endifMakefile 声明了实现文件的清单(SRCS-y,第 6 行):RTE_SDK ?= $(HOME)/myshare/dpdk-roadmap-v3/source/dpdk-stable-24.11.6 include $(RTE_SDK)/mk/rte.vars.mk APP = libmtcp_v3 SRCS-y = src/mtcp_socket.c src/mtcp_rx.c src/mtcp_tx.c src/mtcp_fsm.c src/mtcp_conn.c CFLAGS += -O2 -Wall -Wextra -std=c11 CFLAGS += -I$(PWD)/includeAPI 与 mTCP 的差异是刻意的:mTCP 每个函数都要传 mctx_t(mtcp_api.h:91 mtcp_socket(mctx_t, ...))因为它支持多核多上下文;libmtcp-v3 定位单线程单上下文(uNOS 的一个 RSS worker),所以 API 就是裸 POSIX 风格——少一个参数,应用代码移植成本更低。模块设计模块(Makefile SRCS-y)职责mTCP 参照mtcp_socket.csocket/listen/accept/connect/close 封装,socket id 从 mempool 分配api.cmtcp_conn.c4-tuple 连接表(rte_hash),RX 每包查一次tcp_stream.c:62 HashFlow + fhash.cmtcp_fsm.cRFC793 简化状态机(11 态)tcp_in.c:1298mtcp_rx.crx_burst 收批 → 解析 → 查表 → fsm → 攒批 ACKdpdk_module.c:464 + tcp_in.cmtcp_tx.ccwnd 限速 + 发送缓冲 + tx_burst 攒批tcp_out.c:753 WriteTCPDataList内存模型(与 mTCP 同哲学,用 DPDK 原生设施落地):conn 池:rte_mempool_create("conn_pool", max_concurrency, sizeof(struct conn), ...) ——对照 mTCP 的 MPCreate("flow_pool_%d", sizeof(tcp_stream), ...)(core.c:947)。每核一个池,运行时零分配。4-tuple 连接表:rte_hash_create,key = 12 字节(saddr/sport/daddr/dport,字段序对齐 mTCP EqualFlow 的 tcp_stream.c:104-107),value = conn 池索引。用 rte_hash 而不是 mTCP 的拉链表——见 P2-05。接收缓冲:定长 ring(每 conn 一个 rte_ring 太重,计划用固定 8KB 环形数组,对照 mTCP rcvbuf = 8192,sample_mtcp.conf:57)。事件队列:rte_ring 存待通知的 socket id(mTCP 用 TAILQ + 锁,我们用免锁 ring,P1-03 学的正好派上用场)。要实现的 RFC(按优先级裁剪)只列设计目标,编号是公开知识:RFC 793(状态机主体)→ RFC 1122(实现建议:延迟 ACK、Nagle 可关)→ RFC 2018(SACK 解析,先只解析不主动用)→ RFC 6298(RTO 计算,取 mTCP 简化版)→ RFC 5681(慢启动/拥塞避免/快重传,对照 mTCP tcp_in.c:447-451)。第一版明确不做:窗口缩放可配、TCP 选项里只认 MSS 和时间戳、不实现 cookie/半连接队列(理由见 P2-03)。🐧 内核对照决策点Linux v6.6mTCPlibmtcp-v3(计划)连接查找全局 ehash 带锁(tcp_hashinfo,tcp_ipv4.c:93)每核拉链 hash(fhash.c:109)每核 rte_hash(布谷鸟 + sig)状态存储struct tcp_sock 内嵌 sock,随 skb 走锁tcp_stream 从 mempool 取(core.c:947)struct conn 从 rte_mempool 取定时器每 sock 独立 timer(tcp_timer.c:666 起)时间轮批查(timer.c:384)时间轮批查(抄 mTCP)发送每包进 qdisc攒批 tx_burst(dpdk_module.c:375)攒批 tx_burst内核那一栏的作用不是"抄",而是"不抄":每个决策点先问"内核为什么这样"(P2-03 已答过半),再问"单核单租户下能否简化"。面试 3 问libmtcp-v3 的 API 为什么不带 mctx 参数? → mTCP 支持多核多上下文才需要 mctx 定位每核状态(mtcp_api.h:91);v3 定位单线程(一个 RSS worker 一个实例),上下文全局唯一,省参数的同时杜绝"传错上下文"这类 bug。conn 池为什么必须用 mempool 而不是 malloc? → 热路径建连/断连频繁,mempool 预分配 + 每核 cache(P1-02 的机制),malloc 有锁且碎片化;mTCP 同款理由(core.c:947)。Makefile 里 SRCS-y 为什么在 .c 还不存在时就写好? → 接口先行:先定 7 个 API 和 5 个模块边界,编译失败本身驱动实现顺序(先 socket.c 让链接过,再 conn.c……)。🔧 动手实验make -C code/libmtcp-v3 一次,把报错记录下来(预期:No rule to make target 'src/mtcp_socket.c')。然后按依赖序实现:mtcp_socket.c(只用 mempool 分配 id)→ 编译过 → mtcp_conn.c(rte_hash 建表)→ 编译过……每一步都保持可构建。里程碑与报错日志落点 perf-data/P2/04-design-notes.md。下一篇:P2-05 · libmtcp-v3 实现(上):RX/TX 路径与 4-tuple 查表
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P2-05 · libmtcp-v3 实现(上):RX/TX 路径与 4-tuple 查表
对应路线图:Phase 2 (Day 50-91) | DPDK 24.11.6 LTS | 内核对照 v6.6代码出处:source/dpdk-stable-24.11.6/lib/hash/(真实接口)+ code/libmtcp-v3/(骨架)+ source/mtcp/(对照实现)上一篇:P2-04 libmtcp-v3 设计 | 下一篇:P2-06 实现(下)状态机与 RTO一句话定位RX/TX 路径上真正的技术含量在连接表:每包一次 4-tuple 查表,查慢了全栈都慢。这篇先把 rte_hash 的真实接口读透——它是 DPDK 里为"数据面查表"造的最称手工具。为什么弃 mTCP 的拉链表,选 rte_hashmTCP 的流表是朴素拉链法:StreamHTSearch 算 hash → TAILQ_FOREACH 遍历桶(fhash.c:109-126),hash 函数是手写的 12 字节混合(tcp_stream.c:82-94)。它能跑,但有三个问题:① 桶冲突时线性遍历,尾延迟不可控;② key 比较是全 12 字节 memcmp 语义,没短路;③ 每桶一个 cache line 但冲突链会跨行。rte_hash 的解法(lib/hash/rte_cuckoo_hash.h,真实代码):/** * Number of items per bucket. * 8 is a tradeoff between performance and memory consumption. * When it is equal to 8, multiple 'struct rte_hash_bucket' can be fit * on a single cache line (64 or 128 bytes long) without any gaps * in memory between them due to alignment. */ #define RTE_HASH_BUCKET_ENTRIES 8 // rte_cuckoo_hash.h:102 /** Bucket structure */ struct __rte_cache_aligned rte_hash_bucket { uint16_t sig_current[RTE_HASH_BUCKET_ENTRIES]; // :139 先比签名 RTE_ATOMIC(uint32_t) key_idx[RTE_HASH_BUCKET_ENTRIES]; // :141 再取 key uint8_t flag[RTE_HASH_BUCKET_ENTRIES]; void *next; // :145 扩展桶 };两级查找:先比 16 位签名(sig)过滤绝大多数不命中的槽,签名命中才拿 key_idx 去 key_store 取 12 字节 key 做全比较。签名表紧凑到 8 槽 x 2 字节,一次加载就覆盖整桶。真实创建参数(rte_hash.h:81-90)struct rte_hash_parameters { const char *name; /**< Name of the hash. */ uint32_t entries; /**< Total hash table entries. */ uint32_t reserved; /**< Unused field. Should be set to 0 */ uint32_t key_len; /**< Length of hash key. */ rte_hash_function hash_func; /**< Primary Hash function used to calculate hash. */ uint32_t hash_func_init_val; /**< Init value used by hash_func. */ int socket_id; /**< NUMA Socket ID for memory. */ uint8_t extra_flag; /**< Indicate if additional parameters are present. */ };libmtcp-v3 计划中的 mtcp_conn.c 建表(设计稿,实现见仓库后续提交):struct rte_hash_parameters conn_tbl_params = { .name = "conn_tbl", .entries = 1 << 17, /* 128K 连接,对齐 mTCP NUM_BINS_FLOWS=131072 */ .key_len = sizeof(struct flow_key), /* 12 字节 4-tuple */ .socket_id = rte_socket_id(), .extra_flag = 0, /* 单线程读写,无需 RW_CONCURRENCY 开销 */ }; conn_tbl = rte_hash_create(&conn_tbl_params); /* rte_hash.h:146 */extra_flag 是性能关键:RTE_HASH_EXTRA_FLAGS_RW_CONCURRENCY_LF(rte_hash.h:56)之类并发旗标都有成本,单核单线程场景全关掉——这是 mTCP"零共享锁"哲学在查表环节的等价物。查表热路径(rte_hash.h:454 / :500 / :559)int rte_hash_lookup_data(const struct rte_hash *h, const void *key, void **data); // rte_hash.h:454 int rte_hash_lookup(const struct rte_hash *h, const void *key); // rte_hash.h:500 /* 批量版:一次查 64 个 key,摊平函数调用与 cache 未命中延迟 */ int rte_hash_lookup_bulk_data(const struct rte_hash *h, const void **keys, uint32_t num_keys, int32_t *positions, void **data); // rte_hash.h:559mTCP 是一次 burst 里逐包调 StreamHTSearch(core.c:792-795 循环)。libmtcp-v3 计划用 lookup_bulk_data:把一批包的 key 指针数组收集起来一次查——把 N 次函数调用和 N 次串行 cache miss 变成一次批量流水。这是 mTCP 都没想到的 DPDK 24.11 红利。key 布局沿用 mTCP 的字段序(tcp_stream.c:104-107 的 EqualFlow 比的就是这 4 个字段):struct flow_key { /* 计划中,12 字节,无 padding */ uint32_t saddr; /* 本端视角,同 tcp_in.c:1247-1250 */ uint16_t sport; uint32_t daddr; uint16_t dport; };RX/TX 路径总览RX(mtcp_rx.c 计划中,对照 mTCP dpdk_module.c:464 的 dpdk_recv_pkts):rte_eth_rx_burst 收一批 → 逐包偏移到 TCP 头 → 组装 flow_key → rte_hash_lookup_data 取 conn 指针 → 未命中(SYN)走建流慢路径 → 命中则校验 seq 后进状态机(对照 tcp_in.c:1261-1298)→ ACK 攒批。TX(mtcp_tx.c 计划中,对照 mTCP dpdk_send_pkts dpdk_module.c:318):mtcp_send 拷进 conn 的发送缓冲 → 按 min(cwnd, peer_wnd) 限速(对照 tcp_out.c:387 的 window = MIN(sndvar->cwnd, sndvar->peer_wnd))→ mbuf 攒进 wmbufs(对照 dpdk_module.c:333)→ 攒够 rte_eth_tx_burst(对照 :375)。🐧 内核对照:同一件事,内核怎么做内核的连接表 tcp_hashinfo(tcp_ipv4.c:93)也是 hash,但桶上挂自旋锁,查找要 __inet_lookup_established(tcp_ipv4.c:1803)读锁保护,key 还有通配语义(TIME_WAIT 期间允许部分 4-tuple 重入)。rte_hash 免锁的前提是"每核私有表 + 单读者单写者"——把并发从机制上消灭,而不是用锁管理并发。 内核 ehashmTCP fhashlibmtcp-v3(计划)结构桶 + 自旋锁链TAILQ 拉链布谷鸟 8 槽桶 + sigkey 比较通配匹配(TW 复用)全 4 字段相等(fhash.c:104)全 4 字段相等 + sig 短路并发锁无(每核私有)无(每核私有)批量 API无无lookup_bulk(rte_hash.h:559)面试 3 问rte_hash 为什么先比 signature 再比 key? → sig 是 16 位,8 槽共 16 字节一次加载即全比对(rte_cuckoo_hash.h:139);不命中连 key_store 都不访问,省掉一次随机访存。RTE_HASH_BUCKET_ENTRIES 为什么是 8? → 8 槽让 rte_hash_bucket 正好对齐一个 cache line,无空隙(rte_cuckoo_hash.h:95-101 注释原文);4 槽省内存但多冲突,16 槽跨行。什么时候该关掉 rte_hash 的并发旗标? → 单核单读者单写者时。extra_flag 里的 RW_CONCURRENCY 等旗标要维护读锁/序号(rte_hash.h:34-56),全关掉就是免锁最快路径——libmtcp-v3 的每核私有表正好满足。🔧 动手实验写一个 12 字节 4-tuple 的 rte_hash 微基准:100 万 key 全命中查找,对比 ① 逐包 rte_hash_lookup_data ② 批量 rte_hash_lookup_bulk_data(64/批)③ mTCP 的 fhash 同规模查表(把 fhash.c/tcp_stream.c 的 hash 函数抽出来单测)。记录 cycles/key 与 cache-miss/key(perf stat -e cache-misses),数据落点 perf-data/P2/05-hash-bench.md。下一篇:P2-06 · libmtcp-v3 实现(下):状态机、RTO 与 cwnd 简化版
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P2-06 · libmtcp-v3 实现(下):状态机、RTO 与 cwnd 简化版
对应路线图:Phase 2 (Day 50-91) | DPDK 24.11.6 LTS | 内核对照 v6.6.143代码出处:source/mtcp/mtcp/src/timer.c、tcp_in.c(真实实现)+ source/linux-stable/net/ipv4/tcp_timer.c、tcp_cong.c(对照)+ code/libmtcp-v3/(计划中)上一篇:P2-05 实现(上)RX/TX | 下一篇:P2-07 性能调优实录一句话定位状态机决定"包往哪走",RTO 决定"丢了怎么办",cwnd 决定"发多快"。libmtcp-v3 的取舍是:状态机抄 mTCP 的 switch,定时器抄 mTCP 的时间轮,拥塞控制只做 Reno 主干——先跑通,再谈 RACK/BBR。mTCP 的 RTO:一个公式 + 指数退避mTCP 没有每流定时器,所有待重传流挂时间轮,主循环每轮扫到期槽(timer.c:384-441,P2-02 的 ④)。到期后进 HandleRTO:HandleRTO(mtcp_manager_t mtcp, uint32_t cur_ts, tcp_stream *cur_stream) { // timer.c:183 uint8_t backoff; // ... /* count number of retransmissions */ if (cur_stream->sndvar->nrtx < TCP_MAX_RTX) { cur_stream->sndvar->nrtx++; // timer.c:201-202 } else { /* if it exceeds the threshold, destroy and notify to application */ // ... state = TCP_ST_CLOSED; RaiseErrorEvent / DestroyTCPStream return ERROR; // timer.c:203-220 } /* update rto timestamp */ if (cur_stream->state >= TCP_ST_ESTABLISHED) { uint32_t rto_prev; backoff = MIN(cur_stream->sndvar->nrtx, TCP_MAX_BACKOFF); rto_prev = cur_stream->sndvar->rto; cur_stream->sndvar->rto = ((cur_stream->rcvvar->srtt >> 3) + cur_stream->rcvvar->rttvar) << backoff; // timer.c:229-233 // ... } else if (cur_stream->state >= TCP_ST_SYN_SENT) { /* if there is no rtt measured, update rto based on the previous one */ if (cur_stream->sndvar->nrtx < TCP_MAX_BACKOFF) { cur_stream->sndvar->rto <<= 1; // timer.c:240-244 } } /* reduce congestion window and ssthresh */ cur_stream->sndvar->ssthresh = MIN(cur_stream->sndvar->cwnd, cur_stream->sndvar->peer_wnd) / 2; // timer.c:249 if (cur_stream->sndvar->ssthresh < (2 * cur_stream->sndvar->mss)) { cur_stream->sndvar->ssthresh = cur_stream->sndvar->mss * 2; // :250-252 } cur_stream->sndvar->cwnd = cur_stream->sndvar->mss; // :253三个要点:① rto = (srtt>>3 + rttvar) << backoff 是 RFC 6298 的一元简化(srtt 存 8 倍定点数,>>3 还原);② 重传次数到 TCP_MAX_RTX 直接销毁连接——不做内核的"路由失效探测";③ 超时减窗一句话:ssthresh 砍半(下限 2MSS),cwnd 回到 1MSS。mTCP 的快重传:三重复 ACK(tcp_in.c:417-473)ProcessACK(tcp_in.c:310)里数 dup_acks,到 3 就动手: /* Fast retransmission */ if (dup && cur_stream->rcvvar->dup_acks == 3) { // tcp_in.c:417 // ... if (TCP_SEQ_LT(ack_seq, cur_stream->snd_nxt)) { // ... cur_stream->snd_nxt = ack_seq; // 回退发送指针 :441 } /* update congestion control variables */ /* ssthresh to half of min of cwnd and peer wnd */ sndvar->ssthresh = MIN(sndvar->cwnd, sndvar->peer_wnd) / 2; // :447 if (sndvar->ssthresh < 2 * sndvar->mss) { sndvar->ssthresh = 2 * sndvar->mss; } sndvar->cwnd = sndvar->ssthresh + 3 * sndvar->mss; // :451 // ... AddtoSendList(mtcp, cur_stream); // 立即重传 :464 } else if (cur_stream->rcvvar->dup_acks > 3) { /* Inflate congestion window until before overflow */ if ((uint32_t)(sndvar->cwnd + sndvar->mss) > sndvar->cwnd) { sndvar->cwnd += sndvar->mss; // :466-473 } }🐧 内核对照:同一件事的完整版定时器侧:内核没有"时间轮统一扫",而是每 sock 一个 write timer,tcp_write_timer_handler 按 icsk_pending 分派四类事件(tcp_timer.c:666-698):REO_TIMEOUT(RACK 乱序超时)、LOSS_PROBE(尾丢探测)、RETRANS(重传,进 tcp_retransmit_timer tcp_timer.c:511)、PROBE0(零窗探测)。tcp_retransmit_timer 的真实行为(tcp_timer.c:511 起):!tp->packets_out 直接返回(:531)→ 零窗时转 PROBE0(:538-573)→ tcp_write_timeout 判超时死亡(:576)→ tcp_enter_loss(sk)(:600)→ tcp_retransmit_skb 重传队头(:603)→ 失败则按 TCP_RESOURCE_PROBE_INTERVAL 节流重试(:607-609)→ 最后 inet_csk_reset_xmit_timer(..., TCP_RTO_MAX) 夹上限(:656-657)。mTCP 的 30 行,内核用了 150 行——多出来的都是状态机:零窗、loss recovery、资源节流。拥塞控制侧:内核是插件化(struct tcp_congestion_ops),Reno 只是 fallback(tcp_cong.c:532 struct tcp_congestion_ops tcp_reno)。三个真实函数:__bpf_kfunc u32 tcp_slow_start(struct tcp_sock *tp, u32 acked) { u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh); acked -= cwnd - tcp_snd_cwnd(tp); tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp)); return acked; // tcp_cong.c:457-465 } __bpf_kfunc void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked) { if (tp->snd_cwnd_cnt >= w) { tp->snd_cwnd_cnt = 0; tcp_snd_cwnd_set(tp, tcp_snd_cwnd(tp) + 1); // tcp_cong.c:474-477 } tp->snd_cwnd_cnt += acked; // 每 ACK 记学分 :479 // ... } __bpf_kfunc u32 tcp_reno_ssthresh(struct sock *sk) { // ... return max(tcp_snd_cwnd(tp) >> 1U, 2U); // tcp_cong.c:520 }慢启动按 acked 字节加窗(还处理 stretch ACK,tcp_cong.c:448-456 注释);拥塞避免用 snd_cwnd_cnt 学分制实现"每 RTT +1";丢包减半在 tcp_reno_ssthresh。mTCP 直接 cwnd += MSS(每包),是"每 ACK 一个 MSS"的粒度放大版。libmtcp-v3(mtcp_fsm.c 计划中)的取舍清单状态机:照抄 mTCP 的 11 态 switch 结构(tcp_in.c:1298 布局),handler 全部 static inline;定时器:时间轮 + 每轮批查(抄 timer.c:384 的 CheckRtmTimeout 模式),不做内核的每流 timer;RTO:(srtt>>3 + rttvar) << backoff(timer.c:232 原式),SYN 阶段 rto <<= 1(timer.c:243);快重传:三重复 ACK 触发(tcp_in.c:417),cwnd = ssthresh + 3MSS(tcp_in.c:451);明确不做:RACK/Probe0/SACK 主动恢复/PRR——第一版目标是"和 mTCP 行为等价",内核的完整版是 P3+ 的课题。面试 3 问mTCP 的 RTO 公式 (srtt>>3 + rttvar) << backoff 里 >>3 是什么? → srtt 存的是 8 倍定点数(3 位小数位),>>3 还原成真实值;backoff 是重传次数的指数退避(timer.c:229-233)。内核为什么需要 LOSS_PROBE 事件而 mTCP 不需要? → 尾丢场景(发了一批丢最后几个)RTO 触发太慢,内核用 TLP 提前探测(tcp_timer.c:687-688);mTCP 的简化模型里这类优化不存在,代价是尾丢恢复慢一个 RTO。三重复 ACK 后为什么 cwnd = ssthresh + 3MSS? → 3 个 dupack 证明有 3 个包已离开网络,加 3MSS 补偿这 3 个"已确认离网"的段(tcp_in.c:451),对应 RFC 5681 的 fast recovery 简化。🔧 动手实验用 tc netem 给测试对端注入 5%/10% 丢包,跑 mTCP 的 epserver/epwget 大文件传输,tcpdump 抓重传:① 验证快重传(3 dupack 触发,重传包 seq 连续);② 验证 RTO 指数退避(重传间隔 1x/2x/4x);③ 对照内核 iperf3 同丢包率下的恢复曲线。抓包与时间线落点 perf-data/P2/06-rto.md。下一篇:P2-07 · 性能调优实录:从 perf top 到单核 14.88 Mpps
2026年08月18日
1 阅读
0 评论
0 点赞
2026-08-18
P2-07 · 性能调优实录:从 perf top 到单核 14.88 Mpps
对应路线图:Phase 2 (Day 50-91) | DPDK 24.11.6 LTS | 内核对照 v6.6工具出处:source/dperf-main/(压测)+ perf 工具链(P0-05 所学)上一篇:P2-06 实现(下)状态机与 RTO | 下一篇:P3-01 OVS-DPDK 数据面一句话定位调优不是玄学,是闭环:perf top 找热点 → 火焰图看调用链 → perf c2c 查伪共享 → 批量化改代码 → dperf 压测验证。每轮只改一个变量。本文是 P2 收尾的方法论 + dperf 实操手册;14.88 Mpps(10G 64 字节线速)是阶段目标,实测数据随调优进度补进 perf-data/P2/07/。压测工具:dperf 的真实用法dperf(source/dperf-main/)是百度开源的 DPDK 流量发生器。它的入口流程(src/main.c:56-86)很短:int main(int argc, char **argv) { // ... if (config_parse(argc, argv, &g_config) < 0) { // main.c:60 return 1; } // ... if (dpdk_init(&g_config, argv[0]) < 0) { // main.c:76 printf("dpdk init fail\n"); return 1; } dpdk_run(lcore_main, NULL); // main.c:81 // ... }命令行只认 -c/--conf 配置文件(config.c:1730)。README 给了一个非常有用的参考系(README.md 实测,100G E810 双口):单核 16.8 Mpps、6 核 105.2 Mpps、12 核 204.6 Mpps;HTTP CPS 单核 400 万。这意味着在 10G 网卡上"单核线速"(14.88 Mpps)是 dperf 远没到极限的工况——压测工具必须比被测对象强,先确认这一点。flood 压测的真实配置(test/performance/tx-pps/flood-1.conf,全文):mode client cpu 0 slow_start 30 tx_burst 128 launch_num 10 payload_size 1 duration 10d protocol udp flood cc 16k cps 1k keepalive 1ms #port pci addr gateway port 0000:5e:00.1 6.6.234.4 6.6.234.1 # addr_start num client 6.6.234.4 1 # addr_start num server 6.6.213.4 1 # port_start num listen 80 1注意 tx_burst 128:dperf 自己就用大 burst,验证了"批量化是第一优先级"(对照 P1-05 的 ixgbe RDT 攒批与 dpdk_module.c:375 的 tx_burst)。方法论四步(每步配一条真实命令)① perf top 找热点:perf top -C 2 -d 1 钉住数据面 lcore 2,看谁烧 CPU。常见三兄弟:rte_eth_rx_burst(说明收包是瓶颈,查 batch 与 mempool cache)、查表函数(说明 hash 太散,见 P2-05)、memcpy(说明拷包太多,该改零拷贝)。② 火焰图看调用链:perf record -g -F 999 -- ./app + FlameGraph 脚本出 SVG。找"宽度异常"的调用路径——比如状态机 handler 里藏着日志/断言(mTCP 的 TRACE_* 宏链),宽度即成本。③ perf c2c 查伪共享:perf c2c record + perf c2c report --stats。HitM 高的相邻字段就是伪共享——P1-03 的 RTE_CACHE_GUARD(rte_ring_core.h:116 附近)就是为这类问题准备的。多核下伪共享的杀伤力大于任何算法优化。④ 批量化改代码:burst 放大(32→128)、ACK 攒批(对照 mTCP ACK_OPT_AGGREGATE,tcp_in.c:924)、批量查表(rte_hash_lookup_bulk_data,rte_hash.h:559)、批量 free mbuf(dpdk_module.c:444-447 的 free 循环是反面教材——rte_pktmbuf_free 有批量版 rte_pktmbuf_free_bulk,热路径少一次循环开销)。每次只改一个,改完回到 ⑤。⑤ dperf 验证:./dperf -c test/performance/tx-pps/flood-1.conf(对端跑 server 配置),每秒输出长这样(README.md 节选):seconds 22 cpuUsage 52 pktRx 3,001,058 pktTx 3,001,025 bitsRx 2,272,799,040 bitsTx 1,920,657,600 dropTx 0 ierrors 0 oerrors 0 imissed 0三个数字看三件事:pktRx 是否达到 14.88M × 秒数、dropTx 是否非零(发送侧丢包)、imissed 是否非零(网卡丢包,通常是 burst 太短或 CPU 被抢占)。三者全绿才算数。为什么目标定 14.88 Mpps10G 线速小包:10^10 bit/s ÷ (64+12+8) byte ÷ 8 bit = 14,880,952 pps。P1-05 的结论是"内核单核百万级 PPS,DPDK 单核可达线速",P2 的目标就是亲手把这个结论复现到 libmtcp-v3 的 RX 路径上。参考系:dperf README 在 100G 上单核 16.8 Mpps(README.md PPS 表),说明 DPDK 单核收包能力在 10G 上留有 10 倍余量——我们的瓶颈只会在自己的代码里。🐧 内核对照同一套 perf 工具看内核侧:perf top -C <软中断核> 会看到 tcp_v4_rcv(tcp_ipv4.c:1999)、__inet_lookup_established(:1803)等——软中断预算(budget)把单核 PPS 锁在百万级,这是 P0-07/P1-05 已经验证过的基线。调优 DPDK 路径时,同一份数据也可以丢进内核对照表:同样的热点,两种解法。面试 3 问perf c2c 报告里 HitM 高说明什么? → 两个核心反复无效化对方的同一 cache line(伪共享);解法是把热点字段拆分到独立 cache line(P1-03 的 RTE_CACHE_GUARD 范式)。为什么压测工具(dperf)必须比被测对象强? → 否则瓶颈在工具上,测出来的曲线是工具的曲线;dperf 单核 16.8 Mpps(README.md)远超 10G 线速 14.88 Mpps,才有资格压测 10G 数据面。burst 从 32 加到 128 为什么通常能提速? → 均摊每次 burst 的固定成本(函数调用、描述符回写、寄存器访问);P1-05 的 RDT 攒批(ixgbe_rxtx.c:1965 附近)与 dperf tx_burst 128 是同一原理。🔧 动手实验流程:① 建 libmtcp-v3 的 RX-only 最小程序(P2-04/05 的骨架),先跑通 flood 收发;② 按四步法逐轮调优,每轮记录 perf top 采样 + dperf 输出到 perf-data/P2/07/(命名 round-N-*.txt);③ 达到或逼近 14.88 Mpps 后,把同机型上内核 iperf3 的 PPS 基线补进对照表。未达标时的检查清单按 ①②③④ 顺序走一遍,不要在没数据的情况下改代码。上一篇:P2-06 · libmtcp-v3 实现(下):状态机、RTO 与 cwnd 简化版
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P3-01 · OVS-DPDK 数据面:EMC 两级缓存 + upcall 慢路径
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6上一篇:P2-07 性能调优 | 下一篇:P3-02 OVS-DPDK 调优一句话定位OVS-DPDK 的数据面 = PMD 轮询收包 + 两级软件查表缓存(EMC → dpcls)+ 查不到就 upcall 回 ofproto 慢路径——把 OpenFlow 流表下沉到每核缓存。PMD 主循环(pmd_thread_main,dpif-netdev.c:6013)主循环节选(dpif-netdev.c:6087-6127): for (;;) { for (i = 0; i < poll_cnt; i++) { process_packets = dp_netdev_process_rxq_port(pmd, poll_list[i].rxq, poll_list[i].port_no); rx_packets += process_packets; if (process_packets >= PMD_SLEEP_THRESH) sleep_time = 0; } if (!rx_packets) { tx_packets = dp_netdev_pmd_flush_output_packets(pmd, max_sleep && sleep_time ? true : false); } // ...(max_sleep 时按 PMD_SLEEP_INC_US 阶梯递增 sleep_time,省略) }每轮收完包立刻查表转发。PMD_SLEEP_THRESH(dpif-netdev.c:177,= NETDEV_MAX_BURST/2)做自适应休眠:连续收不到半批包才允许 PMD 睡一觉。dp_netdev_process_rxq_port(dpif-netdev.c:4434-4498)内部三步:netdev_rxq_recv(netdev-dpdk 即 netdev_dpdk_rxq_recv,netdev-dpdk.c:2990,底层 rte_eth_rx_burst)→ dp_netdev_input 查表 → dp_netdev_pmd_flush_output_packets 立即发 TX 攒批(:4483)。两级缓存:EMC 精确命中 → dpcls 掩码匹配① EMC:每 PMD 私有的 8192 槽组相联缓存结构(dpif-netdev-dfc.h:93-101):struct emc_entry = flow 指针 + netdev_flow_key;struct emc_cache = 8192 个 entry 数组(EM_FLOW_HASH_SHIFT=13,:67-68)。查表(dpif-netdev-dfc.h:150-166 节选): EMC_FOR_EACH_POS_WITH_HASH (cache, current_entry, key->hash) { if (current_entry->key.hash == key->hash && emc_entry_alive(current_entry) && emc_flow_key_equal_mf(¤t_entry->key, &key->mf)) { return current_entry->flow; } } return NULL;32 位 hash 切 2 段(EM_FLOW_HASH_SEGS=2,:70)各当一次索引,只探 2 个槽;命中三条件——hash 相等、流没死、miniflow 逐位 memcmp(:147)。每 PMD 一份私有 EMC(:62-64 注释),天然免锁——RSS 同流同核的红利。② dpcls:tuple-space 子表分类器struct dpcls(dpif-netdev.c:186-191)是一组子表的容器;每个子表收集同一掩码组合的规则(dpif-netdev-dpcls.h:73-100),按指纹选最优查找实现(lookup_func,:93)。批量查找(dpif-netdev.c:9278-9301): /* The Datapath classifier - aka dpcls - is composed of subtables. * Each subtable collects rules with matches on a specific subset * of packet fields as defined by the subtable's mask. ... */ PVECTOR_FOR_EACH (subtable, &cls->subtables) { found_map = subtable->lookup_func(subtable, keys_map, keys, rules); keys_map &= ~found_map; if (!keys_map) { return true; } subtable_pos++; }一次批量查 32 个包(NETDEV_MAX_BURST):命中即从 keys_map 位图摘掉,全部命中提前返回——把逐包流表匹配变成批量操作。③ 快路径总装:dfc_processing(dpif-netdev.c:7289) miniflow_extract(packet, &key->mf); key->len = 0; /* Not computed yet. */ key->hash = (md_is_valid == false) ? dpif_netdev_packet_get_rss_hash_orig_pkt(packet, &key->mf) : dpif_netdev_packet_get_rss_hash(packet, &key->mf); /* If EMC is disabled skip emc_lookup */ flow = (cur_min != 0) ? emc_lookup(&cache->emc_cache, key) : NULL; if (OVS_LIKELY(flow)) { n_emc_hit++; dfc_processing_enqueue_classified_packet(...); } else { /* Exact match cache missed. Group missed packets together at * the beginning of the 'packets' array. */ // ...(miss 的 key 收集进 missed_keys,批量查 dpcls) }hash 直接复用 NIC RSS 哈希(dpif_netdev_packet_get_rss_hash_orig_pkt,dpif-netdev-dpcls.h:116-129);循环内 prefetch 下一个包(dpif-netdev.c:7331-7336)——P1-02 的 cache 纪律在这里兑现。upcall:miss 了怎么办handle_packet_upcall(dpif-netdev.c:7430)把 miss 包交给 dp_netdev_upcall(:6973),最终调用 dp->upcall_cb(:7013)——连到 ofproto 层,流表翻译出 actions 后把新流装回 EMC/dpcls。慢路径是逐包的,EMC 命中率是性能第一指标。🐧 内核对照:同一个"upcall"思想,两套实现内核 OVS 数据面(net/openvswitch/datapath.c:246-294,节选):void ovs_dp_process_packet(struct sk_buff *skb, struct sw_flow_key *key) { // ... /* Look up flow. */ flow = ovs_flow_tbl_lookup_stats(&dp->table, key, skb_get_hash(skb), &n_mask_hit, &n_cache_hit); if (unlikely(!flow)) { struct dp_upcall_info upcall; upcall.cmd = OVS_PACKET_CMD_MISS; error = ovs_dp_upcall(dp, skb, key, &upcall, 0); // ... goto out; } ovs_flow_stats_update(flow, key->tp.flags, skb); sf_acts = rcu_dereference(flow->sf_acts); error = ovs_execute_actions(dp, skb, sf_acts, key); // ... }环节内核 OVS datapathOVS-DPDK (dpif-netdev)收包NAPI 软中断PMD 轮询(pmd_thread_main)缓存per-CPU mask cache(n_cache_hit)每 PMD 私有 EMC 8192 槽流表ovs_flow_tbl_lookup_statsdpcls 子表批量查missovs_dp_upcall 发 netlinkdp_netdev_upcall 直调 upcall_cb同构:两级查找 + miss 上送,这是 OVS 全平台的骨架;命中后内核 ovs_execute_actions 在内核态执行 actions,DPDK 在用户态执行。差异在运行模型:内核版每包构造 skb、受 NAPI budget 约束;DPDK 版批量 32 包、无预算、mbuf 复用。面试 2 问EMC 为什么只要 2 次探测? → 32 位 hash 按 EM_FLOW_HASH_SEGS=2 切两段做组相联索引(dpif-netdev-dfc.h:70、120-124),每段 13 位索引 8192 槽(:67-68)。upcall 链路的终点在哪? → dp_netdev_upcall 调 dp->upcall_cb(dpif-netdev.c:7013);内核版对应 ovs_dp_upcall 发 OVS_PACKET_CMD_MISS(datapath.c:267-276)。🔧 动手实验搭 OVS-DPDK:ovs-vsctl add-br br0 -- set bridge br0 datapath_type=netdev + add-port 两个 DPDK phy 口,ovs-appctl dpif-netdev/pmd-stats-show 看 EMC hit/miss;单流 vs 10k 流各打 30s 对比 miss 曲线。数据落点:perf-data/P3/01-ovs-emc-hitrate.md(EMC 命中率 vs 流数量表)。下一篇:P3-02 · OVS-DPDK 调优实战:pmd-cpu-mask 与 tx-flush
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P3-02 · OVS-DPDK 调优实战:pmd-cpu-mask 与 tx-flush-interval
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6上一篇:P3-01 OVS-DPDK 数据面 | 下一篇:P3-03 SONiC 控制面链路一句话定位OVS-DPDK 的性能不靠改代码,靠把线程钉对核、把发送攒够批:pmd-cpu-mask 决定"谁在哪个核上轮询",tx-flush-interval 决定"攒多少包再发一次"。本篇没有独立源码文件——它的"源码"是配置项语义 + 上一节读过的数据面代码,我如实标注出处。pmd-cpu-mask:PMD 线程的出生证明官方语义定义在 vswitchd/vswitch.xml:375-392(节选): <column name="other_config" key="pmd-cpu-mask"> <p> Specifies CPU mask for setting the cpu affinity of PMD (Poll Mode Driver) threads. Value should be in the form of hex string, similar to the dpdk EAL '-c COREMASK' option input or the 'taskset' mask input. The lowest order bit corresponds to the first CPU core. A set bit means the corresponding core is available and a pmd thread will be created and pinned to it. ... </p> <p> If not specified, one pmd thread will be created for each numa node and pinned to any available core on the numa node by default. </p> </column>要点:mask 的每一位 = 一个 PMD 线程。0x6(二进制 0110)就是 core 1、2 各起一个 PMD;不指定则每 NUMA node 只给一个 PMD——多队列网卡会严重饿死,这是最常见的性能坑。数据面依据:PMD 线程创建时按 core_id 绑核(dpif-netdev.c:6033-6034): ovs_numa_thread_setaffinity_core(pmd->core_id); dpdk_attached = dpdk_attach_thread(pmd->core_id);每个 PMD 在 pmd_load_queues_and_ports(dpif-netdev.c:6035)拿到自己的 poll_list,主循环只轮询自己队列组——所以 PMD 数量和 RXQ 数量必须匹配(tests/pmd.at:137 有专门的分配测试用例)。绑定纪律(Phase 1 P1-06 学过的规矩在这里兑现):PMD 核别给 vswitchd 主线程(vswitch.xml:363-367 警告多线程共享单核的后果);用 isolcpus 或 systemd AllowedCPUs 隔离;PMD 所在 socket 必须与它轮询的 NIC 一致,跨 socket 内存带宽腰斩。tx-flush-interval:用一点延迟换吞吐语义与效果(Documentation/intro/install/dpdk.rst:647-669):The ``tx-flush-interval`` parameter can be used to specify the time in microseconds OVS should wait between two send bursts to a given port (default is ``0``). When the intermediate queue fills up before that time is over, the buffered packet batch is sent immediately:: $ ovs-vsctl set Open_vSwitch . other_config:tx-flush-interval=50 ... Low traffic (``packet rate < 1 / tx-flush-interval``) should not experience any significant latency or throughput increase as packets are forwarded immediately. At intermediate load levels (``1 / tx-flush-interval < packet rate < 32 / tx-flush-interval``) traffic should experience an average latency increase of up to ``1 / 2 * tx-flush-interval`` and a possible throughput improvement. Very high traffic (``packet rate >> 32 / tx-flush-interval``) should experience the average latency increase equal to ``32 / (2 * packet rate)``. Most send batches in this case will contain the maximum number of packets (``32``).翻译成白话:TX 侧引入一个攒批窗口。窗口期内收到的包先堆在 tx_port 的输出队列,攒到一批 32 个(NETDEV_MAX_BURST)或窗口到期才调 rte_eth_tx_burst。低流量零影响(没包可攒,直接发);中流量每个包平均多等 1/2 * interval;高流量攒满 32 个才发,延迟上界 32/(2*rate)。官方给的经验值:PHY-VM-PHY 场景 50us 收益明显(dpdk.rst:671-674)。数据面依据:flush_time 与 forcetx-flush-interval 进 dp->tx_flush_interval 原子变量(dpif-netdev.c:284,默认值 DEFAULT_TX_FLUSH_INTERVAL = 0,dpif-netdev.c:106;配置解析在 dpif-netdev.c:3902-3906)。PMD 把包排进某端口的输出队列时设置到期时间(dpif-netdev.c:4391-4392): atomic_read_relaxed(&pmd->dp->tx_flush_interval, &tx_flush_interval); // ... p->flush_time = pmd->ctx.now + tx_flush_interval;真正发出去的是 dp_netdev_pmd_flush_output_packets(dpif-netdev.c:4414-4431): HMAP_FOR_EACH (p, node, &pmd->send_port_cache) { if (!dp_packet_batch_is_empty(&p->output_pkts) && (force || pmd->ctx.now >= p->flush_time)) { output_cnt += dp_netdev_pmd_flush_output_on_port(pmd, p); } }要么 force、要么到期。force 的两个时机:本轮收到包后立即 flush(dpif-netdev.c:4483);主循环发现本轮无包、准备睡 PMD 前强制 flush(dpif-netdev.c:6119-6126,max_sleep && sleep_time ? true : false)——绝不让包在输出队列里陪着 PMD 睡觉。底层仍是批量发送(netdev-dpdk.c:3394-3418)netdev_dpdk_eth_send(struct netdev *netdev, int qid, struct dp_packet_batch *batch, bool concurrent_txq) { struct rte_mbuf **pkts = (struct rte_mbuf **) batch->packets; // ... cnt = netdev_dpdk_common_send(netdev, batch, &stats); dropped = netdev_dpdk_eth_tx_burst(dev, qid, pkts, cnt);攒批的意义就在 rte_eth_tx_burst 的批量摊薄:一次发送 32 个包和发 1 个包的 MMIO/描述符写回成本差不了太多——批越大,每包成本越低。这就是 tx-flush-interval 用延迟换吞吐的微观机制。🐧 内核对照:内核怎么攒批环节内核网络栈OVS-DPDK攒批机制NAPI budget 收一批 + qdisc 自然攒包TX 侧显式 tx_flush_interval 攒批窗口触发发送qdisc 出队(net_tx_action 软中断)flush_time 到期或 force(dpif-netdev.c:4426)低流量中断驱动,来包就发无包可攒,立即发(dpdk.rst:658-660)延迟模型qdisc 排队延迟 + BQL平均 1/2 * interval(dpdk.rst:662-665)每包成本skb 构造 + qdisc 锁mbuf 批量 rte_eth_tx_burst内核靠中断 + qdisc 天然批量,代价是每包软中断和 skb 构造;DPDK 主动让出微秒级延迟换纯批量发送。思路一致:批量摊薄固定成本。面试 3 问pmd-cpu-mask=0x6 会创建几个 PMD 线程? → 2 个(core 1、2),最低位对应 core 0(vswitch.xml:383-386);不设置则每 NUMA node 默认只有 1 个 PMD(vswitch.xml:389-391)。tx-flush-interval 为什么能提吞吐? → 攒批到 32 个再 rte_eth_tx_burst,摊薄每次发送的固定开销;代价是中流量每包平均多等 1/2 * interval(dpdk.rst:662-665)。PMD 要睡之前,输出队列里的包怎么办? → 主循环强制 force=true flush(dpif-netdev.c:6124-6126),包不会陪着 PMD 睡 max_sleep。🔧 动手实验# 1. 两个 PMD 核 + 两个隔离核 ovs-vsctl set Open_vSwitch . other_config:pmd-cpu-mask=0x18 # 2. 验证队列分配(每个 PMD 该看到一半 RXQ) ovs-appctl dpif-netdev/pmd-rxq-show # 3. 打流对比 tx-flush-interval=0 vs 50 的 PPS/延迟 ovs-vsctl set Open_vSwitch . other_config:tx-flush-interval=50 # 4. 观察攒批效果 ovs-appctl dpif-netdev/pmd-stats-show ovs-appctl dpif-netdev/pmd-perf-show # tx batches 直方图产出 perf-data/P3/02-pmd-tune.md:不同 PMD 核数 × tx-flush-interval 的 PPS/平均延迟矩阵。下一篇:P3-03 · SONiC 控制面链路:APPL_DB → ASIC_DB → syncd
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P3-03 · SONiC 控制面链路:APPL_DB → ASIC_DB → syncd
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6上一篇:P3-02 OVS-DPDK 调优 | 下一篇:P3-04 SAI 头文件全景一句话定位SONiC 把"交换机配置"拆成一条Redis 接力链:业务进程写 APPL_DB → orchagent 消费、翻译成 SAI 对象写 ASIC_DB → syncd 消费、调厂商 SDK 驱动 ASIC。控制面与数据面之间唯一的耦合点是 Redis 的 key-value 协议——谁写谁读、写成什么格式,全部由这套协议定义。全景:Redis 六库与三进程数据库名是 sairedis 的公开常量(lib/sairediscommon.h:104-106):ASIC_DB/STATE_DB/COUNTERS_DB/FLEX_COUNTER_DB,加上 CONFIG_DB、APPL_DB 就是六库。职责一句话版:CONFIG_DB 存静态配置;APPL_DB 是业务进程(BGP/LLDP/Port)的目标意图;ASIC_DB 是翻译后的 SAI 对象;STATE_DB 是 ASIC 实际状态;COUNTERS_DB/FLEX_COUNTER_DB 是统计。本篇只跟 APPL_DB → ASIC_DB 这条主线。第一棒:orchagent 从 APPL_DB 消费任务orchagent 是 swss 的核心,每个业务一个 Orch 子类(PortOrch、RouteOrch……),任务处理入口 Orch::doTask(orchagent/orch.cpp:919-955,节选):void Orch::doTask() { // limit the number of tasks moved from RetryMap to SyncMap in one iteration // to avoid starvation of new tasks in SyncMap auto threshold = gBatchSize == 0 ? 30000 : gBatchSize; size_t count = 0; for (auto &it : m_consumerMap) { try { count += retryToSync(it.first, threshold - count); it.second->drain(); } catch (const std::invalid_argument& e) { SWSS_LOG_ERROR("Exception caught: type=invalid_argument, table=%s, orch=%s, error=%s", it.first.c_str(), typeid(*this).name(), e.what()); } // ...(其余 catch 分支省略) } }m_consumerMap 把表名映射到 Consumer,每个 Orch 通过它声明"我订阅 APPL_DB 的哪些表"。消费动作(orch.cpp:455-456):subTable->pops(entries) → addToSync(entries)。Redis 表被当成队列:pops 取出 KeyOpFieldsValuesTuple(key + SET/DEL + field:value 列表),addToSync 交给子类 doTask 处理。RouteOrch 收到一条 ROUTE_TABLE:192.168.1.0/24 的 SET,就调 SAI 接口创建路由对象。第二棒:ClientSai 把 SAI 调用序列化成 ASIC_DB 写操作swss 链接 sairedis 的客户端库——把标准 SAI C API 变成 Redis 写。ClientSai::create(lib/ClientSai.cpp:112-209,节选):sai_status_t ClientSai::create( _In_ sai_object_type_t objectType, _Out_ sai_object_id_t* objectId, _In_ sai_object_id_t switchId, _In_ uint32_t attr_count, _In_ const sai_attribute_t *attr_list) { MUTEX(); // NOTE: in client mode, each create oid must be retrieved from server since // server is actually creating new oid, and it's value must be transferred // over communication channel *objectId = SAI_NULL_OBJECT_ID; // ...(INIT_SWITCH=false 的握手分支,省略)... auto status = create( objectType, sai_serialize_object_id(switchId), attr_count, attr_list); if (status == SAI_STATUS_SUCCESS) { // since user requested create, OID value was created remotely and it // was returned in m_lastCreateOids *objectId = m_lastCreateOids.at(0); } // ... }三个关键点:① OID 由对端分配——orchagent 传 SAI_NULL_OBJECT_ID,真正的 OID 是 syncd 回传的(m_lastCreateOids);② switch ID 也要序列化成字符串做 key;③ 全程互斥(MUTEX())——Redis 通信通道是复用的。写出的 key 形如 ASIC_STATE:SAI_OBJECT_TYPE_ROUTE_ENTRY:{...},value 是 SAI_ROUTE_ENTRY_ATTR_NEXT_HOP_ID:oid:0x... 这类 field:value——这套文本协议同时是 ASIC_DB 的 schema。第三棒:syncd 消费 ASIC_DB,调厂商 SDKsyncd 侧走 RedisRemoteSaiInterface(服务端模式)。请求-响应类消息走独立频道,notifySyncd(lib/RedisRemoteSaiInterface.cpp:2236-2266)是典型样例: auto key = sai_serialize(redisNotifySyncd); // we need to use "GET" channel to be sure that // all previous operations were applied, ... m_communicationChannel->set(key, entry, REDIS_ASIC_STATE_COMMAND_NOTIFY); auto status = waitForNotifySyncdResponse();普通 SAI 调用走 ASIC_DB 的 key-value 写,notify/GET 请求走 REDIS_ASIC_STATE_COMMAND_NOTIFY 频道,响应回来在 REDIS_TABLE_GETRESPONSE 表(sairediscommon.h:100)。syncd 处理完 create 把 OID 写回响应表,orchagent 的 m_lastCreateOids 才有值——闭环。最终 syncd 持有一张真实的 sai_switch_api_t 表(references/sai-headers/inc/saiswitch.h:4261-4279),一路调进厂商 SDK(BCM/MLNX 或 vslib)。🐧 内核对照:rtnetlink 是内核的"ASIC_DB"ip route add 最终进 rtnl_newlink(net/core/rtnetlink.c:3744-3757):static int rtnl_newlink(struct sk_buff *skb, struct nlmsghdr *nlh, struct netlink_ext_ack *extack) { struct rtnl_newlink_tbs *tbs; int ret; tbs = kmalloc(sizeof(*tbs), GFP_KERNEL); if (!tbs) return -ENOMEM; ret = __rtnl_newlink(skb, nlh, tbs, extack); kfree(tbs); return ret; }环节内核SONiC用户接口netlink socket(RTM_NEWLINK)Redis 写(APPL_DB)消息格式nlmsghdr + nlattr 二进制 TLV文本 key + field:value翻译层rtnetlink 直接改 net_deviceorchagent 翻译成 SAI 对象执行层内核驱动(ndo_*)syncd → 厂商 SAI SDK同一张架构图,两种实现:用户态请求 → 统一入口 → 翻译 → 驱动执行。SONiC 用 Redis 因为控制面要多进程、可热重启、可容器化——Redis 天然是进程间的共享内存 + 队列;内核用 netlink 因为要服务全系统且严格校验(extack 报错路径)。面试 4 问为什么 create 的 OID 不由 orchagent 分配? → 服务端才真正创建对象,OID 必须从 syncd 经通信通道回传(ClientSai.cpp:123-125 注释、190 行 m_lastCreateOids)。APPL_DB 和 ASIC_DB 的区别? → APPL_DB 是意图("我想要一条路由"),ASIC_DB 是翻译后的 SAI 对象("SAI_ROUTE_ENTRY + NEXT_HOP_ID"),中间隔着 orchagent 的翻译。syncd 和 orchagent 之间除了 key-value 还有什么通道? → 请求-响应类走独立频道,如 REDIS_ASIC_STATE_COMMAND_NOTIFY(RedisRemoteSaiInterface.cpp:2260),避免与 op 队列竞争(2248-2255 注释)。doTask 的 threshold 为什么存在? → 限制单轮从 RetryMap 搬进 SyncMap 的任务数(默认 30000),防止重试任务饿死新任务(orch.cpp:921-923)。🔧 动手实验# 观察一条路由的完整旅程 redis-cli -n 0 HSET "ROUTE_TABLE:10.0.0.0/24" "nexthop" "10.0.0.1" # APPL_DB redis-cli -n 1 KEYS "ASIC_STATE*" # ASIC_DB 出现序列化后的 SAI 路由 redis-cli -n 6 KEYS "*" # STATE_DB 出现 ASIC 实际状态 docker exec -it syncd redis-cli -n 1 MONITOR # 看谁在消费产出 perf-data/P3/03-sonic-path.md:APPL_DB 写入 → ASIC_DB 出现 → STATE_DB 落盘的耗时三段计时表。下一篇:P3-04 · SAI 头文件全景与 15 个 MVP API
2026年08月18日
1 阅读
0 评论
0 点赞
2026-08-18
P3-04 · SAI 头文件全景:15 个 MVP API 矩阵
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6代码出处:references/sai-headers/inc/(真实 SAI 头文件),引用均标 文件:行号上一篇:P3-03 SONiC 控制面链路 | 下一篇:P3-05 sai-dpdk-mini一句话定位SAI(Switch Abstraction Interface)是一套纯 C 函数指针 API 集合:所有对象共用一个 sai_object_id_t 句柄,所有属性共用一个 sai_attribute_t 结构,所有能力通过 sai_api_query 按模块换函数表。看完头文件你会发现:它比内核的 net_device_ops 还要"接口化"——连对象都不是结构体,而是 ID。三个地基类型① OID:64 位不透明句柄(saitypes.h:131)typedef uint64_t sai_object_id_t;端口、VLAN、路由表项、交换机本身……万物皆 64 位。对象类型枚举(saitypes.h:188-227 节选):typedef enum _sai_object_type_t { SAI_OBJECT_TYPE_NULL = 0, /**< invalid object type */ // ... SAI_OBJECT_TYPE_ROUTE_ENTRY = 37, // ...OID 的高位编码对象类型、低位编码索引,所以 sai_object_type_query() 能从一个 OID 反查类型(sai.h:286)。对比内核:内核每种对象是一个 struct(net_device/fib_info),SAI 统一成一个整数——因为厂商 SDK 在进程边界之外,句柄比指针跨得了界。② attribute:id + union(saitypes.h:1815-1822)typedef struct _sai_attribute_t { /** @passparam meta */ sai_attr_id_t id; /** @passparam meta */ sai_attribute_value_t value; } sai_attribute_t;value 是个大 union(bool/u8/u16/u32/u64/oid/指针全塞里面)。所有 API 的参数都是 attr_count + attr_list——新增属性不破坏二进制接口,这是 SAI 能兼容十多家 ASIC 厂商的协议基础。③ API 表:每个模块一张函数指针表sai_switch_api_t(saiswitch.h:4261-4279)节选:typedef struct _sai_switch_api_t { sai_create_switch_fn create_switch; sai_remove_switch_fn remove_switch; sai_set_switch_attribute_fn set_switch_attribute; sai_get_switch_attribute_fn get_switch_attribute; sai_get_switch_stats_fn get_switch_stats; // ... } sai_switch_api_t;应用启动时 sai_api_query(SAI_API_SWITCH, ...) 换出这张表,剩下全是函数指针间接调用。没有虚函数、没有对象模型、没有 ABI 版本地狱——C ABI 是跨厂商 SDK 的唯一公约数。15 个 MVP API 矩阵#API签名位置作用1sai_create_switch_fnsaiswitch.h:4113创建/连接交换机2sai_create_port_fnsaiport.h:4296创建端口3sai_set_port_attribute_fnsaiport.h:4320端口 admin up/down4sai_create_vlan_fnsaivlan.h:563创建 VLAN5sai_create_fdb_entry_fnsaifdb.h:357静态 MAC 表项6sai_create_route_entry_fnsairoute.h:195L3 路由7sai_create_neighbor_entry_fnsaineighbor.h:219ARP 邻接8sai_create_next_hop_fnsainexthop.h:297下一跳对象9sai_create_acl_table_fnsaiacl.h:3713ACL 表10sai_create_hostif_fnsaihostif.h:1003上送 CPU 的 host 接口11sai_send_hostif_packet_fnsaihostif.h:1385从 CPU 发包12sai_packet_event_notification_fnsaihostif.h:1442收包回调(RX trap)13set_switch_attribute(switch 级属性)saiswitch.h:4265switch 级属性14sai_api_query 换表 + sai_remove_* 删除族sai.h:252 / 同名文件相邻行全生命周期读两个有代表性的完整定义:saifdb.h:357-360:typedef sai_status_t (*sai_create_fdb_entry_fn)( _In_ const sai_fdb_entry_t *fdb_entry, _In_ uint32_t attr_count, _In_ const sai_attribute_t *attr_list);saihostif.h:1385-1390:typedef sai_status_t (*sai_send_hostif_packet_fn)( _In_ sai_object_id_t hostif_id, _In_ sai_size_t buffer_size, _In_ const void *buffer, _In_ uint32_t attr_count, _In_ const sai_attribute_t *attr_list);套路完全一致:entry 结构描述 key(sai_fdb_entry_t 是 MAC+VLAN+port,sai_route_entry_t 是 prefix,saineighbor.h:206 的 neighbor 是 IP+rif),属性列表描述 value。把 key 和 value 分开是 SAI 和内核 API 最大的风格差异。🐧 内核对照:net_device_ops 的"每对象一张表"内核把操作挂在对象自身上(include/linux/netdevice.h:1410 起),tun 驱动实例(drivers/net/tun.c:1251-1262):static const struct net_device_ops tun_netdev_ops = { .ndo_init = tun_net_init, .ndo_uninit = tun_net_uninit, .ndo_open = tun_net_open, .ndo_stop = tun_net_close, .ndo_start_xmit = tun_net_xmit, // ... };维度内核 net_deviceSAI对象表示struct net_device(结构体+指针)sai_object_id_t(64 位句柄)操作接口net_device_ops 挂在对象里每模块一张全局 API 表属性访问内核态直接读写字段set/get_attribute(id+union)表项对象fib_info/neighbour 结构体entry 结构体(key 值分离)跨进程不可(内核态)可(序列化成 Redis/grpc)热插拔实现指针直接替换换 API 表指针SAI 是为"控制面和数据面分属不同进程/不同厂商"设计的——它的每个设计决策(句柄、属性 union、函数指针表)都在为进程边界和二进制兼容让路。这正好解释 P3-03 里 orchagent 和 syncd 为什么能隔着 Redis 工作。面试 3 问SAI 为什么不把属性做成结构体字段? → sai_attribute_t = id + union(saitypes.h:1815-1822),增删属性不改 ABI;对比内核直接访问 net_device 字段,SAI 必须保证跨厂商 SDK 的二进制兼容。sai_fdb_entry_t 和 attribute 怎么分工? → entry 描述 key(MAC/VLAN/port,saifdb.h:357 参数),attr_list 描述 value(如端口绑定)——和内核把 key、value 都塞进一个结构体的习惯不同。为什么 OID 用 64 位整数而不是指针? → 对象在厂商 SDK 进程里,跨进程只能传句柄(saitypes.h:131);类型编码进 OID 高位,sai_object_type_query 反查(sai.h:286)。🔧 动手实验把 references/sai-headers/inc/ 下 10 个头文件的 typedef sai_status_t (*sai_create_* 全部 grep 出来,做一张"对象 × 模块"对照表。写一个只含 sai_api_query 换表 + create_switch(INIT_SWITCH=false)的最小程序链接 sairedis。产出 perf-data/P3/04-sai-api-matrix.md。下一篇:P3-05 · sai-dpdk-mini:用 DPDK 实现 SAI 最小适配器
2026年08月18日
3 阅读
0 评论
0 点赞
2026-08-18
P3-05 · sai-dpdk-mini:用 DPDK 实现 SAI 最小适配器
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6代码出处:code/sai-dpdk-mini/(当前实际内容)与 source/dpdk-stable-24.11.6/lib/,引用均标 文件:行号上一篇:P3-04 SAI 头文件全景 | 下一篇:P3-06 host_if 打通一句话定位sai-dpdk-mini 回答一个问题:不买 ASIC,纯 DPDK 软件能不能当 SAI 的一个"厂商实现"? 能——把 SAI 的 L2/L3/ACL 语义逐条映射到 rte_hash/rte_lpm/rte_flow 上,SONiC 全家桶就能跑在 x86 软件交换机上。如实交代现状:截至写这篇博客,code/sai-dpdk-mini/ 里只有一份 CMake 骨架和空目录——所以这篇是"接口映射设计 + 骨架精读",每个映射目标都给出 DPDK 侧真实行号,实现代码随学习进度补。现状:骨架的真实内容code/sai-dpdk-mini/CMakeLists.txt(全文,当前唯一文件):cmake_minimum_required(VERSION 3.16) project(sai-dpdk-mini C) set(CMAKE_C_STANDARD 11) add_library(sai-dpdk SHARED src/sai_switch.c src/sai_port.c src/sai_fdb.c src/sai_route.c src/sai_neighbor.c) target_include_directories(sai-dpdk PRIVATE include ${CMAKE_SOURCE_DIR}/../../references/sai-headers/inc)这份骨架已经锁定了三个关键决策:产物是共享库 libsai-dpdk.so——它扮演厂商 SDK 的角色,syncd 加载它就像加载 libsai-brcm;模块划分 = SAI 模块划分:switch/port/fdb/route/neighbor 五个 .c,每个导出一张 SAI API 表(对应 P3-04 的 sai_switch_api_t 等);直接 include 真实 SAI 头文件(references/sai-headers/inc)——API 签名零翻译、零自造,sai_api_query 换表流程原样可用(sai.h:252)。src/、include/、test/ 目录已建但为空——按学习节奏逐模块填充,这是刻意的:先定接口契约,再写实现。映射设计:SAI 语义 → DPDK 原语① L2 FDB → rte_hash(哈希表)SAI 的 sai_fdb_entry_t(MAC+VLAN+port,saifdb.h:357 的 key 参数)天然是哈希 key。DPDK 侧的真实接口(lib/hash/rte_hash.h):// rte_hash.h:146 struct rte_hash * rte_hash_create(const struct rte_hash_parameters *params); // rte_hash.h:246 int rte_hash_add_key_data(const struct rte_hash *h, const void *key, void *data); // rte_hash.h:454 int rte_hash_lookup_data(const struct rte_hash *h, const void *key, void **data);设计要点:key 用 12 字节结构 {mac[6], vlan_id[2], port_id[4]} 对齐填零;data 存出端口号;rte_hash_add_key_data 把"查表→拿值"合成一步。学习型 FDB 的洪泛逻辑(查不到就广播)在 SAI 层用 create_fdb_entry 的删除回调 + 超时实现,与内核 br_fdb_update 的职责对照(内核版见 net/bridge/br_fdb.c:863)。② L3 路由 → rte_lpm(DIR-24-8 最长前缀匹配)SAI 的 sai_route_entry_t(sairoute.h:180 的 destination 前缀)对应 DPDK 的真实 LPM 接口(lib/lpm/rte_lpm.h):// rte_lpm.h:162-164 struct rte_lpm * rte_lpm_create(const char *name, int socket_id, const struct rte_lpm_config *config); // rte_lpm.h:221 int rte_lpm_add(struct rte_lpm *lpm, uint32_t ip, uint8_t depth, uint32_t next_hop); // rte_lpm.h:279 static inline int rte_lpm_lookup(const struct rte_lpm *lpm, uint32_t ip, uint32_t *next_hop)next_hop 参数直接挂 sai_next_hop_t 的 OID 低 32 位(P3-04:OID 是 64 位句柄,高位编码类型)。DIR-24-8 算法本身就是"软件交换机的最佳路由表实现"——这也是 P3-01 里 OVS dpcls 和内核 fib_trie 之外的第三条路线(内核对照:net/ipv4/fib_trie.c:1425 的 fib_table_lookup 用 level-compressed trie)。③ ARP 邻接 → rte_hashsai_neighbor_entry_t(IP+rif,saineighbor.h:206)同样进哈希,key 是 {ip[4], rif_oid[8]},data 是 {mac[6], 出口}。它和 FDB 共用同一份 rte_hash 初始化代码——两个 SAI 模块映射到同一个 DPDK 原语,这就是适配层的乐趣。④ ACL → rte_flowSAI 的 create_acl_table(saiacl.h:3713)+ entry 匹配动作,翻译成 DPDK 的 flow 规则(lib/ethdev/rte_flow.h):// rte_flow.h:89 struct rte_flow_attr { uint32_t group; uint32_t priority; /**< Rule priority level within group. */ uint32_t ingress:1; uint32_t egress:1; // ... }; // rte_flow.h:4695 struct rte_flow * rte_flow_create(uint16_t port_id, const struct rte_flow_attr *attr, const struct rte_flow_item pattern[], const struct rte_flow_action actions[], struct rte_flow_error *error);映射规则:SAI ACL table → rte_flow_attr.group;SAI 规则优先级 → priority;匹配字段翻译成 pattern(ETH/IPV4/5-tuple);动作翻译成 actions(DROP/COUNT/redirect)。如果 NIC 不支持 offload,DPDK 会退软件 fallback——sai-dpdk-mini 的 ACL 一开始就走软件路径,等价于一张每核免锁的精确匹配表(P3-01 的 EMC 思路复用)。⑤ port/switch → ethdevcreate_port(saiport.h:4296)→ rte_eth_dev_info_get/rte_eth_dev_configure;set_port_attribute(saiport.h:4320)的 admin state → rte_eth_dev_start/stop。switch 属性(saiswitch.h:4263 表)落在全局配置结构里。🐧 内核对照:这三张表在内核长什么样SAI 语义DPDK 原语内核对应FDB entryrte_hash_add_key_data(rte_hash.h:246)br_fdb_update(net/bridge/br_fdb.c:863)route entryrte_lpm_add(rte_lpm.h:221)fib_table_insert(net/ipv4/fib_trie.c:1199)neighborrte_hash_add_key_dataneigh_update(ARP 表)ACLrte_flow_create(rte_flow.h:4695)tc cls flower + net/sched/hostif 发包sai_send_hostif_packet_fn(saihostif.h:1385)tun_net_xmit(drivers/net/tun.c:1256)注意 DPDK 和内核的查表数据结构选择完全一致:哈希表做精确匹配(FDB/邻居),trie/LPM 做前缀匹配(路由)。区别在于 DPDK 的表是用户态预分配、每核可私有、批量查询(rte_lpm_lookup_bulk);内核表要扛 RCU 和路由事件通知。sai-dpdk-mini 就是把这个对照写成代码。面试 3 问sai-dpdk-mini 的产物为什么是共享库? → 它扮演厂商 SDK:syncd 通过 sai_api_query 换表(sai.h:252),CMake 骨架 add_library(sai-dpdk SHARED ...) 已定;模块按 SAI 模块切 .c 文件(CMakeLists 的 5 个源文件)。FDB 和 neighbor 为什么都用 rte_hash 而不是各自的表? → 两者都是精确匹配语义,key 结构不同({MAC,VLAN,port} vs {IP,rif})但原语相同;SAI 层两个模块、DPDK 层一个原语。SAI ACL 怎么映射 rte_flow? → table → rte_flow_attr.group,优先级 → priority(rte_flow.h:89-101),匹配字段 → pattern,动作 → actions,rte_flow_create(rte_flow.h:4695)下发;软件 fallback 时等价 per-core 精确匹配表。🔧 动手实验cmake -B build 验证骨架可配置(src 为空时先补桩函数);实现 sai_fdb.c 的第一版:create_fdb_entry 用 rte_hash_add_key_data,写 gdb 断点验证 key 布局(P0-06 的 gdb 功夫);实现 sai_route.c 用 rte_lpm_add,和 fib_trie.c:1199 的插入路径对照读;跑通 sai_api_query 换表 → create_switch(INIT_SWITCH=false) 的最小 demo。产出 perf-data/P3/05-sai-dpdk-mini.md(三张表插入/查询的单核 OPS 曲线)。下一篇:P3-06 · host_if 打通:让 FRR/BGP 跑在纯软件转发上
2026年08月18日
1 阅读
0 评论
0 点赞
2026-08-18
P3-06 · host_if 打通:让 FRR/BGP 跑在纯软件转发上
对应路线图:Phase 3 (Day 92-119) | DPDK 24.11.6 LTS | 内核对照 v6.6上一篇:P3-05 sai-dpdk-mini | 下一篇:P4-01 PCIe一句话定位交换机光会转发没用,BGP/OSPF 这些协议要能收发包。SAI 为此定义了一整套 hostif(host interface)抽象:控制面报文(BGP 的 TCP 179、ARP、ICMP)从数据面 trap 上 CPU,控制面要发的包经 hostif 送回数据面——这就是把 FRR 接进纯软件转发的最后一公里。hostif 三件套:类型、创建、收发① 三种通道类型(saihostif.h:848-859)typedef enum _sai_hostif_type_t { /** Netdevice */ SAI_HOSTIF_TYPE_NETDEV, /** File descriptor */ SAI_HOSTIF_TYPE_FD, /** Generic netlink */ SAI_HOSTIF_TYPE_GENETLINK } sai_hostif_type_t;三种类型的语义差异:NETDEV 让 SAI 实现去创建一个 Linux 网卡(hostif 就是"内核能看到的那张卡");FD 用文件描述符收发包(最高效,syncd 直接读 socket);GENETLINK 走 generic netlink family。创建时的 SAI_HOSTIF_ATTR_TYPE 是 MANDATORY_ON_CREATE(saihostif.h:906-908): /** * @brief Host interface type * * @type sai_hostif_type_t * @flags MANDATORY_ON_CREATE | CREATE_ONLY */ SAI_HOSTIF_ATTR_TYPE = SAI_HOSTIF_ATTR_START,② 创建与收发(saihostif.h:1003-1007、1442-1447、1385-1390)typedef sai_status_t (*sai_create_hostif_fn)( _Out_ sai_object_id_t *hostif_id, _In_ sai_object_id_t switch_id, _In_ uint32_t attr_count, _In_ const sai_attribute_t *attr_list); typedef void (*sai_packet_event_notification_fn)( _In_ sai_object_id_t switch_id, _In_ sai_size_t buffer_size, _In_ const void *buffer, _In_ uint32_t attr_count, _In_ const sai_attribute_t *attr_list);(sai_send_hostif_packet_fn 与回调同构,只是返回值是 sai_status_t。)收包靠回调(packet_event 挂在 switch 属性上,saiswitch.h:1970-1972 注释),发包靠 send_hostif_packet_fn,全套在 sai_hostif_api_t(saihostif.h:1452 起)。端到端:一个 BGP 报文的旅程进(BGP 邻居 → FRR):数据面收到 TCP 179 报文 → 查表 miss(不是转发流量)→ 命中 trap 规则 → 沿 packet_event 回调进 syncd → syncd 经 NETDEV 写进 Linux 内核协议栈 → FRR 的 zebra/bgpd socket 收到。出(FRR → BGP 邻居):bgpd 发 TCP 179 报文 → 内核路由表把流量指向 hostif 网卡 → SAI 实现的 ndo_start_xmit(NETDEV 型)或 syncd 读 FD → 调 send_hostif_packet_fn → 数据面封装后从物理口发出。对比:内核 tun 的同一位置(drivers/net/tun.c:1251-1262):static const struct net_device_ops tun_netdev_ops = { .ndo_init = tun_net_init, .ndo_uninit = tun_net_uninit, .ndo_open = tun_net_open, .ndo_stop = tun_net_close, .ndo_start_xmit = tun_net_xmit, // ... };NETDEV 型 hostif 本质就是一个"SAI 实现的 tun":内核侧是标准网卡,驱动侧不是 DMA 而是 send_hostif_packet_fn。这是理解 hostif 最快的类比。设计取舍:NETDEV vs FD vs GENETLINK NETDEVFDGENETLINK内核集成原生(就是网卡)无,syncd 自己读需要 family 注册性能中(内核协议栈必经)高(绕过内核)低(netlink 序列化)实现复杂度低(tun 参考现成)中高适用快速打通 FRR生产数据面工具类通道学习路线选 NETDEV:sai-dpdk-mini 阶段第一目标是把 FRR 跑起来,NETDEV 让 BGP 报文"看起来像普通网卡流量",内核协议栈、FRR 全都不动。等 perf 数据显示 hostif 成为瓶颈,再切 FD 型绕内核。🐧 内核对照:tun 的收包路径tun_get_user(drivers/net/tun.c:1750)是字符设备读写的核心——应用写 /dev/net/tun 就进这里(节选):static ssize_t tun_get_user(struct tun_struct *tun, struct tun_file *tfile, void *msg_control, struct iov_iter *from, int noblock, bool more) { struct tun_pi pi = { 0, cpu_to_be16(ETH_P_IP) }; struct sk_buff *skb; // ... if (!(tun->flags & IFF_NO_PI)) { if (len < sizeof(pi)) return -EINVAL; len -= sizeof(pi); if (!copy_from_iter_full(&pi, sizeof(pi), from)) return -EFAULT; } // ...(IFF_TAP 时校验 ETH_HLEN 等,省略)...环节tun 驱动SAI hostif (NETDEV)内核侧对象/dev/net/tun 字符设备标准 net_device发包进"网卡"写 char dev → tun_get_userndo_start_xmit → send_hostif_packet_fn收包出"网卡"tun_net_xmit → skb 进协议栈packet_event 回调 → 内核协议栈数据面另一端用户态进程读 fdSAI 数据面(硬件或 DPDK)方向正好相反:tun 的"网线那头"是用户态进程,hostif 的"网线那头"是交换机数据面。同一份 net_device_ops 机制,两端换了个位置。面试 3 问三种 hostif 类型的区别? → NETDEV 创建 Linux 网卡、FD 用文件描述符、GENETLINK 走 generic netlink(saihostif.h:848-859);学习期选 NETDEV 最快打通 FRR。BGP 报文怎么从数据面到 FRR? → trap 命中 → packet_event 回调(saihostif.h:1442)→ syncd 经 NETDEV 入内核协议栈 → bgpd socket。hostif 和 tun 是什么关系? → NETDEV 型 hostif 就是"SAI 实现的 tun":内核侧同一套 net_device_ops(tun.c:1251-1262 对照),驱动侧从"读写 fd"换成 SAI 收发函数。🔧 动手实验ip tuntap add dev bgp0 mode tap 建一个真 tun,跑 FRR 邻居建连,抓包看 TCP 179 的路径(先在内核侧建立直觉);在 sai-dpdk-mini 里实现 create_hostif + send_hostif_packet 的 NETDEV 桩,ip link 能看到这张"SAI 网卡";FRR zebra 把 10.0.0.0/24 路由注入,观察路由经 APPL_DB → ASIC_DB 最终变成 rte_lpm_add(衔接 P3-03/05);端到端验证:BGP 邻居建连 + 数据面转发 + ping 通。产出 perf-data/P3/06-hostif-frr.md(邻居建连日志 + route 注入链路截图 + 转发吞吐)。下一篇:P4-01 · PCIe Configuration Space 与 BAR 解码(Phase 4 学到时发布)
2026年08月18日
2 阅读
0 评论
0 点赞
2026-08-18
P4-01 · PCIe Configuration Space 与 BAR 解码:写全 1 读回来,才知道设备多大
对应路线图:Phase 4 (Day 120-140) | 内核 v6.6 | 代码出处:source/linux-stable/drivers/pci/,引用均标 文件:行号上一篇:P3-06 host_if | 下一篇:P4-02 MSI-X一句话定位Config Space 是设备与软件之间的"注册表":软件通过它知道设备是谁(Vendor/Device ID)、有什么能力(Capability 链)、占多大地址(BAR)。而 BAR 解码的玄机在于——BAR 寄存器同时承担"地址寄存器"和"尺寸寄存器"两个身份,内核用"写全 1 读回"让它现出原形。真实源码精读① 64 字节头部:偏移定义在 include/uapi/linux/pci_regs.h:38-125#define PCI_VENDOR_ID 0x00 /* 16 bits */ #define PCI_DEVICE_ID 0x02 /* 16 bits */ #define PCI_COMMAND 0x04 /* 16 bits */ // ...省略(STATUS 0x06、REVISION_ID 0x08、CLASS_DEVICE 0x0a ...) #define PCI_BASE_ADDRESS_0 0x10 /* 32 bits */ // ...省略(BASE_ADDRESS_1..5 依次 0x14/0x18/0x1c/0x20/0x24,pci_regs.h:96-100) #define PCI_CAPABILITY_LIST 0x34 /* Offset of first capability list entry */ #define PCI_INTERRUPT_LINE 0x3c /* 8 bits */ #define PCI_INTERRUPT_PIN 0x3d /* 8 bits */BAR 寄存器的位布局(pci_regs.h:101-110):bit0 区分空间(1=I/O, 0=Memory)、bit1-2 编码类型(PCI_BASE_ADDRESS_MEM_TYPE_64 = 0x04 表示 64 位 BAR 占两个寄存器)、bit3 是否 prefetchable,其余位才是地址。② 枚举第一步:读 Vendor/Device ID 判断设备是否存在(probe.c:2494-2517)static struct pci_dev *pci_scan_device(struct pci_bus *bus, int devfn) { struct pci_dev *dev; u32 l; if (!pci_bus_read_dev_vendor_id(bus, devfn, &l, 60*1000)) return NULL; dev = pci_alloc_dev(bus); if (!dev) return NULL; dev->devfn = devfn; dev->vendor = l & 0xffff; dev->device = (l >> 16) & 0xffff; if (pci_setup_device(dev)) { // ... return NULL; } // ... }一次 dword 读偏移 0x00 就完成"设备在不在"判断:全 0xffffffff 表示该 devfn 悬空。存在则 pci_setup_device(probe.c:1867)继续读 header type、class 等并解析 BAR。③ BAR 尺寸探测:写全 1 读回(probe.c:176-247,__pci_read_base 节选) mask = type ? PCI_ROM_ADDRESS_MASK : ~0; // ... pci_read_config_dword(dev, pos, &l); pci_write_config_dword(dev, pos, l | mask); pci_read_config_dword(dev, pos, &sz); pci_write_config_dword(dev, pos, l); /* * All bits set in sz means the device isn't working properly. * If the BAR isn't implemented, all bits must be 0. If it's a * memory BAR or a ROM, bit 0 must be clear; if it's an io BAR, bit * 1 must be clear. */ if (PCI_POSSIBLE_ERROR(sz)) sz = 0; // ... if (res->flags & IORESOURCE_MEM_64) { pci_read_config_dword(dev, pos + 4, &l); pci_write_config_dword(dev, pos + 4, ~0); pci_read_config_dword(dev, pos + 4, &sz); pci_write_config_dword(dev, pos + 4, l); // ... }四步协议:读原值 → 写全 1 → 读回 → 还原。读回值中低 N 位被硬件"钉死"为 0,N 就是尺寸的对数——设备应答 0xfff00000 说明它是 1MB 的 BAR。注意写全 1 前内核先关掉 decode(probe.c:189-193),防止探测期间把配置写当成正常访问。64 位 BAR 占两个寄存器,第二个寄存器在 pos + 4 重复一遍(probe.c:239-243)。④ 树形枚举:pci_scan_bus → pci_scan_slot → pci_device_addpci_scan_bus(probe.c:3308)挂上 ioport/iomem/busn 三类资源后创建根总线,调 pci_scan_child_bus 深度扫描。pci_scan_slot(probe.c:2745-2770)按 function 递增探测:fn 0 不存在且不在 hypervisor 透传环境就 break——Fn 0 是一个 slot 的"存在性锚点"。每个设备最终进 pci_device_add(probe.c:2609),挂到 bus->devices 链表(:2642)后由 driver core 触发匹配和 probe。🐧 内核机制对照:同一份 BAR 信息,两种消费方式环节内核(drivers/pci/)DPDK(drivers/bus/pci/)枚举入口pci_scan_bus probe.c:3308用户态读 /sys/bus/pci/devices/存在性判断读 offset 0x00 dword,probe.c:2499sysfs vendor/device 文件BAR 尺寸写全 1 读回,probe.c:198-201直接读 sysfs resource 文件BAR 映射ioremap(内核态地址)mmap,pci_common_uio.c:178-179空 BAR 跳过尺寸 0 则 fail,probe.c:253-254phys_addr == 0 continue,pci_common_uio.c:120-121本质区别:内核替所有驱动把 BAR 解析好、放进 pci_dev.resource[](pci.h:421);DPDK 在用户态只能拿到已分配好的物理地址,用 mmap 直接映射设备寄存器——这也是 DPDK 不写内核驱动就能操作网卡的前提。面试 3 问内核怎么测 BAR 尺寸? → 写全 1 读回(probe.c:198-201),读回值取反加一即尺寸;写之前先关 decode 防误访问(probe.c:189-193);64 位 BAR 在 pos+4 再做一遍(probe.c:239-243)。BAR 低 4 位为什么不是地址? → bit0 空间类型(pci_regs.h:101)、bit1-2 类型编码含 64 位标志(pci_regs.h:104-107)、bit3 prefetch(pci_regs.h:108),掩码 PCI_BASE_ADDRESS_MEM_MASK = ~0x0f(pci_regs.h:109)。扫描时如何避免误判"幽灵设备"? → 悬空 devfn 读回 0xffffffff,pci_scan_device 直接返回 NULL(probe.c:2499-2500);读回全 1 也用于识别坏设备(probe.c:203-210 注释)。🔧 动手实验用真实 NIC(如 ixgbe 网卡)做一次"手写 __pci_read_base":lspci -nn -v -s 01:00.0 # 记录 BAR0 地址与尺寸(如 64-bit, 4MB) sudo lspci -xxxx -s 01:00.0 | head # 前 64 字节逐字节对表解码 sudo setpci -s 01:00.0 0x10.L # 读 BAR0 原值 sudo setpci -s 01:00.0 0x10.L=0xffffffff sudo setpci -s 01:00.0 0x10.L # 读回:低 N 位为 0,验证尺寸 sudo setpci -s 01:00.0 0x10.L=<原值> # 必须还原!不还原设备可能失效同时 insmod code/dpu_dev/01_pci/pci_scan.ko 看内核侧 pci_resource_start(pdev, 0) 给出的 BAR0 值是否与 lspci 一致。数据落点 perf-data/P4/01-config-space.txt(lspci 原文 + 手工解码过程 + setpci 三次读数)。下一篇:P4-02 · MSI-X 中断机制:Table 在哪、怎么触发
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P4-02 · MSI-X 中断机制:Table 在哪、怎么触发,一个内存写就够了
对应路线图:Phase 4 (Day 120-140) | 内核 v6.6 | 代码出处 source/linux-stable/drivers/pci/msi/ | 上一篇:P4-01 Config Space 与 BAR | 下一篇:P4-03 最小 PCIe 驱动一句话定位MSI-X 把"中断线"变成了"内存写事务":设备想打断 CPU 时,往一个特殊地址写一个 16 位数据,根复合体(RC)把它翻译成中断投递。每个向量在 MSI-X Table 里有一张 16 字节的表项,存着自己的"地址+数据"。所以问题拆成两个:Table 在哪(Capability 链 → BAR 内偏移),谁写表项(内核用 ioremap 后的 MMIO 直接写)。真实源码精读① 表在哪:从 Capability 链到 BAR 内偏移(msi.c:561-580)static void __iomem *msix_map_region(struct pci_dev *dev, unsigned int nr_entries) { resource_size_t phys_addr; u32 table_offset; u8 bir; pci_read_config_dword(dev, dev->msix_cap + PCI_MSIX_TABLE, &table_offset); bir = (u8)(table_offset & PCI_MSIX_TABLE_BIR); // ...省略(pci_resource_flags 检查,:572-574) table_offset &= PCI_MSIX_TABLE_OFFSET; phys_addr = pci_resource_start(dev, bir) + table_offset; return ioremap(phys_addr, nr_entries * PCI_MSIX_ENTRY_SIZE); }找 Capability:内核沿 Capability 链表(起点 PCI_CAPABILITY_LIST,pci_regs.h:121)找到 ID 为 PCI_CAP_ID_MSIX = 0x11(pci_regs.h:226)的能力结构,缓存偏移到 dev->msix_cap(pci.h:359);读 Table dword:PCI_MSIX_TABLE(能力内偏移 4,pci_regs.h:327)低 3 位是 BIR(PCI_MSIX_TABLE_BIR,pci_regs.h:328),高 29 位是 BAR 内偏移(PCI_MSIX_TABLE_OFFSET,pci_regs.h:329);BAR 基址 + 偏移 → ioremap:pci_resource_start(dev, bir) 拿到 P4-01 里解析好的 BAR 物理地址,加偏移直接映射。MSI-X 表项数与 PCI_MSIX_FLAGS(pci_regs.h:323)里的 QSIZE 相关:msi.h:6 定义 msix_table_size(flags) = (flags & PCI_MSIX_FLAGS_QSIZE) + 1——QSIZE 存的是"个数减 1"。② 表项布局与触发:16 字节一张"名片"(pci_regs.h:336-342)/* MSI-X Table entry format (in memory mapped by a BAR) */ #define PCI_MSIX_ENTRY_SIZE 16 #define PCI_MSIX_ENTRY_LOWER_ADDR 0x0 /* Message Address */ #define PCI_MSIX_ENTRY_UPPER_ADDR 0x4 /* Message Upper Address */ #define PCI_MSIX_ENTRY_DATA 0x8 /* Message Data */ #define PCI_MSIX_ENTRY_VECTOR_CTRL 0xc /* Vector Control */ #define PCI_MSIX_ENTRY_CTRL_MASKBIT 0x00000001中断触发不需要任何"电平拉低":设备做一次 Memory Write TLP,写地址 = 表项里的 address、数据 = data,RC/IOMMU 拦截后路由到本地 APIC/ITS,最终给 CPU 一个向量号。INTx 是物理线共享,MSI-X 是每个向量独享一张名片——这就是多队列网卡每队列一个中断还互不打扰的原因。③ 内核怎么写表项:先 mask 再改(msi.c:205-227,pci_write_msg_msix 节选) /* * The specification mandates that the entry is masked * when the message is modified: * * "If software changes the Address or Data value of an * entry while the entry is unmasked, the result is * undefined." */ if (unmasked) pci_msix_write_vector_ctrl(desc, ctrl | PCI_MSIX_ENTRY_CTRL_MASKBIT); writel(msg->address_lo, base + PCI_MSIX_ENTRY_LOWER_ADDR); writel(msg->address_hi, base + PCI_MSIX_ENTRY_UPPER_ADDR); writel(msg->data, base + PCI_MSIX_ENTRY_DATA);写完 address/data 再解除 mask,并用一次读强制写可见(:231-232)。消息本身来自 msi_desc.msg(msi.h:193),由中断子系统(x86 上多为 irq_matrix / MSI domain)分配。④ 使能全流程:mask 一切,再逐步放开(msi.c:712-766,msix_capability_init 节选) /* * Some devices require MSI-X to be enabled before the MSI-X * registers can be accessed. Mask all the vectors to prevent * interrupts coming in before they're fully set up. */ pci_msix_clear_and_set_ctrl(dev, 0, PCI_MSIX_FLAGS_MASKALL | PCI_MSIX_FLAGS_ENABLE); // ...省略(msix_enabled=1、读 control、msix_table_size) dev->msix_base = msix_map_region(dev, tsize); // ... /* Disable INTX */ pci_intx_for_msi(dev, 0); // ...省略(Marvell 设备 quirk 注释) msix_mask_all(dev->msix_base, tsize); pci_msix_clear_and_set_ctrl(dev, PCI_MSIX_FLAGS_MASKALL, 0);顺序是防御性的:先置 MASKALL+ENABLE(防止设置期间漏进中断)→ map Table → 分配描述符 → 关 INTx(避免与 MSI-X 并存导致双份中断)→ mask 全部表项 → 解除 MASKALL。全部搞定后驱动才逐个 unmask 自己的向量。⑤ 驱动侧入口:MSI-X 优先的 fallback 链(api.c:254-298) if (flags & PCI_IRQ_MSIX) { nvecs = __pci_enable_msix_range(dev, NULL, min_vecs, max_vecs, affd, flags); if (nvecs > 0) return nvecs; } if (flags & PCI_IRQ_MSI) { nvecs = __pci_enable_msi_range(dev, min_vecs, max_vecs, affd); // ... } /* use legacy IRQ if allowed */ if (flags & PCI_IRQ_LEGACY) { if (min_vecs == 1 && dev->irq) { // ... pci_intx(dev, 1); return 1; } }驱动传 PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY(pci.h:1076-1078)时,有多少本事用多少:MSI-X 不够换 MSI,再不行退回共享的 INTx。成功之后用 pci_irq_vector(dev, nr)(api.c:313)把"设备侧向量号"翻译成 Linux IRQ 号传给 request_irq。🐧 内核机制对照:INTx vs MSI vs MSI-X维度INTx(PCI_INTERRUPT_PIN)MSIMSI-X载体物理线 INTA#-INTD#,多设备共享内存写 TLP内存写 TLP向量数1(共享)1/2/4/8/16/32最多 2048(QSIZE+1)每向量独立地址/数据无共享一份每表项一份内核路径传统 irq domainMSI domain(msi.c:270-279)Table ioremap(msi.c:579)亲和性由 APIC 路由每向量可绑核每向量可绑核对 DPDK 的意义:多队列网卡每队列一个 MSI-X 向量,是"每队列独立中断源"的硬件基础;PMD 不想要中断时把 mask 位一置即可。面试 3 问MSI-X Table 在物理内存的哪个位置? → Capability 链找到 PCI_CAP_ID_MSIX(pci_regs.h:226),读 PCI_MSIX_TABLE dword(pci_regs.h:327)拆出 BIR(pci_regs.h:328)与偏移(pci_regs.h:329),BAR 基址加偏移 ioremap(msi.c:569-579)。为什么改表项前必须 mask? → PCI 规范原文:unmasked 状态下改 Address/Data 结果 undefined,内核在 msi.c:213-222 引用并遵守。QSIZE 为什么加 1? → QSIZE 编码的是"表项数 - 1",msix_table_size 统一加 1(msi/msi.h:6),读表项数走 pci_msix_vec_count(api.c:71-80)。🔧 动手实验lspci -vvv -s 01:00.0 | grep -A5 MSI-X # 记下 "Capabilities: [70] MSI-X" 的能力偏移 sudo lspci -xxxx -s 01:00.0 | grep -A4 '^70:' # 手工解析 Table/BIR dword sudo setpci -s 01:00.0 0x72.L # Message Control: QSIZE / Enable / MaskAll 位对照任务:把 0x72.L 读出的 QSIZE 与 lspci -v 的 "Count=xx" 互验;流量打满时观察 /proc/interrupts 各队列分布是否均匀(RSS 生效则各队列计数接近)。数据落点 perf-data/P4/02-msix.txt。下一篇:P4-03 · 从 0 写一个最小 PCIe 内核驱动
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P4-03 · 从 0 写一个最小 PCIe 内核驱动:24 行 probe 一切,然后逐行升级
对应路线图:Phase 4 (Day 120-140) | 内核 v6.6 | 实验代码:code/dpu_dev/01_pci/,引用均标 文件:行号上一篇:P4-02 MSI-X | 下一篇:P5-01 DMA/IOMMU一句话定位PCIe 驱动的最小骨架不是操作寄存器,而是参与内核的"设备-驱动匹配协议":填一张 pci_device_id 表 + 一个 probe 回调,driver core 就会在设备出现时替你调它。code/dpu_dev/01_pci/pci_scan.c 全文件只有 24 行,却能 probe 整条总线上的所有设备——这是每个网卡驱动(ixgbe、mlx5)的同款起点。真实源码精读① 全文件带读:code/dpu_dev/01_pci/pci_scan.c:1-24#include <linux/module.h> #include <linux/pci.h> static int probe(struct pci_dev *pdev, const struct pci_device_id *id) { printk("pci_scan: %04x:%04x (rev %02x) BAR0=%llx IRQ=%d\n", pdev->vendor, pdev->device, pdev->revision, pci_resource_start(pdev, 0), pdev->irq); return 0; } static struct pci_device_id ids[] = { { PCI_ANY_ID, PCI_ANY_ID, PCI_ANY_ID, PCI_ANY_ID, 0, 0, 0 }, { 0, } }; MODULE_DEVICE_TABLE(pci, ids); static struct pci_driver drv = { .name = "pci_scan", .id_table = ids, .probe = probe, }; module_pci_driver(drv); MODULE_LICENSE("GPL");逐行拆:probe 回调(:4-10):签名 (struct pci_dev *, const struct pci_device_id *) 是 PCI 子系统的硬约定。这里读的 vendor/device/revision 正是 P4-01 里 pci_scan_device 从 Config Space 填进 pci_dev 的(probe.c:2507-2508);pci_resource_start(pdev, 0) 是 BAR0 物理地址(宏定义 pci.h:2107);pdev->irq 是内核分配的 IRQ 号(pci.h:420)。返回 0 = "这个设备我接管了"。ids 表(:12-16):全 PCI_ANY_ID 表示匹配所有设备——所以本模块会 probe 系统里每一个 PCI 设备,只能在学习机上玩。真正的驱动用 PCI_DEVICE(0x8086, 0x10fb) 这类精确匹配(宏在 pci.h:962)。表项结构在 mod_devicetable.h:44-50:vendor/device/subvendor/subdevice/class/class_mask 六维过滤 + driver_data 私有数据。module_pci_driver(drv)(:23):展开成 module_driver(drv, pci_register_driver, pci_unregister_driver)(pci.h:1598-1599),自动生成 init/exit,替换手写 module_init/module_exit 的样板代码。② 匹配与 probe 触发:struct pci_driver 与 driver core(pci.h:928-943)struct pci_driver { struct list_head node; const char *name; const struct pci_device_id *id_table; /* Must be non-NULL for probe to be called */ int (*probe)(struct pci_dev *dev, const struct pci_device_id *id); /* New device inserted */ void (*remove)(struct pci_dev *dev); /* Device removed (NULL if not a hot-plug capable driver) */ // ...省略 suspend/resume/shutdown/sriov/err_handler ... struct device_driver driver; // ... };id_table 注释说得很直白:没有它 probe 就不会被调用。注册入口 __pci_register_driver(pci-driver.c:1451-1467)只做一件事:填 drv->driver.bus = &pci_bus_type 然后 driver_register()——后面就是通用设备模型的 match → probe 机器了。设备侧由 pci_device_probe(pci-driver.c:444-467)接棒:static int pci_device_probe(struct device *dev) { int error; struct pci_dev *pci_dev = to_pci_dev(dev); struct pci_driver *drv = to_pci_driver(dev->driver); // ... pci_assign_irq(pci_dev); error = pcibios_alloc_irq(pci_dev); // ... pci_dev_get(pci_dev); error = __pci_device_probe(drv, pci_dev); // ... }注意顺序:先 pci_assign_irq 分配 IRQ,再进驱动的 probe——所以 probe 里 pdev->irq 一定已经有值。③ remove 与卸载:内核兜底(pci-driver.c:469-510 节选)static void pci_device_remove(struct device *dev) { struct pci_dev *pci_dev = to_pci_dev(dev); struct pci_driver *drv = pci_dev->driver; if (drv->remove) { // ... runtime PM barrier ... drv->remove(pci_dev); // ... } pcibios_free_irq(pci_dev); pci_dev->driver = NULL; // ... }drv->remove 是驱动自己的清理窗口(放走 IRQ、unmap、还资源);窗口之外的事内核兜底:pcibios_free_irq 收尾 IRQ(:486)、解绑 driver = NULL(:487)。rmmod 会经 pci_unregister_driver(pci-driver.c:1480)对每个被该驱动接管的设备各调一次 remove。④ 升级路线:把"只读"变成"拥有"pci_scan.c 只读不占,等于借设备看了一眼。要真正"拥有"一个 BAR(比如网卡的 MMIO 寄存器区),probe 里按序做四件事(API 均已核实存在):步骤API位置作用1pci_enable_device(pdev)pci.c:2191打开 IO/MEM decode,唤醒设备2pci_request_regions(pdev, name)pci.c:4273声明 resource 所有权(/proc/iomem 可见)3pci_ioremap_bar(pdev, bar)pci.c:252映射 BAR 到内核虚拟地址4request_irq(pci_irq_vector(pdev, 0), handler, ...)interrupt.h:165挂中断(配合 P4-02 的 MSI-X)remove 里严格逆序:free_irq(interrupt.h:196)→ iounmap → pci_release_regions(pci.c:4254)→ pci_disable_device(pci.c:2379)。🐧 内核机制对照:驱动协议 vs 用户态"野路子"环节内核驱动(本模块)DPDK 用户态(bus/pci)设备发现driver core 匹配 id_table → probe自己扫 /sys/bus/pci/devices/"接管"动作probe 返回 0,设备归属本驱动bind 到 vfio-pci/uio 驱动后拿 fdBAR 访问ioremap → 内核虚拟地址mmap,pci_common_uio.c:178-179中断request_irq + 上半部/NAPI轮询或 epoll 中断线程资源冲突防护pci_request_regions 互斥依赖内核驱动让位(bound to VFIO)DPDK 不是"没有驱动协议",而是把协议放进了用户态:内核里那个 vfio-pci 驱动替它完成了 probe/接管,用户态只负责 mmap 和轮询。面试 3 问probe 什么时候被调用? → 设备注册或驱动注册后 driver core 完成 match 即调;前提是 id_table 非 NULL(pci.h:931 注释);入口 pci_device_probe(pci-driver.c:444)先 pci_assign_irq 再调驱动的 probe(pci-driver.c:453-460)。PCI_ANY_ID 表项的风险? → 匹配所有设备(pci_scan.c:12-13),probe 返回 0 会接管每一个设备(pci_scan.c:9),真实驱动必须用 PCI_DEVICE 精确匹配(pci.h:962-964)。remove 里不释放资源会怎样? → IRQ 方面内核会兜底 pcibios_free_irq(pci-driver.c:486),但 ioremap 的地址和 request_regions 占用的 resource 泄漏,再加载驱动会 EBUSY——所以 remove 必须逆序归还(见上表)。🔧 动手实验cd code/dpu_dev/01_pci make # Makefile:1-2,obj-m += pci_scan.o sudo insmod pci_scan.ko dmesg | tail -30 # 每个 PCI 设备一行:vendor:device rev BAR0 IRQ sudo rmmod pci_scan升级练习(在实验机上,不要在主力机上):给 probe 加 pci_enable_device + pci_request_regions,验证 /proc/iomem 里出现 pci_scan 名字段;再试 pci_ioremap_bar 读 4 字节网卡寄存器与 lspci -xxxx 对拍。数据落点 perf-data/P4/03-pci-scan-dmesg.txt(dmesg 原文 + iomem 段 + 升级版对比)。下一篇:P5-01 · DMA/IOVA/IOMMU:设备怎么看到内存
2026年08月18日
0 阅读
0 评论
0 点赞
2026-08-18
P5-01 · DMA/IOVA/IOMMU:设备看到的地址,从来不是你 malloc 出的那个
对应路线图:Phase 5 (Day 141-168) | DPDK 24.11.6 LTS | 内核对照 v6.6代码出处:source/linux-stable/drivers/iommu/dma-iommu.c、source/dpdk-stable-24.11.6/lib/eal/linux/eal.c,引用均标 文件:行号上一篇:P4-03 最小 PCIe 驱动 | 下一篇:P5-02 VFIO一句话定位CPU 用页表把 VA 变成 PA,设备做 DMA 时也要有一套自己的"页表":IOMMU 页表。IOVA 就是设备视角的虚拟地址——没有 IOMMU 时 IOVA 直接等于 PA,有 IOMMU 时内核替你维护 IOVA→PA 的映射。DPDK 的 mempool 写进网卡描述符的"物理地址",本质都是 IOVA。真实源码精读① 内核的 DMA 抽象:每设备一张 dma_map_ops 虚表(dma-iommu.c:1613-1636)static const struct dma_map_ops iommu_dma_ops = { .flags = DMA_F_PCI_P2PDMA_SUPPORTED, .alloc = iommu_dma_alloc, .free = iommu_dma_free, .alloc_pages = dma_common_alloc_pages, .free_pages = dma_common_free_pages, // ...省略 alloc_noncontiguous / mmap / get_sgtable ... .map_page = iommu_dma_map_page, // :1623 .unmap_page = iommu_dma_unmap_page, .map_sg = iommu_dma_map_sg, // :1625 .unmap_sg = iommu_dma_unmap_sg, // ...省略 sync_* / map_resource ... };驱动代码从来不直接调 iommu_map,而是调 dma_map_sg() 宏 → 落到 dev->dma_ops->map_sg。IOMMU 打开时指向这张表,关掉时指向直通实现——上层驱动一行不用改,这就是内核 DMA-API 分层的意义。② iommu_dma_map_sg:先算总长度,再一次性分配 IOVA(dma-iommu.c:1332-1349) if (!iova_len) return __finalise_sg(dev, sg, nents, 0); iova = iommu_dma_alloc_iova(domain, iova_len, dma_get_mask(dev), dev); if (!iova) { ret = -ENOMEM; goto out_restore_sg; } /* * We'll leave any physical concatenation to the IOMMU driver's * implementation - it knows better than we do. */ ret = iommu_map_sg(domain, iova, sg, nents, prot, GFP_ATOMIC); if (ret < 0 || ret < iova_len) goto out_free_iova; return __finalise_sg(dev, sg, nents, iova);三步走,跟我 P0 学的内存池一个思路——先要一块连续的 IOVA 空间,再把 sg 里散落的物理页逐段映射进去。__finalise_sg(:1349)把算好的 IOVA 写回每个 sg_dma_address,驱动接下来只跟 IOVA 打交道。函数签名在 :1241-1242,开头还要处理 sg 段对齐(:1275 起 for_each_sg 的 pad 逻辑)。③ 挂载点:IOMMU 可用时偷换 dev->dma_ops(dma-iommu.c:1642-1663)void iommu_setup_dma_ops(struct device *dev, u64 dma_base, u64 dma_limit) { struct iommu_domain *domain = iommu_get_domain_for_dev(dev); if (!domain) goto out_err; // ...省略注释... if (iommu_is_dma_domain(domain)) { if (iommu_dma_init_domain(domain, dma_base, dma_limit, dev)) goto out_err; dev->dma_ops = &iommu_dma_ops; // :1656 关键一行 } return; out_err: pr_warn("Failed to set up IOMMU for device %s; retaining platform DMA ops\n", dev_name(dev)); }设备注册时调用一次,之后所有 DMA API 全部改道 IOMMU 路径。注意 :1661 的失败路径:IOMMU 初始化失败就保留 platform DMA ops(直通 PA)——系统不崩,只是没有隔离,这种"静默降级"排查问题时要想到。④ DPDK 侧:启动时三态选择 IOVA 模式(lib/eal/linux/eal.c:1038-1066) /* if no EAL option "--iova-mode=<pa|va>", use bus IOVA scheme */ if (internal_conf->iova_mode == RTE_IOVA_DC) { /* autodetect the IOVA mapping mode */ enum rte_iova_mode iova_mode = bus_iova_mode; if (iova_mode == RTE_IOVA_DC) { // ...省略 build option / phys addrs 判断 ... } else if (is_iommu_enabled()) { /* we have an IOMMU, pick IOVA as VA mode */ iova_mode = RTE_IOVA_VA; // :1054-1057 } else { /* physical addresses available, and no IOMMU * found, so pick IOVA as PA. */ iova_mode = RTE_IOVA_PA; // :1058-1063 } } rte_eal_get_configuration()->iova_mode = iova_mode;IOVA=VA 是默认最优解:有 IOMMU 就选 VA(mempool 里存的是 mbuf 虚拟地址,IOMMU 页表替设备翻译);没有 IOMMU 才退回 PA。所以 DPDK 起不来的常见报错之一就是 PA 模式 + 物理地址不可得(eal.c:1072-1076 的检查)。🐧 内核对照:同一件事,谁来做翻译维度内核驱动DPDK抽象层DMA-API(dma_map_ops 虚表,dma-iommu.c:1613)IOVA 全局三态(PA/VA/DC),无 per-device 虚表选择时机设备注册时挂表(:1656)EAL 启动时一次选定(eal.c:1066)映射粒度sg 段级,按需 map/unmapmempool 大块预映射,mbuf 只记 IOVA 偏移失败语义降级直通 + pr_warn(:1661)启动即报错退出(:1072-1076)内核把 DMA 当"每次 I/O 都建立的一段契约",DPDK 把 IOVA 当"一次启动定死的世界设定"。性能取向不同:内核要通用和隔离,DPDK 要热路径零开销。面试 3 问IOVA 和 PA、VA 什么关系? → 有 IOMMU 时 IOVA 是设备视角的虚拟地址,由 IOMMU 页表映射到 PA(iommu_map_sg 建表,dma-iommu.c:1345);无 IOMMU 时 IOVA 就是 PA。VA 是 CPU 视角的虚拟地址,和 IOVA 是两套体系。内核怎么决定走不走 IOMMU? → 设备注册时 iommu_setup_dma_ops 检查默认域,可用就把 dev->dma_ops 换成 iommu_dma_ops(:1656),失败则保留直通 ops 并打 warning(:1661)。DPDK 的 --iova-mode 为什么默认选 VA? → 有 IOMMU 时选 VA 可让 mbuf 直接存虚拟地址、避免 PA 碎片问题(eal.c:1054-1057);没有 IOMMU 且能读 /proc/self/pagemap 才退 PA(:1058-1063)。🔧 动手实验J3455 支持 VT-d,做一次"开关 IOMMU"的对照实验:# 1. 默认开启:dmesg 确认 IOMMU 生效 dmesg | grep -i -E "iommu|dmar" | head # 期望看到 DMAR: IOMMU enabled / dmar irq 等字样 # 2. 跑 testpmd,看 EAL 启动日志的 IOVA 选择 ./testpmd -c 0x3 -n 2 -- -i # 期望日志:"Selected IOVA mode 'VA'" # 3. 重启,Grub 内核参数加 intel_iommu=off(关掉 IOMMU) # 再跑 testpmd,日志应变为 "Selected IOVA mode 'PA'" # 4. 两种模式下各跑 5 分钟 rxonly,记录 PPS/CPU数据落点:perf-data/P5/01-iommu-mode.md(两种模式的 EAL 日志、dmesg 证据、PPS 对比)。预期:小包转发下 VA/PA 吞吐基本持平(IOMMU 页表走 cache),但 VA 模式是生产部署的默认答案。下一篇:P5-02 · VFIO 源码深读:把设备安全地交给用户态
2026年08月18日
0 阅读
0 评论
0 点赞
1
2
3
DPDK 成长路线
· 第 1/3 页 · 本页 15 条 · 共 39 条