我第一次认真研究 kallsyms 内核符号表,不是在读内核源码的时候,而是在某个凌晨三点抓一个诡异崩溃现场的时候。设备打印出来的调用栈只有一串十六进制地址,函数名一个都没有,靠 System.map 对半天还对不齐,最后才想起来去翻 /proc/kallsyms。那时我才意识到,这个平时不太起眼的内核符号表,其实是调试、排查、追踪内核问题最关键的底层设施之一。
kallsyms 说白了,就是把内核镜像里的函数名、变量名跟它们运行时的地址做成一张表,打进内核里,运行时可以通过文件名 /proc/kallsyms 直接查。它能做的远比“cat 一下看地址”多得多:ftrace 的函数过滤要靠它、kprobe 动态断点要靠它、崩溃栈自动符号化要靠它、perf 的符号解析也要靠它。不管你是做内核驱动、嵌入式系统、性能优化还是安全方向,只要有一天需要跟内核调用栈打交道,这张表你就绕不开。
这篇文章我不会只讲概念,而是把这几年实际摸过的存储结构、权限机制、排查流程和踩坑记录都摊开讲,尽量做到能直接拿来用。适合刚接触内核的同学建立整体认知,也适合已经写过一段时间驱动的朋友查漏补缺。
1. 别等crash了才想起符号表:kallsyms到底是什么
1.1 为什么内核需要一张“运行时符号表”
普通应用程序的调试符号通常不在运行进程里,而是放在外部 ELF 文件的 debug 段,需要用 gdb 等工具加载后做解析。但内核没有这个条件。内核启动后就是一个独立的二进制世界,崩溃在现场时,很可能没有联网、没有调试器、没有外部符号文件陪你。打印一个 oops 到串口、写进 pstore,往往就是全部的现场信息。
如果内核本身不保存符号表,那 oops 里打印的RIP: 0010:ffffffff81029f40就只是一堆数字。你只能靠 System.map 离线对照,而且一旦设备开启了 KASLR,编译机上的 System.map 地址跟运行时的真实地址差着一个随机偏移,直接对会让人相当迷惑。
kallsyms 的存在解决的就是这个问题。它把符号名和地址的对应关系直接编译进内核镜像的只读数据段,运行时内核打印栈回溯、注册 ftrace 过滤器、解析 kprobe 事件时都能在内存里直接按名字查地址、按地址查名字。有它没它,排查效率完全是两个量级。
提示:kallsyms 不是“调试符号的完整替代”。它能告诉你函数名和地址,但没有行号、没有参数结构、没有局部变量信息。它更像一本只有章节目录的书,能翻到函数,看不到函数内部的每一行代码。
1.2 三个编译选项决定了符号表“厚不厚”
内核的符号表不是天生就长一个样,它由几个配置项控制,理解这三个选项,基本就能看懂各种内核发行版的差异。
第一个是CONFIG_KALLSYMS,总开关。内核社区对它的态度很明确:生产环境建议打开,嵌入式极简场景才会为了省内存关掉它。默认发行版内核几乎都开着,但这不代表你手头的每个定制内核都有符号表,安全加固过或者裁剪过的内核完全可能把它关掉。
第二个是CONFIG_KALLSYMS_ALL,它决定是否把所有符号都收进去。只开CONFIG_KALLSYMS时,内核本身会包含所有函数符号,但大量的数据符号可能被省略,比如全局变量、结构体名、某些只读数据。做内存泄漏排查或者要追踪某个全局变量时,没有这个选项会很难受,因为grep一个数据符号会直接查不到。
第三个是CONFIG_KALLSYMS_BASE_RELATIVE,这个比较现代,它把符号地址以“相对基准地址的偏移量”来保存,而不是保存绝对地址。这样可以显著压缩符号地址所需的存储空间,同时在 KASLR 开启后也不需要修正符号表内容。64 位平台基本都默认开启。
这几个选项之所以重要,是因为它们直接决定了你能从/proc/kallsyms里看到多少东西。我在调试一个内存膨胀问题时就遇到过:函数符号都在,全局变量符号全没有,最后只能靠立国旗插桩打印地址,才发现问题是某个被反复分配的缓存结构没释放。如果当初CONFIG_KALLSYMS_ALL开着,grep那个变量名就能直接定位。
1.3 kallsyms、System.map、vmlinux 三者怎么分工
很多初学者会把这三样东西混在一起,其实它们各自干各自的事。
vmlinux 是编译出来的原始 ELF 文件,包含完整的调试信息和符号表,是“出厂原型”。但实际运行的 zImage、bzImage 通常会经过 strip 和压缩,里面的符号信息被清掉了。
System.map 是编译时由 vmlinux 导出的文本符号清单,按地址排好列好,格式和/proc/kallsyms很像。它是个静态文件,内核版本一变就失效,KASLR 一开地址还可能对不上。
kallsyms 则是打进镜像的运行时压缩符号表,由内核的 kallsyms 机制生成。它才是真正“跟内核一起跑”的那个。你调试 crash dump 时,dump 文件里往往也嵌了一份 kallsyms 数据,crash 工具可以直接用它完成地址到符号的解析,不依赖外部 System.map。
我个人的经验是:能基于运行时 kallsyms 的就别依赖编译机上的 System.map;能看/proc/kallsyms的时候就别拿 vmlinux 硬凑。三者可以互相印证,但“运行时”永远优先。
2. 几千个符号塞进内核镜像:地址、名字与压缩存储的博弈
2.1 符号不只是“名字+地址”这么简单
初看/proc/kallsyms,觉得它就是个 key-value 表,名字对应地址。但到了内核内部,这张表的设计要复杂得多。现代内核函数符号以数万计,数据符号更多,直接存成字符串和绝对地址,会白白吃掉几兆内存,因此在存储结构上做了不少优化。
我理解它的存储可以分成两路:一路管地址,一路管名字。
地址存储方面,开启了CONFIG_KALLSYMS_BASE_RELATIVE的内核,不会保存每个符号的完整 64 位绝对地址,而是保存kallsyms_offsets:每个符号到某个基准地址的带符号偏移,通常用 32 位甚至更小空间保存。找到基准地址,加上偏移,就算出了真实地址。KASLR 动态改变内核基址后,符号之间的相对偏移是不变的,所以这套方案对随机化也非常友好。
名字存储方面,则主要是压缩。符号名不能做成定长数组,那样太浪费空间,所以内核把所有符号名压进一个大的字符串池kallsyms_names,每个符号在里面按偏移定位。这个“符号+名字+地址”的三元关系,在数据结构上并不像教科书里写得那么优雅,但空间效率确实做到了极致。
2.2 压缩token表:把“__x64_sys_read”压成两个字节
符号表压缩的关键在于kallsyms_token_table和kallsyms_token_index,这两个数组是配套使用的。思路其实跟压缩字典很像:先扫描内核里所有的符号名,统计出高频片段,比如_、__x64_sys_、do_、acpi_、_init、_0这类反复出现的子串,把它们收编成一个个 token,每个 token 用两个字节的索引表示。
实际存储符号名时,不是直接写 ASCII 字符串,而是把名字切分成一串 token 索引。读取时拿索引到 token 表里还原成原始字符。符号名里有大量公共前缀和后缀,这种压缩效果相当可观。原本平均二十多字节的函数名,压完之后可能只占两三个 token 的长度,整体符号名存储的体积大概能缩到原始文本的十分之一左右。
我第一次看到这个设计时没太当回事,直到试过自己写个脚本去解kallsyms_names,才发现里面的数据几乎全是\x00\x01这种不可读的索引。对比一下不做压缩的 System.map 的大小,会更理解内核社区为什么愿意用“复杂度换空间”——在嵌入式设备上,这几百 KB 到几 MB 的节省,可能就是能不能塞进 flash 的区别。
2.3 查找标记:为什么符号表里还要有 markers
压缩解决了体积问题,但带来了另一个问题:符号名是变长存储的,没法像数组一样通过下标直接定位到第 N 个符号的数据起点。如果每次查找都从头扫描一遍,几万个符号线性扫下去,性能会很难看。
解决方案是kallsyms_markers。内核编译时会按照符号数量每隔一段放一个 marker,记录这个位置对应的符号名在kallsyms_names里的字节偏移。查找时先通过 markers 进行一次粗定位,跳到大概位置,再在附近线性扫描,这样就不需要每次都从池子开头开始翻。它很像书籍目录里的章节页码:先翻到对应章节附近,再逐字找目标文字。
理解了这套结构,你就知道/proc/kallsyms看起来简单的“cat”,底层其实是 makers 粗定位、名字解压、地址偏移计算几个步骤协同的结果。内核还通过kallsyms_lookup_name()、kallsyms_on_each_symbol()等 API 把你查到的信息暴露给模块和追踪框架使用。写内核模块时如果有人说“找不到符号”,多半也跟这张表里的条目不全有关。
3. 读符号表也有门槛:权限、掩码与地址可见性规则
3.1 /proc/kallsyms 的格式与符号类型含义
/proc/kallsyms是内核暴露给用户态的主要入口。格式非常稳定,每一行大概是:
ffffffff81000000 T _text ffffffff81000100 t startup_64 ffffffff81234000 d init_uts_ns ffffffff81400000 D init_task第一列是符号地址,第二列是符号类型,第三列是符号名。类型字母基本沿用了 ELF 符号表语义:大写字母表示全局符号,小写字母表示局部符号,T/t是代码段,D/d是已初始化数据,R/r是只读数据,B/b是未初始化数据,A是绝对值符号,U是未定义符号。
这个类型列很容易被当成装饰性字段忽略掉,但我实际排查中没少靠它。比如两个符号地址非常接近,一个是T开头,一个是D开头,你就知道前者是函数、后者是全局变量,排查方向完全不一样。再比如某个内核模块的函数符号,类型前面还经常带着模块名相关的上下文,需要结合系统里加载的模块列表一起看,才知道这个函数属于哪个模块。
如果某个内核关闭了CONFIG_KALLSYMS_ALL,你会发现数据段符号大面积缺失,剩下的大多数是T开头的函数符号。这种情况不要觉得奇怪,先看编译配置,比盲目怀疑是不是文件坏了要靠谱。
3.2 kptr_restrict、dmesg_restrict 与权限模型
Linux 对内核地址的保护,越来越多的筹码押在了这几个内核参数上。
kptr_restrict是最核心的一个。它有三个典型取值。值为 0 时,任何进程 cat/proc/kallsyms都能看到真实地址。值为 1 时,只有拥有CAP_SYSLOG权限的进程能看到真实地址,其他进程看到的地址会被打印成0000000000000000。值为 2 时,内核把所有地址都隐掉,即使有部分权限也一样看不到。
dmesg_restrict则限制谁能读内核日志缓冲。如果 dmesg 被限制,那么普通用户通过 dmesg 拿到的内核调用栈里同样看不到真实符号信息。它跟 kallsyms 是两套独立机制,但经常一起被安全策略打开。你在一台安全基线很严格的主机上排查问题,可能明明开着符号表,却只能看到一串零,那八成就是kptr_restrict的问题。
很早之前我调过一台机器,cat /proc/kallsyms出来的地址全是 0,一开始以为内核没开符号表,差点重编内核。后来发现kptr_restrict=1,自己又没有CAP_SYSLOG,才恍然大悟。所以拿到一台陌生设备,先看/proc/sys/kernel/kptr_restrict和/proc/sys/kernel/dmesg_restrict,能省很多无谓的折腾。
提示:这里不存在“绕过”这种说法。做安全审计时,正确的做法是按系统权限模型去配置,比如用 root 提升
CAP_SYSLOG,或者把调试任务放到允许访问内核日志和符号表的上下文中。遇到阉割过的符号表,第一选择永远是跟系统管理员对齐权限,而不是试图从别的地方掏地址。
3.3 KASLR 让符号表地址“每次启动都不同”
从前 kallsyms 与 System.map 能精确对上,现在不一样。很多系统开启了内核地址空间布局随机化,每次启动内核镜像被加载的基址都可能变化,函数符号的绝对地址也随之漂移。
但 kallsyms 是运行时数据,它会跟着加载地址自动调整。所以开机后直接读/proc/kallsyms,你拿到的永远是“当前这次启动的真实地址”。真正麻烦的是离线场景:你手上只有一个 crash dump,没有对应这次启动的 kallsyms 快照,手里的 System.map 又是编译机上的地址,两者差一个随机偏移。这时候要么靠 dump 里嵌入的符号信息,要么用kaslr_offset之类的信息手动换算,轻易拿编译机地址硬套是会误导排查方向的。
遇到这种状况,我的习惯是先记录dmesg里打印的“Kernel Offset”这类行,它往往直接给出随机化的偏移量。有了偏移量,再把符号表地址减去偏移量,就能跟旧的 System.map 对起来。这个过程不复杂,但很多人第一次会遇到:“为什么 grep 到的函数地址,跟书里写的不一样”。
4. 拿到地址后怎么用:从栈回溯到动态追踪的一条龙
4.1 栈回溯地址到符号的“反查”操作
内核 oops 崩溃时,异常的 CPU 寄存器和调用栈会打印一系列地址。如果内核开了 kallsyms,栈回溯输出通常已经做了符号化处理;如果没开,你看到的只是一串地址,这时就需要自己反查地址对应的符号。
反查工具不复杂,cat /proc/kallsyms | grep <地址前缀>是最朴素的方式。但要注意 grep 很可能匹配到多个符号,因为地址可能落在某个函数范围中间,所以我会把符号表按地址排好序,再拿崩溃地址做二分查找,看看它落在哪个符号的起始地址和结束地址之间。这个过程看起来简单,实际上很常用,比如崩溃地址是ffffffff81029f42,对应符号应该是ffffffff81029f00 T xxx_func,这意味着崩在xxx_func+0x42的位置。
如果手里有完整的 vmlinux,还可以用 gdb 或addr2line把地址翻成函数名和行号,那样精度更高。但生产现场往往没有 vmlinux,能被 kallsyms 定位到函数级别已经很幸运了,至少能知道问题出在哪个模块、哪个子系统。
4.2 配合 ftrace、perf 与 kprobe 做动态追踪
kallsyms 不只是静态查看的“字典”,它更是动态追踪机制的基础。
ftrace 的set_ftrace_filter可以按函数名过滤追踪,但你填进去的名字必须能解析成地址,解析过程就依赖 kallsyms。没有符号表时,你要用set_ftrace_filter只能填地址,体验相当原始。
perf 命令里,perf probe能够把函数名转换成内核探针点,同样基于符号表。想追踪do_sys_openat2的调用,直接perf probe --add do_sys_openat2就能生成探针。如果符号表缺失,这一步必然报错,这时候不是 perf 有问题,而是符号来源断了。
内核的 kprobe 机制也类似。BPF 程序里的 kprobe 挂载,很多场景是写“函数名”给框架,由内核在加载时通过 kallsyms 查地址。我调试驱动时经常先在/sys/kernel/debug/tracing/kprobe_events里加一行p:my_probe do_sys_openat2,然后开一个追踪会话,看一下驱动调用的路径是否经过这个函数。这种“符号名到探针”的粗粒度 hook,实际排障效率非常高。
4.3 模块符号的坑:卸载即失效
/proc/kallsyms里的内容不只来自内核本体,还包含所有已加载内核模块的符号。模块.ko在加载时由内核模块加载器分配内存,符号地址在加载那一刻才确定。所以模块符号显示出来的地址,跟你在编译机上用nm看到的文件内偏移不是一回事。
追踪模块函数时,一方面要确认模块确实加载着,另一方面要注意模块卸载后,符号地址立刻变成无效状态。如果曾把某个模块符号地址记录到笔记里,下次开机或重新加载后去追踪就会扑空。正确的做法是通过模块名和函数名组合去重新查询,而不是凭旧地址硬来。
我也试过在模块里导出某个函数给另一个模块用,结果对方模块加载时总是报“Unknown symbol”。查了一圈,发现导出的符号确实在/proc/kallsyms里能看到,但类型标记和地址范围不对。这件事提醒我:模块之间的符号依赖,最终都会反映在 kallsyms 提供的符号可见性上,符号表查不到,多半是导出流程没走对。
5. 那些年我在符号表上踩过的坑
5.1 内核版本和 System.map 对不上是最常见的“假故障”
升级内核后,忘了把配套的 System.map 同步到调试机器上;或者拿着旧版本编译机的 System.map 去解析新版本设备的崩溃栈。这两种场景我见过太多次,结果就是地址对不上,符号名全错乱,排查方向跑偏到离谱的地方。
内核符号表是与内核二进制严格绑定的。版本号只差一个小补丁,函数地址就可能整体移位。不仅 System.map 会过期,连你手头那个 vmlinux 也要谨慎,最好确认它的构建哈希与设备运行的内核一致。最不容易出错的办法是直接从运行设备的/proc/kallsyms里取符号数据,或者从它配套的 crash dump 里提取,别用“差不多”这几个字安慰自己。
5.2 类型误判:数据和函数符号不要混着查
grep do_exit /proc/kallsyms会查出来一堆匹配项,有T do_exit,可能有t开头的小写符号,也可能有名字相近的其他函数。初学者容易抓第一个结果就当作目标,然后发现地址怪怪的。
我会把 grep 出的结果先按类型过滤,通常只保留T/t类型的函数符号来跳转。对待数据符号也类似,D/d才是变量。这样做的好处是,你排查的方向不会被一个同名但类型迥异的符号带偏。局部变量和全局变量同名的情况在内核源码里很多,只靠名字不看类型,真的会调试到怀疑人生。
5.3 裁剪内核里符号表缺失后的“盲人摸象”
嵌入式场景里,为了省那几百 KB,有些裁剪方案会直接关掉 kallsyms。平时跑着没问题,一旦出问题,串口打印的崩溃栈全是裸地址。手头既没有完整 vmlinux,又没有 System.map,排查难度直接拉满。
如果让我给嵌入式项目一个建议:能保留CONFIG_KALLSYMS就保留,实在空间紧张,也要在发布版本里留一份对应构建的 System.map 或多出一份可复现的符号映射机制。这里省下来的内存,未来大概率会以数倍的时间成本补回来。一次现场事故定位不出来,带来的损失早就超过那点 flash 空间了。
5.4 权限受限时的合规处理思路
最后说一个几乎每个人都会撞见的情况:在别人管理的主机上查符号表,发现地址全是 0。不要第一反应就是找其他途径掏地址,应该先看kptr_restrict和dmesg_restrict,判断这台设备的权限模型是什么。
合规的做法是把调试需求告诉系统管理员,用具备CAP_SYSLOG的上下文操作。很多内核追踪链路都支持 tracefs 事件记录,某些场景下也可以通过事件文件获取到已经解析好的符号名,不需要直接读原始地址。我自己的体会是,安全限制虽然增加了一点摩擦,但也逼着你形成更规范的调试流程——该记录的信息记录,该申请的权限申请,反而比乱挖地址更稳。
写过几年代码之后,我对 kallsyms 的态度已经变成:它不只是调试工具,而是一套内核对自身运行时结构的“自描述机制”。崩溃时靠它定位,追踪时靠它解析,模块加载时靠它验证。那句“内核是黑盒”的说法,在很大程度上就是靠这张表打破了。你在什么时候第一次用上 kallsyms?留下的第一个印象,多半也是某次异常排查里,它把一串十六进制数字变成了一个有意义的名字。