前言
Linux 是一种自由和开放源码的类 UNIX 操作系统,由林纳斯·托瓦兹在1991年赫尔辛基大学上学时创立的,主要受到 Minix 和 Unix 思想的启发。
Linux 遵循 GNU 通用公共许可证(GPL),任何个人和机构都可以自由地使用 Linux 的所有底层源代码,也可以自由地修改和再发布。由于 Linux 是自由软件,任何人都可以创建一个符合自己需求的 Linux 发行版。
Linux社区(官网链接)提供了多种发行版本,也可以下载最新Linux内核源码(官网链接),如果英文水平不错,可以加入社区交流;
内核与发行版本
-
Linux 内核
Linux 内核是 Linux 操作系统的核心部分,负责管理硬件资源和提供系统服务。Linux 内核处理底层的硬件接口、进程管理、内存管理、文件系统和网络功能等。内核本身并不包含用户界面、应用程序或用户友好的工具,它主要是提供底层功能供其他软件调用。
-
Linux 发行版
Linux 发行版(Linux Distribution)是由不同组织或公司基于 Linux 内核创建的完整操作系统,它集成了 Linux 内核、GNU 工具、系统库、应用程序包管理器、图形界面和一系列应用程序等。发行版是一个用户可以安装和使用的完整操作系统。
从市面上常用的产品理解内核与发行版本:
- 系统内核:Linux、Windows NT
- 操作系统:
- Linux: Ubuntu、Fedora、Deepin等;
- Windows:Windos98、Windows 7、 Windows 10等;
指令集与构架
Linux是支持多种架构的内核,架构内又分指令集版本,定义如下:
-
架构,是一种更广泛的概念,它不仅包含处理器的指令集,还包括微架构设计、系统总线、缓存层次结构和内存管理单元等组件的组织和实现方式。
在Linux内核中,架构相关目录路径为:linux_5.10/arch,在目录下可见到多种架构,如:alpha、arc、arm等。
以ARM架构为例,对应多种IP核心:
- ARM 架构,如 :Cortex-A9、Cortex-M33等;
- ARM64架构,如:Cotex-A53、Cotex-A55等;
-
指令集,是计算机 CPU 可以执行的二进制指令的集合和格式描述。它定义了处理器如何理解和执行指令,如加法、减法、数据传输、跳转等。
常见的指令集:
- x86:Intel 和 AMD 使用的指令集,广泛应用于桌面、服务器和个人电脑。
- ARM:适用于移动设备、嵌入式系统,具有高能效和低功耗特点,按版本有:armv7、armv8等版本;
- RISC-V:一种开源的指令集,越来越受到嵌入式和研究领域的青睐。
注:
- 嵌入式行业以低功耗、丰富IO等需求,ARM架构被受青睐,其中ARM64(又称AArch64)又是佼佼者,所以后续都将以ARM处理为测试环境,特殊需要会另外说明平台外,默认为ARM64平台环境。
识别崩溃类型
Linux系统是一个支持多用户和多任务的操作系统,所以当发生崩溃时需要区分一下是程序崩溃还是系统崩溃。
区分用户与内核崩溃
-
用户程序崩溃,日志示例
-
示例一、X86 Ubuntu22.04
$ sudo dmesg -c|tail # 清内核日志 $ ./output/test_segfault # 执行用户程序 test_segfault 段错误 (核心已转储) $ sudo dmesg -c|tail [332738.014107] test_segfault[2067643]: segfault at 0 ip 0000000000401624 sp 00007ffc6b370d58 error 6 in test_segfault[401000+97000] likely on CPU 8 (core 0, socket 0) [332738.014134] Code: d6 fb ff ff 50 e8 d0 fb ff ff e8 cb fb ff ff 4c 89 ff e8 af b4 06 00 48 89 ef e8 87 1d 09 00 0f 1f 80 00 00 00 00 f3 0f 1e fa <c7> 04 25 00 00 00 00 00 00 00 00 0f 0b 66 2e 0f 1f 84 00 00 00 00
由内核Coredump机制保存了异常环境,后续可使用gdb调试。此外就另外打印了一些:寄存器、代码信息;
-
示例二、ARM64 Busybox
~ # ./mnt/nfs/F5/sample_segfault Segmentation fault ~ # ~ # dmesg -c ~ #
而没有启用Coredump机制的F5平台在用户程序发生异常时,在提示了
Segmentation fault
段错误后就没有其他内核日志。 -
-
内核崩溃
-
示例、sysRq-trigger测试内核panic
[root@milkv-duo]~# echo c > /proc/sysrq-trigger [ 20.025118] sysrq: Trigger a crash [ 20.028748] Kernel panic - not syncing: sysrq triggered crash [ 20.034689] Kernel Offset: disabled [ 20.038294] CPU features: 0x0040002,20002000 [ 20.042702] Memory Limit: none [ 20.045863] ---[ end Kernel panic - not syncing: sysrq triggered crash ]---
以
[...]
时间戳开始的信息为内核日志;错误类型为panic,属于严重的内核错误。
-
识别崩溃类型
Linux内核崩溃有很多原因(至少10种),嵌入式开发工作中学会遇到以下几种类型:
-
Oom
Linux内核的OOM(Out of Memory)机制是一种内存管理策略,用于在系统物理内存耗尽时选择并杀死一个或多个进程,以释放内存并防止系统崩溃。当系统无法满足新的物理内存分配请求,并且所有其他内存回收机制(如内存规整、页帧回收等)都失败时,OOM Killer会被触发。
OOM Killer会遍历系统中所有进程,根据每个进程的oom_score(一个基于进程内存占用情况计算得到的分数),选择分数最高的进程进行杀死,以释放内存。oom_score的计算考虑了进程的内存使用情况,包括常驻集大小(RSS)、交换空间占用、页表占用的内存等。系统管理员可以通过调整oom_score_adj的值来影响oom_score,从而影响OOM Killer的选择。
-
Oops
在计算机科学与软件开发中,“Oops” 并不是一个特定术语的缩写。相反,它是一个互联网俚语,通常用于形容某种小错误或意外故障,类似于“哎呀”或“出错了”的意思。在Linux内核中,“Oops” 主要是指内核级别的错误报告。Oops通常是由于内核开发中的bug、设备驱动程序不当使用、内存管理错误,或硬件故障等原因引起的。 Oops并不会导致整个系统崩溃,内核会尝试继续运行。这种机制使得系统能尝试提供一些服务,尽管可能会有部分功能受到影响。
Oops机制在Linux内核中提供了一个重要的错误报告和诊断功能,有助于及时识别和修复系统中潜在的错误,使得操作系统能够在面对问题时仍然能够运作,在一定程度上提高了系统的稳定性。
-
空指针引用
- 内核报错:Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
- 用户报错:Segmentation fault
-
内存访问越界
- 内核报错:Unable to handle kernel paging request at virtual address xxx
- 用户报错:Segmentation fault
-
-
Panic
Oops与Kernel Panic不同,后者是一种更严重的错误状态,意味着内核无法继续运行,系统将完全崩溃,无法恢复。
-
栈溢出
内核报错:Kernel panic - not syncing: corrupted stack end detected inside scheduler
-
死锁
内核报错:Kernel panic - not syncing: System is deadlocked on memory
-
核间影响
内核报错:Kernel panic - not syncing: Fatal exception
对于多核处理的系统,当一个核(CPU)检测到致命错误时,它会触发一个同步机制,防止其他核继续进行有可能导致数据损坏的操作。因此,你可能会看到其他核也报告 panic 的信息,这就需要问题分析时有一定的判断力。
-
崩溃信息分析
在崩溃信息分析前,需要确保已收集足够的信息以帮助错误分析。
崩溃信息收集
崩溃信息的收集是否充足与日志等级、获取方式相关。
日志等级
通过读取/proc/sys/kernel/printk
节点可以直接获知当前内核的日志等级,示例如下:
$ cat /proc/sys/kernel/printk
4 4 1 7
可知printk子系统控制了4个关键日志等级,分别如下:
-
console_loglevel
控制printk打印中日志等级小于
console_loglevel
的在终端上打印输出。 -
default_message_loglevel
对于内核中调用
printk
函数而未指定日志等级时,使用该默认等级。 -
minimum_console_loglevel
console_loglevel
(第一列)最小值,经测试无明显效果,可不关注。 -
default_console_loglevel
console_loglevel
(第一列)默认值。
等级0~8,解释:
Kernel constant Level value Meaning
KERN_EMERG 0 System is unusable
KERN_ALERT 1 Action must be taken immediately
KERN_CRIT 2 Critical conditions
KERN_ERR 3 Error conditions
KERN_WARNING 4 Warning conditions
KERN_NOTICE 5 Normal but significant condition
KERN_INFO 6 Informational
KERN_DEBUG 7 Debug-level messages
日常开发调试中,为了降低因频繁的日志打印而影响系统性能,会将默认内核日志等级设置为4,即只打印内核错误及更严重的错误。当开发或问题定位时,为了获取更多的可用信息以提高问题排查效率,会将日志等级设置为8。
等级修改
-
方式一、/proc/sys/kernel/prinkt
虽然printk有四个参数,但调整console输出日志等级只需要调整第一个参数
console_loglevel
。操作如下:echo 8 > /proc/sys/kernel/print
注:实现可根据需要顺序写入多个修改值,如:
echo 8 3 > /proc/sys/kernel/printk
。 -
方式二、/proc/sysrq-trigger
sysrq-trigger
允许用户通过写入特定命令来触发系统请求(SysRq)功能,常用于系统恢复和调试任务。调整系统日志也是
sysrq-trigger
的功能之一,操作如下:# echo 7 > /proc/sysrq-trigger [ 1910.944987] sysrq: Changing Loglevel [ 1910.945092] sysrq: Loglevel set to 7
日志获取
启动日志
启动日志尽量使用调试口来收集日志,若在运行时再手动收集日志会因内核日志缓存有限导致的启动部分日志被冲刷消失。为了尽量充分、详细地收集启动日志需要将内核日志等级设置的比较大(常为7、8)。设置方式如下:
-
启动参数
bootargs
中的loglevel
参数
logleve
是内核启动支持的入参,在系统启动时生效。这个参数需要由前一级的引导程序传入,嵌入式开发中内核引导程序一般为U-boot,在U-boot的命令行模式下可以设置loglevel
并保存以生效,示例如下:# setenv bootargs 'earlycon=sbi riscv.fwsz=0x80000 loglevel=8' # saveenv
因信息安全的需要,海康设备对引导程序下可设置的内核参数进行了限制,所以设置
loglevel
并不生效,但可以使用一个功能相同的另一个变量kdbg
并且设置的方法也相同。
运行日志
设备运行过程中有调试需要时,除了直接的串口连接外还可以使用网络SSH等方式进入Linux终端以收集日志。在终端命令行模式下,可以使用:dmesg、kmesg等方式收集日志,详细操作如下:
-
缓存日志
dmesg
命令可以用于打印系统已缓存的日志。内容较串口收集的日志稍有不同:- 串口终端,输出的日志受
printk
中相关日志等级控制策略的限制,但内容大小没有限制; - dmesg命令,输出的日志不受
pintk
子系统中日志等级控制的限制,但内容大小有限制;
命令执行效果如下:
[root@milkv-duo]~# dmesg [ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd034] [ 0.000000] Linux version 5.10.4-tag- (gaoyang3513@543480d0842a) (aarch64-linux-gnu-gcc (Linaro GCC 7.3-2018.05) 7.3.1 20180425 [linaro-7.3-2018.05 revision d29120a424ecfbc167ef90065c0eeb7f91977701], GNU ld (Linaro_Binutils-2018.05) 2.28.2.20170706) #3 SMP PREEMPT Mon Oct 14 09:49:44 CST 2024 [ 0.000000] Machine model: Milk-V DuoS ... [ 0.000000] Kernel command line: root=/dev/mmcblk0p3 rootwait rw console=ttyS0,115200 earlycon=sbi riscv.fwsz=0x80000 loglevel=8 ... [ 16.867347] bm-dwmac 4070000.ethernet eth0: Link is Down [ 18.916219] bm-dwmac 4070000.ethernet eth0: Link is Up - 100Mbps/Full - flow control rx/tx [root@milkv-duo]~#
- 串口终端,输出的日志受
-
即时日志
如果只关注后续的日志而不关注历史日志,可以借助
cat
工具读取/proc/kmsg
节点的方式,示例如下:# cat /proc/kmsg
该命令在会在前台运行,即用户无法输入对调试造成不便,因此可以另外一个终端另放置后台运行。
相较于串口日志收集而言,运行时使用dmes或kmsg收集日志不得不面对因系统发生崩溃而受牵连的风险,此时dmesg或kmsg可能会无法使用或收集到的日志有缺失。
异常日志
对于正常部署的设备一般不会连接串口会运行dmesg命令来收集异常日志,此时就需要有一种自动的、可以识别异常日志并保存成日志文件的机制,很庆幸的是内核开发了pstore
子系统。
Pstore的使用只需要关注:1. Pstore是否开启;2. 崩溃日志是否生成,相应的可查看如下信息心确认:
- 内核模块:
cat /proc/kallsyms |grep pstore
,若有输出说明已开启功能(可以量内嵌或手动加载); - 保存路径:
mount |grep pstore
,若有输出则说明已挂载日志保存路径; - 后端模块:
find /proc/device-tree/ -name ramoops
,若有输出则说明后端模块ramoops
已经设置;
以上三个条件都满足后,Pstore就可以正常使用。
当发生异常时,Pstore子系统将自动收集崩溃日志(包含前段的缓存)至保存路径下,从文件名可以区分错误的类型。以下示例崩溃文件的命名模式:
console-ramoops-0
console
,终端日志,主要记录重启原因(手动reboot、panic等);ramoops
,pstore后端,日志保存在内存中;0
,第0个日志文件;
信息解读
以“空指针引用”触发Oops为例说明关键信息解读,另外补充Panic、Oom错误解读。
# insmod test_fault.ko
[ 56.836885] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
[ 56.846074] Mem abort info:
[ 56.849540] ESR = 0x96000005
[ 56.853005] EC = 0x25: DABT (current EL), IL = 32 bits
[ 56.858731] SET = 0, FnV = 0
[ 56.862102] EA = 0, S1PTW = 0
[ 56.865551] Data abort info:
[ 56.868754] ISV = 0, ISS = 0x00000005
[ 56.872911] CM = 0, WnR = 0
[ 56.876156] user pgtable: 4k pages, 39-bit VAs, pgdp=00000000829cb000
[ 56.883000] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000
[ 56.892762] Internal error: Oops: 96000005 [#1] PREEMPT SMP
[ 56.898530] Modules linked in: test_fault(FO+) cv181x_pwm(FO) aic8800_fdrv(F) aic8800_bsp(F) cv181x_ive(FO) cvi_vc_driver(FO) cv181x_jpeg(FO) cv181x_vcodec(FO) cv181x_tpu(FO) cv181x_clock_cooling(FO) cv181x_rgn(FO) cv181x_mipi_tx(FO) cv181x_vo(FO) cv181x_dwa(FO) cv181x_vpss(FO) cv181x_vi(FO) snsr_i2c(FO) cvi_mipi_rx(FO) cv181x_fast_image(FO) cv181x_rtos_cmdqu(FO) cv181x_base(FO) cv181x_sys(FO)
[ 56.934605] CPU: 0 PID: 340 Comm: insmod Tainted: GF O 5.10.4-tag- #1
[ 56.942510] Hardware name: Milk-V DuoS (DT)
[ 56.946834] pstate: 80000005 (Nzcv daif -PAN -UAO -TCO BTYPE=--)
[ 56.953049] pc : trigger_init+0x48/0x1d0 [test_fault]
[ 56.958272] lr : do_one_initcall+0x64/0x168
[ 56.962590] sp : ffffffc01102bba0
[ 56.966015] x29: ffffffc01102bba0 x28: ffffff80029b5700
[ 56.971508] x27: 0000000000000001 x26: ffffff80029b5748
[ 56.977000] x25: 0000000000000001 x24: ffffffc008893090
[ 56.982492] x23: ffffff8002219600 x22: 0000000000000000
[ 56.987984] x21: ffffffc010a35000 x20: ffffffc008891138
[ 56.993476] x19: ffffffc008893000 x18: 000000000000000a
[ 56.998969] x17: 0000000000000000 x16: 0000000000000000
[ 57.004460] x15: 00000000000003dc x14: ffffff80022bfa1c
[ 57.009952] x13: ffffffffffffffff x12: 0000000000000020
[ 57.015445] x11: 00000000fffffff9 x10: 00000000000186d3
[ 57.020937] x9 : ffffffc010a10880 x8 : ffffffc01089c998
[ 57.026429] x7 : ffffff8014b0bb10 x6 : 0000000000000000
[ 57.031920] x5 : 0000000000000000 x4 : ffffff8014b0aea0
[ 57.037412] x3 : 00000000004d1c00 x2 : 0000000000000000
[ 57.042903] x1 : ffffffc008891178 x0 : 0000000000000000
[ 57.048396] Call trace:
[ 57.050932] trigger_init+0x48/0x1d0 [test_fault]
[ 57.055792] do_one_initcall+0x64/0x168
[ 57.059759] do_init_module+0x58/0x1d8
[ 57.063637] load_module+0x19d4/0x1f0c
[ 57.067512] __do_sys_finit_module+0xcc/0xd8
[ 57.071925] __arm64_sys_finit_module+0x1c/0x28
[ 57.076608] do_el0_svc+0x140/0x19c
[ 57.080214] el0_svc+0x14/0x20
[ 57.083373] el0_sync_handler+0x68/0x134
[ 57.087425] el0_sync+0x158/0x180
[ 57.090856] Code: 8b208820 d61f0000 d2800000 52800002 (b9400001)
[ 57.097151] ---[ end trace c4c63764a3265bc2 ]---
Segmentation fault
分段解析,
-
Step1. 错误定性
-
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
可知直接原因:引用空指针
NULL pointer dereference
;内存地址:0000_0000_0000_0000
。其中ARM64即系统可支持64bits地址访问,但当前测试环境下Linux内核使用39-bit。
-
Internal error: Oops: 96000005 [#1] PREEMPT SMP
错误级别为
Oops
(错误级别还有:oom_trigger:
,内存不足;Kernel panic - not syncing:
,内核崩溃等),错误寄存器值为96000005
(16进制),同类错误已经出现1次,使用的是PREEMPT
抢占型内核,支持SMP
多核处理。
-
-
Step2. 主角定位
-
CPU: 0 PID: 340 Comm: insmod Tainted: GF O 5.10.4-tag- #1
可知
CPU0
在执行340
号进程insmod
时发生了错误,此时内核已处于Tainted: GF
污染状态(共18个bit,位域映射),其中:G标记(Bit0)置位,已加载的模块使用了GPL或兼容证书;F标记(Bit1)置位,外部模块中,曾使用insmod -f
方式强制加载;D标记(Bit7)未置位,说明第一次发生该错误;O标记(Bit12)置位,有外部模块加载过;内核版本为5.10.4-tag-
,该内核镜像为第一次生成#1
。
-
-
Step3. 还原现场
-
pstate: 80000005 (Nzcv daif -PAN -UAO -TCO BTYPE=--)
该行为线程状态信息:当前线程(insmod)的状态为
80000005
,括号中内容为其位域映射与置位信息。涉及线程相关内容后续展开,简单了解 [5:0]低5位为aarch64架构下的EL等级,此时值为 5 对应为 EL1,即内核态。 该行以下是线程上下文信息(陷入内核态后,工作环境的信息),主要是寄存器的值。
-
trigger_init+0x48/0x1d0 [test_fault]
pc
(程序计数器)寄存器指向trigger_init+0x28
位置,即当前在执行该位置的代码,其中trigger_init函数代码的大小为0x1b0
,代码段归属于模块test_fault
。 -
lr、sp、x29-x0
余下的这些寄存器属于次要信息和平台也有关联,暂且忽略。
-
-
Call trace:
, 该行以下即是对线程栈的回溯信息,自上而下逐行是调用到现场的路径,可知:
-
trigger_init+0x48/0x1d0 [test_fault]
最后调到到
trigger_init
符号(函数)偏移0x48
字节的位置,trigger_init函数代码的大小为0x1d0
,属于模块test_fault
。
-
-
结合三步骤,可以对问题出现的原因、发生路径和环境信息有个大致的了解。正如上面的示例,如果要确认:test_fault
驱动模块哪一行代码出现的错误就需要借助内核提供的各种调试工具。
内核崩溃调试
无论借助哪种工具,定位到问题发生的那一行代码都是至关重要的,在正式跟踪代码行前需要先确认环境信息:
- 内核是否开启调试信息?
- 那些符号(函数)属于外部模块?
如果内核已开启调试信息,可以方便的使用内核提供的工具faddr2line
(addr2line的内核定制版本)在内核镜像vmlinux
中定位到出错位置,命令如下:
$ ./scripts/faddr2line ./build/sg2000_milkv_duos_glibc_arm64_sd/vmlinux do_one_initcall+0x64/0x168
do_one_initcall+0x64/0x168:
do_one_initcall at init/main.c:1218
但如果内核没有开启调试信息就将会是一堆问号,对于定位并没有作用
$ ./scripts/faddr2line ./build/sg2000_milkv_duos_glibc_arm64_sd/vmlinux do_one_initcall+0x64/0x168
do_one_initcall+0x64/0x168:
do_one_initcall at ??:?
而对于test_panic
这样的外部模块,其符号信息是没有被包含在vmlinux
中的,因此需要找到test_fault.ko
文件,示例如下:
$ ./scripts/faddr2line ~/Source/03-Sample/02-Driver_Linux/01-Debug/01-Panic/test_fault.ko trigger_init+0x28/0x1d0
trigger_init+0x28/0x1d0:
trigger_oops at /home/gaoyang3513/Source/03-Sample/02-Driver_Linux/01-Debug/01-Panic/test_ps.c:18 # 18 行
(inlined by) trigger_init at /home/gaoyang3513/Source/03-Sample/02-Driver_Linux/01-Debug/01-Panic/test_ps.c:151
实际内容为:
在第18行有printk函数中引用了空指针ptr
,进而引发了错误。
崩溃的定位方法
事后追溯
gdb工具
如果内核模块在编译时使用-g
参数添加了调试信息,那么就可以使用gdb对错误进制定位。注意一定使用目标设备的交叉工具链下的gdb工具调试,示例命令如下:
$ aarch64-linux-gnu-gdb ~/Source/03-Sample/02-Driver_Linux/output/lib/modules/private/test_fault.ko
GNU gdb (Linaro_GDB-2018.05) 8.1.0.20180612-git
...
Reading symbols from /home/gaoyang3513/Source/03-Sample/02-Driver_Linux/output/lib/modules/private/test_fault.ko...done.
(gdb) l *trigger_init+0x48
0x180 is in trigger_init (/home/gaoyang3513/Source/03-Sample/02-Driver_Linux/01-Debug/01-Panic/kernel_fault.c:18).
13
14 static int trigger_oops(int test)
15 {
16 int *ptr = (int *)0; // 强制类型转换0地址为指针并尝试读取
17
18 printk(KERN_ALERT "Dereferenced NULL pointer value: %d, %d\n", *ptr, test);
19
20 return 0;
21 }
22
list
命令用于查看源代码,默认显示源文件的几行代码。
此时,由0x180 is in trigger_init (...kernel_fault.c:18).
可知错误位置指向kernel_fault.c
文件的第18行。如果看到上下方完整的汇编代码好定位问题,还可以继续使用命令 x/i trigger_init+0x48
。
decodecode工具
如上示例,内核错误定位就根本的方法还是尽量多的获取可参考信息,所以调试信息对问题排查至关重要。所以可以再查找一下汇编信息。先反汇编出汇编代码,再具体定位错误位置。操作如下:
在Oops打印末尾有一段以Code:
开始的一串16进制字节代码,内容如下:
[ 46.780171] Code: f90013f5 b9400e80 35000180 d2800000 (b9400001)
该段内容固定为:含出错位置及前4个的机器码(unsigned long
型指令),可以将其使用交叉工具链转译为汇编代码以查看,但过程比较繁琐。借助Linux内存提供的工具decodecode
可以基于截取的Oops错误快速完成转译转译并定位错误位置,示例如下:
$ scripts/decodecode < Oops.file
[ 46.780171] Code: f90013f5 b9400e80 35000180 d2800000 (b9400001)
All code
========
0: f90013f5 str x21, [sp, #32]
4: b9400e80 ldr w0, [x20, #12]
8: 35000180 cbnz w0, 0x38
c: d2800000 mov x0, #0x0 // #0
10:* b9400001 ldr w1, [x0] <-- trapping instruction
Code starting with the faulting instruction
===========================================
0: b9400001 ldr w1, [x0]
从转译出的汇编代码可以看出:汇编代码中有使用ldr
指令将w1
中的值载入到x0
寄存器指向的内存地址,而此时x0
的值为0,即空指针。向空指针写值是非法操作,所以触发了异常。
objdump工具
objdump
是一个 GNU binutils 提供的命令行工具,用于分析和查看二进制文件的内容。objdump
支持多种文件格式(如 ELF、PE),可以帮助开发者了解可执行文件、目标文件和库文件的详细信息。
已知出错的是test_fault.ko
内核模块,所以先确认一个该文件是否可以被objdump工具使用,命令示例:
$ file output/lib/modules/private/test_fault.ko
output/lib/modules/private/test_fault.ko: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), BuildID[sha1]=83117ab2287d5b004365eb2f33ade4eb67b59fa2, with debug_info, not stripped
可知:
test_fault.ko
文件是一个64位(64-bit
)的以小端序(LSB
)组织内容的ELF文件(遵循规范版本version 1
);- 该文件内容为 用于
ARM
平台aarch64
架构,带有debug_Info
调试信息且符号not stripped
保留的可执行文件;
那么就可以使用objdump工具将其反汇编,等到汇编代码。注意:一定使用目标平台ARM aarch64
的交叉工具链下带的objdump工具,命令示例:
$ aarch64-linux-gnu-objdump -hDst test_fault.ko > test_fault.dump
解读:
-h
,显示文件的节区头信息。该选项会输出文件的各个节(section)的详细信息,例如节的名称、大小、虚拟地址、偏移量等;-D
,完整反汇编整个文件的代码段(.text
段)。这个选项会显示每条机器码对应的汇编指令,通常用于查看可执行代码的结构;-s
,显示文件中各个节的内容,以十六进制形式输出。它会显示该节的原始字节数据,方便检查数据段或者在调试时查看文件中的原始二进制数据。-t
,显示符号表,列出所有的符号信息(函数、变量、全局数据等)。符号表包含文件中的符号地址、类型和相关节的信息;> test_fault.dump
,将命令输出结果保存到文件test_fault.dump
中,而不是直接显示在终端。
查看test_fault.dump
文件,可以查找到pc指令的位置trigger_init+0x48/0x1d0
,文件截取如下:
SYMBOL TABLE:
...
0000000000000138 l F .text 00000000000001d0 trigger_init
...
0000000000000138 g F .text 00000000000001d0 init_module
Disassembly of section .text:
...
0000000000000138 <init_module>:
138: d503233f paciasp
13c: a9bd7bfd stp x29, x30, [sp, #-48]!
140: 910003fd mov x29, sp
144: a90153f3 stp x19, x20, [sp, #16]
148: 90000013 adrp x19, 0 <bad_rcu_callback>
14c: 91000260 add x0, x19, #0x0
...
178: d2800000 mov x0, #0x0 // #0
17c: 52800002 mov w2, #0x0 // #0
180: b9400001 ldr w1, [x0]
184: 90000000 adrp x0, 0 <bad_rcu_callback>
188: 91000000 add x0, x0, #0x0
...
由反汇编出的dump文件可知:
-
0000000000000138 l F .text 00000000000001d0 trigger_init
,符号trigger_init
是一个属于代码段(.text
)的起始位置为0x138
且大小为0x1d0
的局部的(l
)函数(F
); -
init_module
是一个全局(g
)的函数,起始位置与大小与trigger_init
相同,可以认为:trigger_init
是init_module
的内联函数; -
0000000000000138 <init_module>:
,init_module
函数的汇编代码起始 -
180: b9400001 ldr w1, [x0]
,出错位置,计算如下: 已知
trigger_init+0x48/0x1d0 [test_fault]
报告中错误位置的偏移量为0x48
,trigger_init
函数的地址为0x138
,所以有出错位置 :0x138
+0x48
=0x180
。 此时执行的操作为:将w1寄存器中的值保存到x0寄存器指向的内存地址,联系上下文可知该地址为0(即空指针),所以触发了异常。
崩溃的调试工具
事前预防
对于常见的、出现概率比较高的内核错误可以针对性的开启一些日志收集手段,在问题发生时能够充分收集错误信息帮助问题排查。按错误类型,对应错误的收集工具与分析如下:
工具 | 作用(对象) | 类型 | 依赖 | 备注 |
---|---|---|---|---|
kdbg参数 | 内核日志等级调整 | 内核配置 | CONFIG_BSP | BSP私有实现 |
sysrq-trigger | 1. 调整日志等级; 2. kdbg调试; | 调试节点 | CONFIG_MAGIC_SYSRQ | 应急使用 |
dynamic_debug | 动态调试 | 调试节点 | CONFIG_DYNAMIC_DEBUG `PRINTK [=y] && (DEBUG_FS [=y] | |
debug_info | vmlinux符号信息 | 内核配置 | CONFIG_DEBUG_INFO | 开发调试阶段 |
kgdb | 调试 | 调试工具 | CONFIG_KGDBHAVE_ARCH_KGDB [=y] && DEBUG_KERNEL [=y] | |
Oops | 将Oops视作Panic处理 | 错误处理,内核配置 | CONFIG_PANIC_ON_OOPS | |
Stack_protector | 栈溢出保护 | 错误检测,内核配置 | CONFIG_STACKPROTECTOR | |
Prove_locking | 死锁检测 | 错误检测,内核配置 | CONFIG_PROVE_LOCKING | |
SOFTLOCKUP | 软锁定系统无法正常调度 | 错误检测,内核配置 | CONFIG_SOFTLOCKUP_DETECTOR |
debug_info
开启内核配置宏CONFIG_DEBUG_INFO
后,便可以在进行内核性能分析或函数调用追踪时,提供更多上下文信息,如函数名的完整映射和源代码行号等。然而,这也会显著增加内核映像的大小,通常仅在开发或调试环境中使用,而不会在生产环境的内核中开启该选项。
以下对比展示CONFIG_DEBUG_INFO
开启前后产物文件的大小
# 无调试信息
$ find linux_5.10/ -name vmlinux -exec ls -alt {} \;
-rwxrwxr-x 1 gaoyang3513 gaoyang3513 13110160 10月 21 20:17 linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/vmlinux
$ find linux_5.10/ -name Image -exec ls -alt {} \;
-rw-rw-r-- 1 gaoyang3513 gaoyang3513 9605632 10月 21 20:17 linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/arch/arm64/boot/Image
$ grep -wrn CONFIG_DEBUG_INFO linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/.config
3640:# CONFIG_DEBUG_INFO is not set
# 有调试信息
$ find linux_5.10/ -name vmlinux -exec ls -alt {} \;
-rwxrwxr-x 1 gaoyang3513 gaoyang3513 148532032 10月 21 20:19 linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/vmlinux
$ find linux_5.10/ -name Image -exec ls -alt {} \;
-rw-rw-r-- 1 gaoyang3513 gaoyang3513 9605632 10月 21 20:19 linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/arch/arm64/boot/Image
$ grep -wrn CONFIG_DEBUG_INFO linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/.config
3640:CONFIG_DEBUG_INFO=y
可见,开启CONFIG_DEBUG_INFO
后变化有:
- vmlinux文件增加100+MB之多,说明调试信息将占用大量存储空间;
- Image文件无明显变化,说明生产使用的镜像文件并不带有调试信息;
参考Linux内核编译Makefile文件中的Image目标生成规则和.cmd.o
文件:
#----> linux_5.10/build/sg2000_milkv_duos_glibc_arm64_sd/arch/arm64/boot/.Image.cmd
cmd_arch/arm64/boot/Image := aarch64-linux-gnu-objcopy -O binary -R .note -R .note.gnu.build-id -R .comment -S vmlinux arch/arm64/boot/Image
说明:
-O binary
,输出二进制文件-R .note -R .note.gnu.build-id -R .comment
,删除.note
、.note.gnu.build-id
和.comment
段内容;-S
,移除所有符号和重定向信息;vmlinux arch/arm64/boot/Image
,输入文件为vmlinux
,输出文件为Image
文件;
总结,ELF文件vmlinux经objcopy工具剥离了调试信息(.note段、符号等)后转化成二进制文件Image。
这两个文件都至关重要,分别作为调试、生产用使用。当使用gdb、kgdb或crash等调试器时,需要使用未压缩、未打包,包含完整的调试信息和符号表的vmlinux
文件;当设备生产、运行和引导时,通常需要不包含调试信息、压缩处理后的纯二进制文件Image(uImage、zImage等)
调试工具
Linux内核提供了多种强大的调试机制,利用这些调试工具辅助问题定位可以达到事半功倍的效果。
kdbg
kdbg是BSP私有实现的,用于控制内核日志等级的启动参数。以F3Plus平台为例,其实现优化位于kernel/hkvs/kernel/kernel/printk/printk.c
。kdbg在U-boot阶段设置,命令示例如下:
# 打印bootargs变量
U-Boot# printenv bootargs
bootargs=earlyprintk console=ttyS0,115200 rootwait nprofile_irq_duration=on root=/dev/ram0 rootfstype=ramfs rdinit=/linuxrc
# 追加kdbg=7
U-Boot# setenv bootargs 'earlyprintk console=ttyS0,115200 rootwait nprofile_irq_duration=on root=/dev/ram0 rootfstype=ramfs rdinit=/linuxrc kdbg=7'
# 保存环境变量以使用修改生效
U-Boot# saveenv
Saving Environment to MMC... Writing to MMC(2)... OK
# 重新启动,见到cmd_line中有kdbg=7即说明生效
U-Boot# reset
Starting kernel ...
...
[ 0.000000] Booting Linux on physical CPU 0x0
[ 0.000000] Linux version 4.19.91 (root@CI-AVI-Slave-71-132) (gcc version 6.5.0 (Buildroot 2019.05.2)) #1-svn501865 SMP PREEMPT Tue Sep 6 16:18:11 CST 2022
...
[ 0.000000] Kernel command line: earlyprintk console=ttyS0,115200 rootwait nprofile_irq_duration=on root=/dev/ram0 rootfstype=ramfs rdinit=/linuxrc kdbg=7 ip=192.0.0.64:192.0.0.128:192.0.0.1:255.255.255.0:NVT-eth:eth0:none bootpart=major
[ 0.000000] Dentry cache hash table entries: 65536 (order: 6, 262144 bytes)
[ 0.000000] Inode-cache hash table entries: 32768 (order: 5, 131072 bytes)
kdbg参数仅在启动时生效,系统运行时修改并不生效,此时可以借助更通用的printk
或sysrq-trigger
节点。
sysrq-trigger
SysRq 是一种特殊的键盘组合键,允许用户在系统遇到严重问题时执行一些紧急操作,例如重启系统、终止进程、同步文件系统等。sysrq-trigger的具体节点为/proc/sysrq-trigger
,该节点在其依赖的内核配置项CONFIG_MAGIC_SYSRQ
开启后才可见。
可以使用如下方式获知sysrq具有的能力集,命令示例如下:
# echo ? > /proc/sysrq-trigger
[ 418.671811] sysrq: SysRq : HELP : show-ps-info(0) Dump-registered-thread-info(1) hik_show_interrupts-info(2) loglevel(0-9) hik_close_sysrq_ops-info(c) kill-all-tasks(i) thaw-filesystems(j) sak(k) show-backtrace-all-active-cpus(l) show-memory-usage(m) nice-all-RT-tasks(n) hik_open_sysrq_ops-info(o) show-registers(p) show-all-timers(q) unraw(r) sync(s) show-task-states(t) unmount(u) show-blocked-tasks(w) dump-ftrace-buffer(z)
以日志前缀可知,该打印为内核日志打印(SSH等终端下使用dmesg命令查看)。每个功能后括号中即是功能对应的组合键。以修改系统日志等级为8为例,借助loglevel(0-9)
键,按键组合为:Ctrl_sysRq
+ 8
,操作方式为:按下ctrl
不放接着按下sysRq
键,放开两个按键再按下8
键,命令生效。
对于部分键盘上没有sysRq
键的情况,也可以直接使用终端输入的方式操作:
# 调试系统日志等级为8
$ echo 8 > /proc/sysrq-trigger
[ 2543.978694] sysrq: SysRq : Changing Loglevel
[ 2543.983058] sysrq: Loglevel set to 8
dynamic_debug
常用的printk
在日志等级满足(< console_loglevel)后将在串口终端上输出打印,而对于一些辅助调试使用的、仅在调试或问题定位时作为信息补充使用的输出信息可以使用动态调试技术。动态调试的优势在于:运行时动态启用或禁用内核代码中的调试信息,而无需重新编译内核。功能由内核配置宏CONFIG_DYNAMIC_DEBUG<br />
又依赖于PRINTK [=y] && (DEBUG_FS [=y] || PROC_FS [=y])
。
挂载debug_fs,命令示例:
$ mkdir -p /data/debugfs && mount -t debugfs none /data/debugfs
利用动态调试技术,可以做到如下特殊处理:
-
某行打印
# 查看动态打印开启情况 $ cat <debugfs>/dynamic_debug/control # filename:lineno [module]function flags format drivers/net/wireless/aicsemi/aic8800/aic8800_fdrv/rwnx_msg_rx.c:519 [aic8800_fdrv]rwnx_rx_ps_change_ind =_ "Sta %d, change PS mode to %s" drivers/net/wireless/aicsemi/aic8800/aic8800_fdrv/rwnx_msg_rx.c:545 [aic8800_fdrv]rwnx_rx_traffic_req_ind =_ "Sta %d, asked for %d pkt" drivers/net/wireless/aicsemi/aic8800/aic8800_fdrv/rwnx_tx.c:1647 [aic8800_fdrv]rwnx_start_mgmt_xmit =_ "TXQ inactive\012" ... # 开启某行的动态打印 $ echo -n 'file svcsock.c line 1603 +p' > <debugfs>/dynamic_debug/control # 关闭某行的动态打印 $ echo -n 'file svcsock.c line 1603 -p' > <debugfs>/dynamic_debug/control
注:
<debugfs>
指代debugfs挂载路径,此处为:/data/debugfs -
某个函数
echo -n 'func svc_process +p' > <debugfs>/dynamic_debug/control
-
某个文件
echo -n 'file svcsock.c +p' > <debugfs>/dynamic_debug/control
-
某个模块
echo -n 'module nfsd +p' > <debugfs>/dynamic_debug/control
可知:
control
节点可使用cat、echo进制读写,分别罗列所有可调整的动态打印和设置;- 所有动态调试打印默认为关闭状态,可手动开启与再关闭;
- 动态调试控制手段灵活,从细到粗分为:行、函数、文件、模块、目录;
kgdb (待补充)
对于BSP开发而言,常需要调试设备驱动,如果能够在真机上调试是在合适不过的。当前比较合适的方案是:gdb + kgdb 方案。
其他
- 耗时分析:bootgraph;
- 性能分析:perf;
- 调用分析:ftrace;
防护工具
及时发现潜在的风险和阻止问题进一步恶化对系统可靠性而言都显得必要,所以建议Linux内核开发中开启如下几种检测工具。这些工具都在Linux内核中集成,通过配置即可开启或关闭。所以先了解一下Linux内核的配置与裁剪。
Linux内核会自动依据依据Kconfig推导各功能配置宏之间的关系,使用方式更新内核配置。以下以DuoS开发板为例,说明内核配置修改:
-
make xxx_defconfig
,选择目标板适配的内核默认配置文件(常以_defconfig
作为文件名结尾,对于未指定配置的项使用默认配置(Kconfig定义)),最终生成.config
文件,如:$ make cvitek_sg2000_milkv_duos_glibc_arm64_sd_defconfig ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
-
make menuconfig
,打开菜单界面手动修改配置,命令与显示效果图如下:$ make menuconfig ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
截图中,顶部可知:当前架构为
arm64
,DEBUG_INFO功能的开关位于kernel hacking > Compile-time checks and compiler options
路径下。根据提示,方括号[]
只能使用Space(空格)
键勾选或取消。使用方向->(右)
键调整光标至Save
保存修改;连接按下Esp(退出)
键退出配置界面,结束配置。 -
make savedefconfig
,根据修改重新生成新的默认配置文件。$ make savedefconfig ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- && mv defconfig $(KERNELDIR)/arch/arm64/configs/cvitek_sg2000_milkv_duos_glibc_arm64_sd_defconfig
使用
savedefconfig
将当前配置以默认配置修改简化出最小配置文件defconfig
,需要将新的配置文件更新回原目标板配置文件。
综上,虽然总能在配置菜单中找到目标配置项,但查找的过程总是繁琐的。对Linux内核的配置仍是推荐手动配置,而当已知需要开启的目标配置宏后,为了快速修改(路过手动配置并自动合并),可以借助Linux内核提供的工具setconfig
。
例如开启kgdb,需要依赖于KGDB
、HAVE_ARCH_KGDB
、DEBUG_KERNEL
共3个配置项,示例命令如下:
$ make -C ${KERNEL_PATH} O=${KERNEL_PATH}/${KERNEL_OUTPUT_FOLDER} setconfig 'SCRIPT_ARG="KGDB=n" "DEBUG_FS=n"'
Oops 视作Panic
内核配置宏CONFIG_PANIC_ON_OOPS
用于开启“将Oops视为内核Panic错误”的处理,即提高了Oops错误的重视程度。因Oops错误常为非致命的、可恢复的内核错误,但发生后却可能一直保留着错误的环境,在此环境下继续业务将会有不可预期的错误。所以及时检出并上报Oops错误就显得十分必要。
开启Panic_on_OOPS
,命令示例如下:
$ make -C ${KERNEL_PATH} O=${KERNEL_PATH}/${KERNEL_OUTPUT_FOLDER} setconfig 'SCRIPT_ARG="CONFIG_PANIC_ON_OOPS=y"'
Stack 边界检查
程序中各数据与分布位置如下图:
其中,本章节主要关注栈的作用、特性与位置信息,以及栈的错误防护。
-
栈的作用
-
管理函数调用
- 当函数被调用时,会为该函数在栈上分配一个栈帧(stack frame);该栈帧保存函数的局部变量、传递的参数、返回地址(调用函数的位置)等信息。
- 当函数执行完毕时,栈帧会被销毁,并恢复到调用该函数时的状态,程序栈中存储了每个函数调用的返回地址(即调用该函数的指令地址)。当函数执行完毕后,程序会通过栈中的返回地址返回到调用处继续执行。
-
自动管理内存
栈用于存储函数的局部变量。这些局部变量只在函数执行时有效,一旦函数返回,栈帧被销毁,局部变量也就不再可用,确保了不同函数的局部变量互不干扰。
-
-
栈的特性
-
先入后出
栈遵循后进先出(LIFO, Last In, First Out)且常为向下生长的原则。最新入栈的函数调用,也会最先出栈返回调用函数,这种特性适用于程序中嵌套函数调用的结构。
所以有如下定义:
- 栈顶,最新插入的数据位置,也是最先被移出的数据位置;
- 栈底,最早插入的数据位置,它位于栈的“底部”,只有在栈顶所有数据被移出后才能访问栈底的数据;
- 入栈(push),将数据放入栈顶;
- 出栈(pop),从栈顶移出数据;
-
栈的大小
程序栈的大小通常是固定的,且有限。Linux内核中,对栈大小控制的由平台相应目录下的
memory.h
文件中修改,如下:#----> linux_5.10/arch/arm64/include/asm/memory.h #define KASAN_THREAD_SHIFT 0 ... #define MIN_THREAD_SHIFT (14 + KASAN_THREAD_SHIFT) ... #define THREAD_SHIFT MIN_THREAD_SHIFT ... #define THREAD_SIZE (UL(1) << THREAD_SHIFT)
可知:
THREAD_SIZE
定义了栈的大小,修改其大小需要由上流的宏MIN_THREAD_SHIFT
决定。arm64平台,MIN_THREAD_SHIFT
值为14,对应栈大小为:16KB(UL(1)<<14)。
-
-
栈的位置
线程内核栈在线程
fork
创建时就分配了。由上图可知:arm64平台下(THREAD_INFO_IN_TASK=y
),内核栈的位置可以由task_struct.stack
追踪到。
由栈的特性,可知:如果函数嵌套过深、分配了过多的局部变量、或局部变量越界访问等都可能会导致栈溢出(Stack Overflow),最终内核崩溃。
Linux内核提供了SCHED_STACK_END_CHECK机制做了防护。SCHED_STACK_END_CHECK开启后,在线程的内核栈末端插入一个特殊标记(通常是固定值),并在特定时间点(如线程切换时)检查该标记。如果标记被破坏,意味着栈可能溢出,内核会采取措施(如记录日志或崩溃)。
开启栈边界检查功能,命令示例如下:
$ make -C ${KERNEL_PATH} O=${KERNEL_PATH}/${KERNEL_OUTPUT_FOLDER} setconfig 'SCRIPT_ARG="CONFIG_SCHED_STACK_END_CHECK=y"'
栈的防护机制还有**STACKPROTECTOR
**,有兴趣可以继续挖掘其防护目标与工作原理。
lock 死锁检测
Linux是一个宏内核设计思想的、支持多任务的操作系统,Linux内核负责对所有线程、资源的调配,对于这些公共的资源,需要采用互斥机制保证原子性与可靠性,手段上常使用锁。内核设计了多种锁,以满足不同场景与性能的要求,但的不当使用锁将导致死锁,造成系统崩溃。内核死锁是指在操作系统的内核中,多个进程或线程由于相互竞争资源而进入了无限等待的状态,导致系统无法继续正常工作。
以下示例几种常见的死锁类型:
-
自引用死锁
递归调用或在同一上下文中多次请求相同的互斥锁会导致自我死锁。
例子:
线程A持有锁L1并在递归调用中再次尝试获取该锁L1,因为锁已被该线程持有,导致线程自我等待。
-
资源竞争死锁
通常发生在多个线程或进程因争夺多个共享资源而互相等待对方释放资源。死锁的典型条件是循环等待,即多个进程形成了一个闭环,彼此等待对方释放资源,导致所有进程都被无限阻塞。
例子:
线程 A 拿到了资源 1,正在等待资源 2,而线程 B 拿到了资源 2,正在等待资源 1。此时,A 等待 B,B 等待 A,形成循环等待,导致死锁。
-
中断上下文死锁
通常发生在中断处理程序试图获取一个可能已经被某个进程持有的锁,而该锁在中断处理程序中是不可阻塞的(因为中断上下文不能被挂起)。如果进程在持有锁的同时中断触发,并且中断处理程序尝试获取相同的锁,这会导致死锁。
例子:
进程 A 获取锁 L1 后进入临界区,处理数据。此时,硬件触发中断,内核进入中断处理程序,而该处理程序也需要锁 L1。由于进程 A 已持有 L1,中断处理程序无法继续执行,但中断上下文无法被阻塞,造成系统死锁。
Linux 内核通过维护一个锁依赖关系图来追踪系统中所有锁的使用情况。如果两个或多个锁之间存在依赖循环,内核会检测到这种循环并报告潜在的死锁情况。在内核中称为lockdep
,开启命令如下 :
$ make -C ${KERNEL_PATH} O=${KERNEL_PATH}/${KERNEL_OUTPUT_FOLDER} setconfig 'SCRIPT_ARG="CONFIG_PROVE_LOCKING=y"'
综上,以上调试、防护工具都比较常用也可以在生产环境中使用,而Linux内核还提供了其他更多功能更强大的工具,如:stackprotector、ftrace、ebpf等,然而将这些工具引入到实现生产中却还要考虑:开销、安全等方面的因素。
扩展
前面的章节中主要介绍错误的定位和调试工具的使用,而对其中底层的原理没有涉及,而底层的这些原因又常是十分复杂的。本章节针对其中几个核心的底层技术尽量用简单的说明来解释。
栈回溯原理
已知Linux内核错误时都会打印栈回溯信息,该信息是定位问题的核心信息,本章节说明栈回溯的原理。其中有几个核心概念需要理解:
寄存器
aarch64架构寄存器展示:
由图可知 :
- 通用寄存器31个,编号:x0 ~ x30。其中:x29 作为 fp 使用,x30 作为 lr 使用;
- 特殊寄存器12个,按CPU的工作状态划分为4个等级(异常等级):EL0 ~ EL3,其中: EL0 用户态,EL1 内核态;
另外使用栈隔离技术,在EL0、1下使用不同的SP寄存器(还CPSR、LR),可以视为:用户态和内核态在出现异常时使用不同的栈。
SP与栈帧
先了解两个概念:
- 栈指针(Stack Pointer, SP):指向当前栈顶的位置;
- 帧指针(Frame Pointer, FP 或 BP):指向当前调用帧的起始位置,保存上一个调用帧的位置(即上一个函数的返回地址和局部变量的位置)。
栈的结构图示如下:
由图可知:
- 两个不同色块即是两个栈帧,自上而下为:调用者(
Caller
)的栈帧,被调用者(Callee
)的栈帧; - 在AArch64 ABI标准中,前八个整数参数通常使用寄存器
x0
到x7
传递,前八个浮点参数则使用寄存器v0
到v7
传递。如果有更多的参数需要传递,将会使用到Stack arg area
(栈参数区)传递参数。 - 被调用者中的FP指针将会指向调用者的FP指针,由此组成了一个单向的链表,用于栈回溯;
以下截取汇编代码,展示**压栈(入栈)、弹栈(出栈)**实现
Disassembly of section .text:
0000000000000000 <trigger_oops>:
0: a9be7bfd stp x29, x30, [sp,#-32]! // sp 向下移动32个单位,依次写入fp'、lr'到栈顶
4: 910003fd mov x29, sp // 更新x29寄存器(FP 帧指针)
8: f9000bf3 str x19, [sp,#16] // 暂存原x19寄存器,写入栈顶向上偏移16个单位的位置
c: 2a0003f3 mov w19, w0 // 入参(w0)保存到x19寄存器,w0 是 x0 寄存器低32的别名
10: aa1e03e0 mov x0, x30 // lr保存到x0,作为调用_mcount的入参
14: 94000000 bl 0 <_mcount> // 调用 _mcount 函数,调试用
18: d2800000 mov x0, #0x0 // #0 // x0寄存器写0
1c: b9400002 ldr w2, [x0] // 调用 从0地址读取值到w2寄存器,参数2
20: 2a1303e1 mov w1, w19 // w19寄存器写入w1寄存器,参数1
24: 90000000 adrp x0, 0 <trigger_oops> // 加载 trigger_oops 符号的页基地到x0寄存器。0 为点位符 指向 链接阶段 trigger_oops 的实际地址,
28: 91000000 add x0, x0, #0x0 // x0寄存器的值加上偏移 0 (点位符),计算出 trigger_oops 的实际地址
2c: 94000000 bl 0 <printk> // 调用 printk 函数,入参w0,w1
30: 52800000 mov w0, #0x0 // #0 // w0寄存器写0
34: f9400bf3 ldr x19, [sp,#16] // 从sp栈顶向上偏移16的位置读取值到x19寄存器,恢复x19寄存器的值(调用者环境)
38: a8c27bfd ldp x29, x30, [sp],#32 // 从sp栈顶向上偏移32的位置读取值到x29、x30寄存器,恢复SP、LR;
3c: d65f03c0 ret // 返回调用者继续执行
其中重要操作的说明:
stp x29, x30, [sp,#-32]!
,可知此次栈帧的大小为32个单位且方向向下。fp、lr的值保存在栈顶位置;mov x29, sp
, 在保存了调用者的sp后,就可以更新fp(x29)为被调用者栈中FP的地址;- aarch64平台中,x0x7寄存器为64位且用于传参,如果参数为32位时,就可以使用寄存器的低32,分别为:w0w7;
ldr x19, [sp,#16]
, x19 ~ x28寄存器作为被调用者使用的临时寄存器,在使用前需要前原值保存到栈中,在退出时恢复;
注:根据ABI规范,栈必须在调用时对齐到16字节的边界。
异常与中断
由寄存器章节可知:aarch64架构在处于不同状态时,将会使用不同的栈。另外,Linux内核aarch64架构异常处理时,会按pt_regs
定义的结构组织栈帧内容。异常与正常运行时的栈帧是不同的,异常时的栈帧将尽量多地收集寄存器等上下文信息,为问题定位提供帮助。以下展示pt_regs
结构:
#----> kernel/arch/arm64/include/asm/ptrace.h
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31]; // 31个通用寄存器
u64 sp; // sp值
u64 pc; // pc值,
u64 pstate; // 线程状态,包含:异常等级
};
};
u64 orig_x0;
u32 unused2;
s32 syscallno; // 系统调用号
u64 orig_addr_limit;
u64 unused; // maintain 16 byte alignment
u64 stackframe[2]; // 栈
};
可知异常时,会额外收集信息:
- 所有通用寄、sp、pc、线程的值;
- pc 指针可以定位:当前执行代码的地址 ;
- pstate 线程状态可获得:当前异常等级EL;
- 系统调用号,但错误信息中并不打印;
可以发现:在Oops错误时,寄存器信息打印顺序与pt_regs
定义的结构一致,由高地址向低地址的成员信息打印。
进程内存分布
以arm架构为例(arm64比较起来稍加复杂)说明线程的内存分布情况,其中有几个核心概念需要理解。
线程与进程
广泛层面,Linux下的线程与进程定义如下:
- 进程(Process):是系统中独立运行的基本单位,拥有自己的地址空间(包括独立的堆栈、堆、全局变量等),相互之间彼此隔离。每个进程的内存、文件描述符等资源是独立的,操作系统通过进程 ID (
PID
) 来管理和区分它们。 - 线程(Thread):是一个共享同一进程资源的执行单元,多个线程在同一个进程的地址空间内运行。每个线程有独立的栈和寄存器状态,但与其他线程共享进程的堆、全局变量、文件描述符等。
总结:进程是资源分配的最小单位,线程是CPU调度的最小单位。
用户线程与内核线程
在Linux内核把进程称作任务(task),进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所有进程共享内核虚拟地址空间,每个进程有独立的用户地址空间。
进程有两种特殊形式:
- 内核线程,没有用户虚拟地址空间的进程;
- 线程(用户线程),共享用户虚拟地址空间的进程称为用户线程;
线程的虚拟地址空间
由图可知:
- aarch32 架构下,可访问的地址空间为4GB(2^32),常有内核与用户空间按 1:3 划分;
- 图为用户线程的内存分布(有用户虚拟地址空间);
参考
-
内核参数文档
- 内核污染:
- kernel/Documentation/admin-guide/tainted-kernels.rst
- kernel/Documentation/translations/zh_CN/oops-tracing.txt,中文
- 系统调用:kernel/Documentation/sysctl/kernel.txt
- 内核日志:linux_5.10/Documentation/core-api/printk-basics.rst
- 错误调试:
- linux_5.10/Documentation/admin-guide/bug-hunting.rst
- linux_5.10/Documentation/admin-guide/README.rst
- 内核污染:
-
程序内存分部
-
https://www.cnblogs.com/hornets/p/12461380.html
-
https://cloud.tencent.com/developer/article/1603829
-
https://www.cnblogs.com/clover-toeic/p/3754433.html
-
-
arm64 过程调用标准
- https://blog.csdn.net/weixin_43412488/article/details/141601194
- https://armv8-doc.readthedocs.io/en/latest/09.html