☰
QEMU+GDB单步解析Linux内核水位线与kswapd唤醒
2026/10/27 3:04:47 网站建设 项目流程

1. 先把问题摆上桌:水位线为什么要靠单步来论证

聊单步调试Linux内核这件事,很多人第一反应是"内核那么大,单步跑一遍不现实"。这话对一半。真要在内核里定位一个具体数值为什么是这个值,单步调试反而是最省时间的手段,尤其是watermark(水位线)这类"平时看不见、出事全怪它"的参数。我在几个内存相关的项目里反复踩过这个坑:系统报 OOM,日志里连个像样的线索都没有,/proc/zoneinfo打印出来的min/low/high三个数字看起来也挺合理,但你问一句"这个 low 为什么正好比 min 多四分之一",大多数人答不上来。答不上来就意味着你不敢动它,只能靠猜着改min_free_kbytes,改完好了算运气,改坏了更懵。

这篇内容就是围绕这一件事展开:用 QEMU + GDB 把内核挂起来,在zone_watermark_fast、__zone_watermark_ok、__setup_per_zone_wmarks这几个点位停下来,单步走进去,把zone->_watermark[]里的三个数字是怎么算出来的、在分配路径上又是怎么被消费掉的,一条指令一条指令地看清楚。适合谁看?写过内核模块但没正经用过 GDB 调内核的人、做内存压测和性能调优的人、被kswapd反复唤醒搞得 CPU 占用居高不下又找不到根因的人。你不需要是 MM 子系统的维护者,但至少得能看懂 C 结构体和基本的 x86 汇编,知道页框(page frame)和 zone 是什么关系。

我先把结论式的定位说清楚,免得后面绕:watermark不是"剩多少内存就该回收"这么一句口号,它是三档阈值,分别对应"正常分配可以继续"、"该叫醒 kswapd 干活了"、"分配已经很难看了要进慢路径"。三档之间的比例关系来自min_free_kbytes的一份固定换算,而min_free_kbytes本身又是由物理内存总量开平方推出来的。这条链路一共四五个函数,光靠读源码很容易"看懂了但不确定",单步一遍就有了实感——你会亲眼看到min被>> 2和>> 1加上去,看到lowmem_reserve在比较时被减掉,看到ALLOC_HARDER把 mark 临时砍掉四分之一。这种"看着它算"的确定性,是读代码给不了的。

1.1 水位线在分配热路径里到底管什么

把内存分配想成一个水库调度问题会好理解很多。水位线不是水库的容量,而是三条警戒线:低于最高警戒线,抽水正常进行;跌破中间那条,得赶紧启动补水(唤醒kswapd);跌破最低那条,说明补水已经跟不上,抽水方(申请内存的进程)要么自己动手回收,要么被挂起等,要么直接失败。三条线之间的距离不是随便定的,它决定了系统在压力上来时有多少缓冲时间去反应,也决定了kswapd会不会被反复惊醒然后又睡下——也就是那种 CPU 看着不高但pgscan_kswapd一直在涨的磨人状态。

具体的判断发生在get_page_from_freelist()里,对每个候选 zone 调一次zone_watermark_fast()。这个函数做两件事:一是快路径,针对 order 0 的小分配,直接拿可用页框数和mark + lowmem_reserve比一下,比得过就直接给页,连free_area都不看;二是慢一点的路,转到__zone_watermark_ok(),那里会把当前阶以上的空闲块按阶累加,判断"扣掉这些大块之后,剩下的零碎页够不够撑住水位"。这个"逐阶扣减"是很多人忽略的点:你以为空闲页有 200MB 很宽裕,但如果这 200MB 全挂在 order 10 的大块上,而你要的是一个 order 0 的页,判断逻辑反而可能放行;反过来,一堆 order 0 的碎片散页看着总数不少,做大页分配时会因为扣不动而被水位卡住。

所以水位线本质上不是"剩余内存量"的度量,而是"剩余可分配性"的保守估计。这也解释了为什么调min_free_kbytes有时对 OOM 有用、有时完全没感觉——如果失败原因是高阶碎片,你把水位抬高一倍只是让系统更早开始回收,反而加重了扫描负担,真正该动的是碎片整理相关的策略。要分辨这两种情况,就得知道判定那一刻 mark 到底是多少、free_pages 到底是多少、是哪个 flag 让判定通过的。这些数字在跑着的系统上抓不到,在单步调试里全都在寄存器旁边摆着。

1.2 四种观测手段的横向对比与取舍

在动手之前我一般会先评估用哪种工具,因为内核调试最贵的不是操作,是等待。下面这张表是我自己这几年的取舍依据,不同场景选错了工具,浪费时间是以小时计的。

手段能看到什么代价与局限适用场景
printk/pr_info变量瞬时值、调用次数要重编译重启;热路径加打印会改变时序,甚至引入延迟抖动确认某个分支有没有走到、粗粒度计数
ftrace+ kprobe函数入参出参、耗时分布不改代码,但拿不到函数内部中间变量;kprobe 在高频函数上有可观开销观察调用频率、确认参数范围
crash//proc/kcore崩溃瞬间的完整内存快照只能事后看,且要有 vmcore 或允许读取内核内存已经崩溃、需要复盘现场
GDB + QEMU 单步每条指令的寄存器、任意结构体成员、函数内局部变量需要虚拟机环境,不能直接用在生产机上;单步会极大放慢执行论证算法、验证公式、确认分支走向
/proc/zoneinfo/proc/vmstat已经算好的结果只有结果没有过程,无法回答"为什么"日常巡检、趋势观察

看这张表就能明白,watermark的论证属于典型的"要知道过程"的需求,前三种都不够。printk能把zone->_watermark[0..2]打出来,但你打不出"这个值是哪一行加出来的",也不知道lowmem_reserve在比较时具体参与了什么运算。crash能看现场,可如果系统没崩,你手上就没有 vmcore。剩下就是 GDB。代价是慢,慢到你在zone_watermark_fast上设个断点,开机后可能几秒钟就命中几百次——所以实战里不是全程单步,而是"断点定位 + 局部单步 + 条件断点过滤"的组合拳,这块后面会细说。

还有一点需要提醒:上面这些手段在虚拟机里可以随便折腾,在生产环境上动内核调试接口是有风险的,很多发行版默认关掉了CONFIG_KPROBES的部分能力,/dev/mem也受限。我的习惯是本地 QEMU 里论证清楚,再把结论写成脚本或可观测指标搬到真实环境验证,绝不在线上开单步。

1.3 这次调试要回答的三个具体问题

目标定得越具体,单步越省时间。我给自己列了三条,后来发现这三条基本覆盖了绝大多数和水位线有关的疑惑。

第一,min/low/high三个值之间的比例是不是我记的那样。源码里全是位移运算,min + (min >> 2)这种写法一眼看过去像 1.25 倍,但它到底作用在哪个变量上、是不是所有 zone 都一样、min为 0 时会不会有特判,只有停下来看才踏实。我印象里早期版本和 6.x 的写法差别不小,特别是引入了按 zone 分摊min_free_kbytes之后,小内存 zone 分到的min可能是 0,这时候三个水位全是 0,判定逻辑就完全依赖lowmem_reserve兜底了。

第二,分配路径上到底用哪一档。很多人以为分配时查的是WMARK_MIN,其实gfp_to_alloc_flags()给的初始值是ALLOC_WMARK_MIN,但__alloc_pages_slowpath()里会随着重试逐步降级,ALLOC_HARDER一上,mark 直接砍掉四分之一。搞清楚这个降级顺序,才能理解为什么"内存已经很紧张了还在分配成功"——那不是水位失效,是有人在用特权通道。

第三,kswapd的唤醒判定为什么用WMARK_HIGH。这一点最容易被忽略:分配走MIN,唤醒走HIGH,中间隔着一个LOW的距离就是留给回收线程的反应窗口。窗口开多大合适,完全取决于watermark_scale_factor和min_free_kbytes的组合,这也是后面调优章节的落点。

2. 水位线的生成逻辑:从 min_free_kbytes 到三个数字

2.1__setup_per_zone_wmarks里的换算过程

水位线不是在编译期定死的常量,它在系统启动时通过__setup_per_zone_wmarks()算一次,之后每次有人改min_free_kbytes或内存热插拔时再重算。这个函数的输入只有一个源头参数min_free_kbytes(单位 KB),输出是每个 zone 的_watermark[3]。核心思路是:把全局的min_free_kbytes按各 zone 的managed_pages占比分摊下去,再对每个 zone 的份额做 1.25 倍和 1.5 倍的放大。

分摊这一步是新手最容易看漏的。以前(大概 5.x 及更早的很多版本)的写法是把min_free_kbytes除以"所有 zone 的总页数"得到一个比例,再乘每个 zone 的页数,四舍五入之后还得保证最小值不为 0,否则某些小 zone 会拿到 0 页水位,等于没有保护。较新的版本改成了按zone_managed_pages循环摊派、把余数一路带下去的做法,好处是总量守恒、不会因为取整误差让总和偏离设定值。你在自己那棵树上看到的函数体可能和我描述的不完全一致,这不重要,重要的是抓住两个不变量:所有 zone 的WMARK_MIN之和应当约等于min_free_kbytes换算出来的页数,以及LOW = MIN × 5/4、HIGH = MIN × 3/2(这个比例在绝大多数版本里没变过)。

在 GDB 里验证这两个不变量非常直观。断在函数返回前,把pgdat->node_zones数组遍历一遍,逐个打印zone->name、zone->managed_pages、zone->_watermark[0..2],然后心算一下比例。如果WMARK_MIN是 0,你会看到LOW和HIGH也都是 0,这就是那个特判分支——源码里通常写成if (min == 0) { ... } else { ... }的形式,不去看很容易误以为除法保护没做好。我第一次撞到时也愣了几秒,后来想明白了:小 zone 上设置 0 水位不是 bug,因为真正拦住分配的还有lowmem_reserve,那是另一层保护。

比例关系的意义值得多说两句。为什么是 1.25 和 1.5,而不是 1.1 和 1.2?因为这中间的距离决定了回收的"缓冲深度"。kswapd被唤醒后需要时间扫描 LRU、回写脏页,如果你的HIGH - LOW间隔只有几个页,回收线程还没干出成绩,分配端就已经跌破LOW甚至MIN了,结果就是直接进同步回收,进程卡顿。间隔太大又浪费内存,等于凭空少了一大块可用容量。1.25 和 1.5 是长期折衷出来的经验值,改比例这事在主线代码里基本没发生过,可调的是基准值min_free_kbytes和缩放因子,不是比例本身。

2.2 新旧内核里watermark_scale_factor的量纲陷阱

min_free_kbytes管的是"绝对底线",watermark_scale_factor管的是"相对余量"。后者的作用是让水位随着 zone 大小自适应:内存越大的机器,光靠min_free_kbytes那点固定值根本不够缓冲,需要一个百分比来放大。这个参数在/proc/sys/vm/watermark_scale_factor里,默认值通常是 10。

这里有个特别容易翻车的点:10 这个数字在不同内核版本里代表的百分比不一样。早期实现里它的单位是千分之一,10 就是 1%;较新的内核改成了万分之一,10 就变成 0.1%。同一个数字,含义差十倍。我见过有人照着一篇老文章把值从 10 调到 200,以为是在设 20%,实际在新内核上只相当于 2%,调完发现没效果还以为是参数不生效。所以调之前必须先确认版本语义,最稳的办法是直接去看__setup_per_zone_wmarks()里那个除法或乘法用的常数是多少,或者看Documentation/admin-guide/sysctl/vm.rst对应版本的描述。别信记忆,也别信二手资料。

单步调试在这里的价值就很具体了:断在计算wmark_pages那一行,看看min和watermark_scale_factor是怎么参与运算的,除数是 1000 还是 10000 一目了然。比起翻文档猜版本,直接看指令靠谱得多。而且你能顺带看到另一个细节:缩放因子算出来的份额是按managed_pages比例摊的,不是简单加在全局值上,所以 NUMA 机器上各个节点的 zone 拿到的是不同的绝对增量。

还有一个衍生参数watermark_boost_factor,它控制的是碎片化规避时的临时抬高水位。这个机制出现得比较晚,思路是:当系统检测到某类可迁移类型(migratetype)的页块被严重碎片化时,临时把水位抬高一截,逼着回收和规整更早介入,避免后面高阶分配接连失败。它和watermark_scale_factor是两个独立旋钮,不要混着调。我在一次压测里就是因为只盯着 scale_factor,没注意 boost 把水位临时抬了将近一倍,导致kswapd提前唤醒、扫描量虚高,排查了大半天才想起来看/proc/vmstat里的 boost 相关计数。

2.3lowmem_reserve与highest_zoneidx的叠加放大

水位判断不是只比free_pages和mark。__zone_watermark_ok()的签名里有个highest_zoneidx,它决定了要检查多少层保留区。逻辑是这样的:内核为了防止低端 zone(比如 DMA 区)被普通分配吃光,给每个 zone 设了一组lowmem_reserve[],含义是"从我这个 zone 里分配时,要给比我更低的每一个 zone 预留多少页"。判断时从highest_zoneidx开始往回走,逐个减掉对应的保留量,减到不够就直接判定失败。

这层机制在小内存嵌入式设备上尤其重要。举个我实际遇到的场景:某台设备物理内存不大,DMA 区就那么点页,跑一段时间后普通分配疯狂往里挤,结果网卡驱动申请 DMA 缓冲时反复失败,日志里全是分配错误但free看着还有富余。原因就是lowmem_reserve设得太宽松(或者说lowmem_reserve_ratio的默认值对这个内存规模不合适),保护没起作用。把highest_zoneidx和lowmem_reserve[]在 GDB 里打出来对比一下就清楚了:普通分配调用时传的highest_zoneidx是ZONE_NORMAL,它要检查对 DMA 和 DMA32 的保留,两个保留值加起来就吃掉了相当一部分空闲页。

计算lowmem_reserve的函数是setup_per_zone_lowmem_reserve(),由lowmem_reserve_ratio驱动。默认值通常是个三元组,管理页数越大的 zone 承担越多保护责任。这套逻辑和watermark是正交的两层防护,但它们在同一个判断函数里叠加生效,所以你在单步时会看到mark之外还有一个减项。很多人调水位调不出效果,就是因为瓶颈其实卡在lowmem_reserve上——把mark降下去,保留量那关还是过不了。

我在实操里的一个习惯是:进入__zone_watermark_ok()后先不看循环,先把入参mark、order、highest_zoneidx和free_pages四个值抄下来,再单步走循环,一步一个o,记下每阶扣了多少。这样一轮下来,你对"这块空闲内存到底算不算够用"就有了完全确定的判断,而不是靠/proc/zoneinfo里的汇总数字脑补。

3. 水位线的消费逻辑:分配路径上谁在查、怎么查

3.1zone_watermark_fast的快路径与慢路径

zone_watermark_fast()是分配热路径上的第一道闸门,名字里的 fast 指的是它对 order 0 有特殊优化。代码骨架大致是这样:先读zone_page_state(zone, NR_FREE_PAGES)拿到总空闲页,如果 order 是 0,再减掉 CMA 占用的页数,然后直接和mark + lowmem_reserve比较,成立就返回 true。注意这里比的是>不是>=,边界条件差一页,这种细节只有盯着代码或单步看才不会记错。

order 不为 0 时就退到__zone_watermark_ok()。这里有个设计意图值得说:order 0 是最常见的分配,占了绝大多数比例,给它一条最短路径能省下可观的指令数;而高阶分配本身就要扫free_area,多花点时间无所谓。这种"按频率分层"的思路在内核里到处都是,看懂了这一处,别的地方也能类推。

单步调试这块有个很实际的坑:zone_watermark_fast()在头文件里是static inline的,GDB 对它设断点时会展开成很多个位置(每个调用点一份),info breakpoints会列出七八个 location。你要么全部启用接受高频中断,要么挑一个具体地址禁用其余。我一般是先用break __zone_watermark_ok这种非 inline 的实函数把范围收窄,确认问题后再回到 inline 函数上做精细观察。这个操作后面在排查章节还会提,因为它是最常见的"断点设了但感觉没生效"的成因。

还有个观测技巧:在快路径里不必每次命中都停。给它加条件,比如只在order == 0 && mark > 1000时断,或者用 GDB 的commands把命中时的关键值静默打印后自动继续,攒够样本再回看。这样既拿到了数据,又不至于被中断淹没。

3.2__zone_watermark_ok里的逐阶扣减

这个函数是水位判定真正的主体,逻辑不复杂但容易看错。它做的事是:从请求的order开始,逐阶往上,把每一阶的空闲块数乘以2^order累加,同时每一步都检查当前累计值够不够 mark,不够就立刻返回 false。换句话说,它不是"把所有空闲页加起来总量够了就放行",而是"从最小的可用块开始往上拼,拼到某一阶发现水位撑不住了,就判定这块大内存不能动"。

为什么要这么保守?因为高阶空闲块是稀缺资源,如果为了满足一次 order 0 的分配就允许把 order 9 的大块拆开,系统会逐渐失去做大页分配的能力,最后连一个新进程的栈都分配不出来。逐阶扣减相当于给大块加了保护:只有当低阶碎片本身足够撑住水位时,高阶层才被视为"可以动用的储备"。这个思路和水库调度里"死库容不能用来发电"是一个道理。

在 GDB 里观察这段循环特别有画面感。你可以p zone->free_area看每阶的nr_free,然后单步走循环体,每轮打印一次o、free_pages、mark,看着free_pages一层层往下减。我第一次这么走完一遍才真正理解为什么"空闲内存很多但分配还是失败"——因为减到最后发现,扣掉必须保留的部分之后,剩余量连请求的阶都不够。这种失败在/proc/buddyinfo里能看出端倪(某一阶以上全是 0),但看不到水位判定是怎么参与其中的。

3.3ALLOC_HIGH与ALLOC_HARDER对 mark 的临时下调

分配标志里有两个位会直接影响判定:ALLOC_HIGH把 mark 砍一半,ALLOC_HARDER砍四分之一,两个都有就先减半再减四分之一(顺序在源码里有明确写法,别凭感觉推)。这两个位不是给普通分配用的,它们代表"这次分配比水位更重要",典型场景是内存规整内部自己需要分配页、或者进程处在不可中断的紧急状态。

这解释了一个长期困扰很多人的现象:明明已经低于WMARK_MIN了,某些分配依然成功。不是水位失效,是有人走了特权通道。你在单步时如果发现mark比/proc/zoneinfo里打出来的min小一截,八成就是这两个标志在起作用。追一下gfp_to_alloc_flags()的调用栈就能确认是谁设的——实时进程(rt_task)在非中断上下文里会自动带ALLOC_HARDER,这一点在压测时经常让数据看起来"不符合预期"。

我个人的经验是:分析压测结果时,先把带ALLOC_HARDER的样本单独拎出来看,否则结论会被污染。具体做法是在__zone_watermark_ok的断点上加条件alloc_flags & ALLOC_HARDER,分别统计两类样本的判定通过率,对比之后才知道水位本身是不是真的不够。

3.4 kswapd 唤醒阈值为什么选WMARK_HIGH

wakeup_kswapd()的触发条件用的是WMARK_HIGH,比分配用的MIN高出整整一档。这个选择经常被问"是不是太激进了"。我的理解是:kswapd 是异步回收,它从被唤醒到真正把水位拉回来之间有延迟,中间还要经历唤醒调度、扫描 LRU、回写脏页几个阶段。如果等到水位跌破LOW甚至MIN才叫它,分配端早就进同步回收路径了,进程延迟直接爆炸。用HIGH作为唤醒线,等于给异步回收留出整个HIGH - MIN的时间窗口,让它有机会在问题变严重之前就把水补上。

代价是唤醒可能偏早、扫描可能偏多。这就引出了调优里的核心矛盾:唤醒线抬高(通过加大min_free_kbytes或watermark_scale_factor)能减少同步回收,但会增加后台扫描的 CPU 消耗和 IO。反过来的取舍也成立。没有普适最优,只有和你负载特征匹配的取值。判断依据一般是/proc/vmstat里的pgscan_kswapd、pgsteal_kswapd、allocstall三个计数:allocstall涨说明同步回收被触发了,水位可能偏低;pgscan_kswapd高但pgsteal低说明扫描效率差,白白烧 CPU,可能是水位偏高或者活跃/非活跃比例失衡。

4. 单步调试环境的搭建与实操

4.1 内核编译配置的取舍

环境这块我不建议用发行版现成的内核镜像,符号不全、优化级别也不可控。自己编一个最省事,配置上抓住几个关键项就够了。CONFIG_DEBUG_INFO=y是必须的,没有 DWARF 信息 GDB 里连结构体成员名都看不到,全是地址偏移。CONFIG_DEBUG_INFO_DWARF4=y建议显式打开,因为较新编译器默认输出 DWARF5,而一些发行版自带的 GDB 版本对 DWARF5 支持不完整,表现为能打断点但打不出局部变量。CONFIG_GDB_SCRIPTS=y也打开,它提供一套内核专用脚本,lx-symbols系列命令能省不少事。CONFIG_DEBUG_KERNEL=y和CONFIG_KALLSYMS_ALL=y顺手开了,后者保证静态符号也进符号表。

编译优化级别是个需要权衡的点。-O2是默认值,好处是行为接近生产,坏处是局部变量会被优化掉、函数会被 inline、指令顺序会和源码对不上。较新内核提供了CONFIG_CC_OPTIMIZE_FOR_DEBUGGING,可以降到-Og,单步体验好很多,但你要清楚这时候观察到的时序和优化后的版本不完全一致。我的做法是分两轮:第一轮用-Og把算法和数值关系论证清楚,第二轮换回-O2验证同样的结论在生产配置下是否成立。如果第二轮发现变量看不见了,就退回用寄存器或者直接读结构体内存来绕过。

还有一个必须关掉的东西:CONFIG_RANDOMIZE_BASE(KASLR)。开了它每次启动内核加载地址都变,断点位置跟着漂,单步时稍微跳一下就对不上了。可以在配置里关掉,也可以在启动参数里加nokaslr,后者更灵活,改起来不用重编。

4.2 QEMU 启动参数与 GDB 接入

启动虚拟机的命令我一般写成脚本,参数里几个位置很关键。-s等价于-gdb tcp::1234,开 GDB stub;-S表示启动后先暂停等调试器连上;-nographic配合内核命令行console=ttyS0让串口输出直接打到终端,这样 GDB 在另一个窗口跑也不会抢控制台。内存大小按你要观察的 zone 规模给,比如 2G 就能看到 DMA32 和 Normal 两个区,4G 以上才会出现更复杂的区划分。CPU 数量建议先给 1,多核下单步会乱,等逻辑弄清楚再上 SMP 验证并发场景。

内核命令行里除了nokaslr,建议加上panic=-1(避免崩溃后卡住)、loglevel=7(串口多打点信息)、以及把init指向一个简单的东西,甚至可以直接init=/bin/sh进单用户,减少无关进程对内存的干扰。这一点在观察水位时很重要:系统正常启动会跑一堆服务,把内存吃到某个稳定状态,你在__setup_per_zone_wmarks断下来时看到的是"已经被消耗过一部分"的现场。想要干净的初始值,就在更早的点停下——比如start_kernel之后、mm_init附近。

GDB 端我通常这么起:gdb vmlinux,然后target remote :1234。连上之后先source linux/scripts/gdb/vmlinux-gdb.py把内核脚本加载进来,再lx-symbols解析模块符号。如果不想每次手敲,写个.gdbinit放在工作目录,里面把连接、脚本加载、常用宏都配好,效率提升很明显。

4.3 断点、单步与结构体打印的具体手法

设断点的顺序我建议从"算水位的地方"开始,而不是从分配路径开始。理由是算水位只在启动和参数变更时执行,命中次数少,适合从容观察;分配路径一秒几万次,上去就被淹没。

先看算水位:break __setup_per_zone_wmarks。命中后不用急着单步,先finish让函数跑完,然后手工遍历 zone 打印结果。打印命令大概是这样的:

p contig_page_data.node_zones[0].name p contig_page_data.node_zones[0].managed_pages p contig_page_data.node_zones[0]._watermark

NUMA 机器上contig_page_data只是个占位,要用first_online_pgdat()或者直接遍历pgdat_list,用内核脚本里的lx-pgdat之类更方便。打印出来的数组三个元素就是 min/low/high,单位是页。拿managed_pages和这三个数一除,比例对不对当场就能验证。

再看分配路径:break __zone_watermark_ok。这个函数参数多,命中后先看mark和free_pages这两个核心值:

p mark p free_pages p order p highest_zoneidx p alloc_flags

alloc_flags是个位掩码,直接看数字没意义,要对照ALLOC_WMARK_MIN之类的枚举值。GDB 里可以用p alloc_flags & 0x7之类的写法拆位,或者干脆p /x alloc_flags看十六进制再对源码里的定义。这个操作看着笨,但比printk靠谱,因为你能在同一个现场反复查不同变量,而printk打完就得重启。

单步这块要说清楚一个区别:next(源码级单步,跳过函数调用)和stepi(指令级单步)。源码级单步在-O2下经常"跳得莫名其妙",因为编译器把几行源码重排了,next可能一次跨过好几个语句。指令级单步最准,但慢,一个函数几百条指令能把人走崩溃。我的用法是:先用next快速走,遇到关键比较或算术运算时切stepi,看清楚寄存器里到底装了什么。特别是水位计算里那些位移和加法,指令级看一眼比读十遍源码管用。

4.4 制造内存压力并实时观察水位变化

静态看初始值只是第一步,真正有意思的是看水位动态变化时判定怎么走。制造压力最直接的办法是在虚拟机里跑一个吃内存的小程序,逐步申请并触碰页面(只malloc不写是拿不到物理页的,得memset一遍)。观察点有两处:一是/proc/zoneinfo里的 free 值和 min/low/high 的对比,二是 GDB 断点上抓到的实际free_pages。

我一般会分阶段加大压力,每个阶段停一下记录数据。比如从空闲状态开始,每写 128MB 记一次,直到触发回收。这个过程中你会清楚看到:空闲页降到HIGH以下时kswapd被唤醒,/proc/vmstat里的pgscan_kswapd开始涨;继续压到LOW以下,分配开始频繁走慢路径,allocstall出现;跌破MIN之后,同步回收成为常态,进程出现明显停顿。如果压到MIN以下还在成功分配,别怀疑,去查alloc_flags里是不是带了ALLOC_HARDER。

有个细节值得专门盯:kswapd被唤醒后,free_pages会短暂回升,然后可能又掉下去,形成锯齿。锯齿的频率和幅度直接反映水位设置是否合理——间隔太密说明HIGH和LOW挨得太近,回收线程刚睡下又被叫起来;幅度太大说明单次回收太猛,可能是watermark_scale_factor给的空间太宽。这些在单步里没法一次看清,得靠断点计数或ftrace配合,这也是为什么我前面说单步是"论证工具"而不是"监控工具"。

5. 单步过程中的异常与排查速查

5.1 断点不命中与断点位置过多

"断点设了但没停"是最高频的问题,成因有四类,我按遇到频率排。第一类是符号没匹配上,比如你断的是zone_watermark_fast,但编译器把它 inline 到七八个调用点,GDB 默认会问你是否全部设置,如果你选了 y 然后只观察其中一个,很可能那个点根本没被走到。用info breakpoints看到一堆 location 编号就属于这种情况,解决方式是先disable全部,再enable你关心的那个。

第二类是 KASLR 没关,每次启动地址不一样,你按上次的地址下断点自然对不上。这种表现为"断点显示已设置但状态是 pending",加载符号后也不解析。加nokaslr重启即可。

第三类是函数被编译器优化掉了。static且只被调用一次的函数很可能被完全内联并消除,断点无处可下。这时候退一级,断它的调用者,或者断它内部某条实际会生成的指令地址。

第四类比较隐蔽:断点设在了很少走到的分支上。比如你断wakeup_kswapd,但系统内存充裕一直没触发回收,那当然不停。这种靠加/proc/vmstat观测或人为制造压力解决,不是 GDB 的问题。

5.2 优化与 DWARF 版本带来的变量不可见

p zone->_watermark报 "no member named" 或者p mark报 "optimized out",本质是调试信息和实际二进制对不上。前者多数是 DWARF 版本问题,换CONFIG_DEBUG_INFO_DWARF4重编基本能解;后者是变量被优化进寄存器或直接被消除了,-O2下非常常见。

绕过手段有三个,按可靠性排:直接读结构体内存,比如p *(unsigned long *)($zone + OFFSET),OFFSET 可以从p &((struct zone *)0)->_watermark拿到(GDB 支持这种取偏移的写法,即使变量被优化也不影响结构体布局);从寄存器里找,先info registers再对照反汇编猜哪个寄存器装的 mark;降到-Og重编,最省事但会改变时序。

我用得最多的是第一种。写个 GDB 宏把常用偏移固化下来,后面不管优化级别怎么变都能打。这招在分析线上内核的 vmcore 时同样管用,因为线上内核必然是-O2。

5.3 多核、抢占与 QEMU 加速的干扰

单步时最怕的是"你以为在走这一条路径,其实 CPU 已经切走了"。QEMU 的 gdbstub 默认会在断点命中时暂停所有 vCPU,这本身是好事,但如果开了 KVM 加速,单步的原子性和你预期的不一样,经常出现"stepi 之后位置跳了很远"。调试内核单步时不要开 KVM,用纯 TCG 模拟,慢是慢,但每一步都可预测。

SMP 场景下还有个问题:你在 CPU0 上单步观察某个 per-CPU 变量的变化,但真正修改它的可能是 CPU1。zone->_watermark是全局的,问题不大;但per_cpu_pageset里的计数是每核独立的,单看一个核会以为数字不对。GDB 里读 per-CPU 变量需要先拿到偏移再按 CPU 编号索引,内核脚本提供了lx_per_cpu之类的辅助,用它比自己算偏移省心。

抢占和中断也会打乱单步节奏。断点命中时如果正好在中断上下文里,你看到的current可能是个无关进程。判断方法很简单:看调用栈,bt一下就知道自己在哪个上下文。养成命中后先bt的习惯,能避免大量误判。

5.4 排查速查表

下面这张表是我自己贴在便签上的,出问题先对一遍,能省掉八成瞎试的时间。

现象最可能原因处理方式
断点显示已设置但从不命中函数被 inline 到多个位置,只启用了错的 locationinfo breakpoints查看全部 location,逐个 enable
每次重启断点位置都变KASLR 未关闭启动参数加nokaslr
p 变量提示 optimized out-O2把变量优化进寄存器或消除读结构体偏移、看寄存器,或换-Og重编
结构体成员名报错DWARF5 与 GDB 版本不兼容打开CONFIG_DEBUG_INFO_DWARF4重编
stepi位置跳跃无规律QEMU 开了 KVM 加速去掉加速参数,用纯模拟
单步时current指向无关进程命中的是中断上下文或别的 CPUbt看调用栈确认上下文
水位数值与/proc/zoneinfo对不上命中的是不同 zone,或读取时机在重算前后打印zone->name确认,命中点选在函数返回后
分配在水位以下依然成功ALLOC_HARDER/ALLOC_HIGH生效打印alloc_flags拆位确认

表之外再补两条经验。一是善用commands让断点命中后自动打印关键值然后continue,把 GDB 当采样工具用,比手工一次次停效率高一个量级。二是善用condition,把条件写精确,宁可多写几个断点也不要让一个断点天天误命中——在高频路径上,一次无效中断的代价可能是几秒钟,一天下来就是几小时。

6. 从论证走向调优:把观测结论落地

6.1min_free_kbytes的取值经验与副作用

论证清楚之后,调参才有依据。min_free_kbytes是最常被动的旋钮,默认值由物理内存总量开平方推出来,小内存机器上可能只有几百 KB,大内存服务器上能到几十 MB。这个默认值的意图是"给系统一个能扛住突发分配的最小余量",不是"给你调优用的起点"。

往上调的好处是减少同步回收、降低分配延迟毛刺,坏处很直接:占用的内存不能用于缓存,同时kswapd会更早被唤醒、扫描更多页。在内存本来就不宽裕的机器上,把min_free_kbytes调大等于自断一臂,可用内存变少反而更容易触发回收。我见过的反面案例是把值设到物理内存的 5%,结果系统常驻内存少了 5%,缓存命中率下降,整体吞吐反而掉了。

我的经验取值是:先看/proc/vmstat里的allocstall计数(异步回收比例),如果同步回收占比明显偏高,先把min_free_kbytes往上调 50% 观察一段时间;如果pgscan_kswapd已经很高而allocstall不高,说明回收是提前且充分的,这时候再调大只会更浪费,应该考虑往下调或者保持不动。这个判断方向比"内存大就调大"要靠谱得多。

6.2 什么时候该动watermark_scale_factor

如果问题是"小内存 zone 不够用"或者"NUMA 节点之间不均衡",那该动的是watermark_scale_factor而不是绝对基准值。因为它按 zone 大小分摊,大 zone 自动拿到更多余量,小 zone 不会被过度保护。

典型的适用场景是大内存机器上跑内存密集型服务,min_free_kbytes的默认值相对总量已经可以忽略,导致水位和实际可用内存规模脱节。这时候把 scale factor 调上去(记得先确认量纲,前面说过那个十倍坑),让水位跟着 zone 规模走。反过来,如果机器内存不大,min_free_kbytes已经占了不少比例,再调 scale factor 就是叠加放大,很容易过度保护。

判断依据我一般看两个指标:pgsteal_kswapd相对pgscan_kswapd的效率比,以及高阶分配失败率(/proc/vmstat里的compact_fail、pgalloc_dma等)。前者低说明扫描白干,可能是水位太高;后者高说明碎片压力大,可能需要配合 boost 而不是单纯的 scale。

6.3 把观测结论沉淀成脚本与长期回归

单步调试最大的问题是不可重复——你总不能每次怀疑水位有问题就搭一遍 QEMU。所以论证完之后必须做一件事:把关键的观测点固化成脚本,让它在正常环境里也能跑。

我的做法分两层。第一层是采集脚本,定期抓/proc/zoneinfo、/proc/vmstat里和水位、回收相关的计数,算好 min/low/high 和 free 的比值,落成时间序列。这样一旦出现异常,能回溯到是哪个阶段开始的。第二层是回归脚本,在 QEMU 里自动执行"启动 → 制造压力 → 采集 → 输出报告"这套流程,把我在单步里验证过的几个不变量写成断言:LOW必须是MIN的 1.25 倍上下(考虑取整)、HIGH必须是 1.5 倍上下、zone 之间MIN之和必须接近min_free_kbytes换算值。内核参数一变或者换版本,跑一遍就知道有没有偏离预期。

这套东西写起来也就一两百行,但价值在于把"我当时论证过"变成"每次都能验证"。踩过的坑是脚本里硬编码了某个版本的计算公式,结果升级内核后断言全红,排查半天才发现是量纲从千分之一变成了万分之一。所以脚本里的比例和常数要么从内核参数反推,要么标注清楚适用的版本范围,别当永恒真理写死。

我个人在实际操作中的体会是,水位线这东西最难的从来不是公式,而是"敢不敢动"。你读过源码、单步走过一遍、亲眼看着三个数字怎么算出来、怎么在判定里被减掉、怎么被几个 flag 临时改写,再去看/proc/zoneinfo里那三行输出,感觉完全不一样——它不再是一组神秘常量,而是你能推演、能预测、能反向验证的对象。至于后续还能怎么扩展,我一般会顺着两条线走:一条是往碎片化方向,看水位和规整怎么互相干扰;另一条是往容器方向,看 cgroup 的内存限制会不会在 zone 层面留下水位相关的副作用。两条线都还在踩坑阶段,等有稳定结论了再单独整理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询