KGDB原理分析及远程挂载调试ARM64内核
为什么使用KGDB
我们在开发或者是调试驱动过程中,往往会通过串口的打印日志来分析驱动运行逻辑,定位问题。但是这种方法需要我们增加大量的打印日志来实现,有时候日志加的地方不对,可能打印不出来,就要重新加,编译驱动烧录,这个过程是很痛苦的。那有没有一种方法,可以实现驱动的断点调试呢?答案是有的,可以使用KGDB在内核态进行源码级别的调试。
KGDB原理分析
简介
kgdb是内核提供的一种内核调试程序,kgdb将自身代码插入在内核中,编译后,与内核一起作为一个整体的程序运行。所以kgdb本身运行在内核空间,因此kgdb可以访问内核空间或者用户空间,获取相应的被调试程序的数据信息。在2.6.25以后的内核版本,kgdb已经被整合到内核中,是内核源码的一部分,要开启kgdb调试,只需要添加对应的kgdb配置选项即可。
kgdb调试的连接通信过程和gdbserver类似,需要远程服务器的gdb配合。target和host之间通过串口或网络连接。target运行配置了kgdb的内核,host运行gdb,中间使用RSP协议进行通信。gdb向kgdb发送RSP命令包,kgdb解析命令包,从而实现target的内核调试。调试模型见下图:
调试原理
kgdb是一种stub调试程序,它是Linux内核的一部分,会和内核一起编译运行。那么Linux内核运行的好好的,kgdb如何打断Linux内核获取CPU控制权呢?在获取到CPU控制权,进入到kgdb源码后又会做一些什么事情?
kgdb其实是在异常通知链 (die_notifier)
中注册自己的回调函数 kgdb_notify
,在step_hook以及break_hook里注册钩子函数,捕获Linux内核产生的异常,实现对Linux的控制。注册代码如下所示:
//arm64的注册oops异常代码
static struct notifier_block kgdb_notifier = {
.notifier_call = kgdb_notify,
/*
* Want to be lowest priority
*/
.priority = -INT_MAX,
};
register_die_notifier(&kgdb_notifier);
static struct break_hook kgdb_brkpt_hook = {
.esr_mask = 0xffffffff,
.esr_val = (u32)ESR_ELx_VAL_BRK64(KGDB_DYN_DBG_BRK_IMM),
.fn = kgdb_brk_fn
};
static struct break_hook kgdb_compiled_brkpt_hook = {
.esr_mask = 0xffffffff,
.esr_val = (u32)ESR_ELx_VAL_BRK64(KGDB_COMPILED_DBG_BRK_IMM),
.fn = kgdb_compiled_brk_fn
};
static struct step_hook kgdb_step_hook = {
.fn = kgdb_step_brk_fn
};
register_break_hook(&kgdb_brkpt_hook);
register_break_hook(&kgdb_compiled_brkpt_hook);
register_step_hook(&kgdb_step_hook);
可看出arm64,是把 kgdb_notify
注册到了最低优先级的位置,内核发生oops异常后,会先处理正常的异常处理函数,完成后才会执行kgdb异常处理函数,kgdb响应完成后,会接着正常往下运行,等待下次异常触发。
由于kgdb捕获了所有的Linux内核异常,所以在启用kgdb的情况下,内核崩溃后,会进入kgdb,等待开发者收集异常信息。其工作流程如下所示:
可看出,kgdb共有5种触发方式,其中3种是首次触发KGDB的方式:
- 内核挂死后自动触发
- 内核正常运行时,使用
echo g > /proc/sysrq-trigger
手动触发。这里其实就是使用sysrq
执行了一个bkpt
的汇编指令,触发了CPU异常,从而进入kgdb。 - 在启动参数中加入
kgdbwait
字段,可以在内核启动时触发。
另外的断点和单步调试触发方式是进入到kgdb后,打断掉或者单步调试触发的。
其工作机制决定了使用kgdb调试的过程是被动调试:即Host的GDB不能主动暂停内核的运行,只有target自己主动触发异常,Host的GDB才可以通过kgdb接管内核。
部分RSP命令的解析
m/M
命令
m/M命令读/写内存的命令,kgdb收到该命令后,会解析协议包中的要读/写的地址:addr
和数量:count
,最终会调用到 probe_kernel_read()/probe_kernel_write()
函数。这两个函数其实是在内核态访问内存地址的函数。所以kgdb当前只能返回内核态的内存数据给HOST GDB,对于硬件寄存器的数据,就没法返回了,不过可以根据地址分布去拓展这个指令。
Z/z
命令
Z/z
命令是设置/移除断点的命令,kgdb收到该命令后,会解析协议包中的要设置/移除断点的PC地址:addr
。随后会调用 kgdb_arch_set_breakpoint()/kgdb_arch_remove_breakpoint()
函数设置/移除断点。函数原型如下所示:
int kgdb_arch_set_breakpoint(struct kgdb_bkpt *bpt)
{
int err;
BUILD_BUG_ON(AARCH64_INSN_SIZE != BREAK_INSTR_SIZE);
err = aarch64_insn_read((void *)bpt->bpt_addr, (u32 *)bpt->saved_instr);
if (err)
return err;
return aarch64_insn_write((void *)bpt->bpt_addr,
(u32)AARCH64_BREAK_KGDB_DYN_DBG);
}
int kgdb_arch_remove_breakpoint(struct kgdb_bkpt *bpt)
{
return aarch64_insn_write((void *)bpt->bpt_addr,
*(u32 *)bpt->saved_instr);
}
可看出,设置断点其实就是在 addr
处添加 AARCH64_BREAK_KGDB_DYN_DBG
,即 bkpt
异常。当程序走到该地址时,就会触发break异常,进入到break的钩子函数,,从而kgdb获得控制权,继续和HOST GDB通信。在设置断点时,kgdb会把 addr
处的值取出存起来,移除断点就是将先前 addr
的值恢复。
s
命令
s
命令用于响应HOST GDB的s/n指令,即GDB单步调试的步入和步出指令。最终会调用到平台相关的 kgdb_arch_handle_exception()
函数中。当HOST GDB执行步入指令时,HOST GDB发送的RSP协议包中的 addr
部分会是0。当HOST GDB执行步出指令时,HOST GDB发送的RSP协议包中的 addr
部分会是步出后的程序地址。kgdb会根据addr的值来判断是步入还是步出,具体函数如下:
static void kgdb_arch_update_addr(struct pt_regs *regs,
char *remcom_in_buffer)
{
unsigned long addr;
char *ptr;
ptr = &remcom_in_buffer[1];
if (kgdb_hex2long(&ptr, &addr))
kgdb_arch_set_pc(regs, addr);
else if (compiled_break == 1)
kgdb_arch_set_pc(regs, regs->pc + 4);
compiled_break = 0;
}
void kgdb_arch_set_pc(struct pt_regs *regs, unsigned long pc)
{
regs->pc = pc;
}
可看出,当解出的addr为0时,kgdb会将 pc+4
的值存到 regs->pc
中。当addr不为0时kgdb会将 addr
的值存到 regs->pc
中。以arm64平台为例,之后会执行 kernel_enable_single_step()
函数对处的地址加上 DBG_MDSCR_SS
(step异常)。当走到对应的程序地址处便会触发step异常,kgdb获得控制权,继续与HOST KGDB通信。
void kernel_enable_single_step(struct pt_regs *regs)
{
WARN_ON(!irqs_disabled());
set_regs_spsr_ss(regs);
mdscr_write(mdscr_read() | DBG_MDSCR_SS);
enable_debug_monitors(DBG_ACTIVE_EL1);
}
这里需要注意一下,触发step异常时,某些ARM64平台的CPU会一直挂死在 el1_irq
里无法退出,无法继续调试,原因未知。具体代码如下:
el1_irq:
kernel_entry 1
enable_da_f
#ifdef CONFIG_TRACE_IRQFLAGS
bl trace_hardirqs_off
#endif
irq_handler
#ifdef CONFIG_PREEMPT
ldr x24, [tsk, #TSK_TI_PREEMPT] // get preempt count
cbnz x24, 1f // preempt count != 0
bl el1_preempt
1:
#endif
#ifdef CONFIG_TRACE_IRQFLAGS
bl trace_hardirqs_on
#endif
kernel_exit 1
为了解决这个问题,我在内核社区找到了一个补丁,不过该补丁未合进内核,补丁的作用是在kgdb单步调试时禁用中断,等单步调试完成后恢复中断。具体修改如下:
int kgdb_arch_handle_exception(int exception_vector, int signo,
int err_code, char *remcom_in_buffer,
char *remcom_out_buffer,
struct pt_regs *linux_regs)
{
int err;
switch (remcom_in_buffer[0]) {
//前面的代码省略,只看修改内容...........
case 's':
/* 单步调试时禁用中断 */
__this_cpu_write(kgdb_pstate, linux_regs->pstate);
linux_regs->pstate |= PSR_I_BIT;
/*
* Update step address value with address passed
* with step packet.
* On debug exception return PC is copied to ELR
* So just update PC.
* If no step address is passed, resume from the address
* pointed by PC. Do not update PC
*/
kgdb_arch_update_addr(linux_regs, remcom_in_buffer);
atomic_set(&kgdb_cpu_doing_single_step, raw_smp_processor_id());
kgdb_single_step = 1;
/*
* Enable single step handling
*/
if (!kernel_active_single_step())
kernel_enable_single_step(linux_regs);
err = 0;
break;
default:
err = -1;
}
return err;
}
static int kgdb_step_brk_fn(struct pt_regs *regs, unsigned int esr)
{
unsigned int pstate;
if (user_mode(regs) || !kgdb_single_step)
return DBG_HOOK_ERROR;
if (kernel_active_single_step())
kernel_disable_single_step();
/* 单步调试后恢复中断 */
pstate = __this_cpu_read(kgdb_pstate);
if (pstate & PSR_I_BIT)
regs->pstate |= PSR_I_BIT;
else
regs->pstate &= ~PSR_I_BIT;
kgdb_handle_exception(0, SIGTRAP, 0, regs);
return DBG_HOOK_HANDLED;
}
如何使用KGDB
内核启用KGDB
在2.6.25以后的内核版本,kgdb已经被整合到内核中,是内核源码的一部分,开启KGDB只需要启用如下内核配置选项:
CONFIG_KGDB=y
CONFIG_KGDB_KDB=y
CONFIG_DEBUG_INFO=y
CONFIG_GDB_SCRIPTS=y
CONFIG_DEBUG_INFO_DWARF4=y
config | desc |
---|---|
CONFIG_KGDB=y | 使能KGDB |
CONFIG_KGDB_KDB=y | 使能KGDB_KDB |
CONFIG_DEBUG_INFO=y | 生成debug信息 |
CONFIG_DEBUG_INFO_DWARF4=y | 生成等级4的debug信息 |
CONFIG_GDB_SCRIPTS=y | 生成linux GDB调试脚本 |
重新编译内核,烧入设备。
修改内核启动参数
在内核启动参数后面加入 nokaslr kgdboc=ttyAMA0,115200
。
KGDB与Server的远程连接
在上文中,我们了解到,KGDB是使用串口和HOST连接的。但是当我们是PC连接Server开发的话,我们的设备只能通过串口跟我们的PC机连接,无法连接至开发的Server。但是我们PC机又无法运行GDB连接target。为了解决这个矛盾,可以在PC机上将与板子相连的串口虚拟成TCP端口,编译服务器端运行GDB,远程连接PC机虚拟出的TCP端口进行调试。具体流程如下:
示例
设备启动后,进入根文件系统。设备端执行步骤:
- 关闭看门狗
- 使用sysrq触发异常
echo g > /proc/sysrq-trigger
- 输入kgdb等待远程主机调试
kgdb
PC端执行步骤:
如果是使用服务器开发的话,使用虚拟串口软件将PC连接设备的com口转发为tcp端口,
- 设置监听端口
- 设置串口
- 设置波特率
- 开启TCP转发服务
服务器运行gdb直接target remote pc_ip:port
,其中pc_ip
是自己电脑的本地ip,port
是com口转tcp的端口号
就跟正常使用gdb是一样的,功能很强大,当调试驱动时,可以在server端gdb连接上设备kgdb后使用内核调试脚本自带的工具lx-symbols
将要调试的ko符号表链接到vmlinux后,进行源码级调试。
Linux内核GDB Script命令集简介
命令 | 功能 |
---|---|
lx-cmdline | 显示当前内核的命令行 |
lx-cpus | 列出CPUS当前状态 |
lx-dmesg | 打印内核日志,类似直接在shell敲dmesg |
lx-fdtdump | dump设备树到文件 |
lx-iomem | 打印当前iomem资源 |
lx-ioports | 打印当前ioport资源 |
lx-list-check | 验证列表一致性 |
lx-lsmod | 列出当前插入的ko |
lx-mounts | 显示当前VFS的挂载点 |
lx-ps | dump当前linux的tasks |
lx-symbols | 加载ko符号表到当前调试内核 |
lx-version | 显示当前内核的版本信息 |
lx_current | 显示当前正在运行的task |
lx_module | 按名称查找模块 |
lx_per_cpu | 查看per cpu变量 |
lx_task_by_pid | 通过pid寻找task结构体指针 |
lx_thread_info | 通过task指针查看thread信息 |
lx_thread_info_by_pid | 通过pid来查看thread信息 |