carloscn/blog

01_LinuxDebug_调试理论和基础综述

carloscn opened this issue · 0 comments

01_LinuxDebug_调试理论和基础综述

我们在Linux应用层空间引入了几个调试工具:

本节主要想论述Linux Kernel等级的调试理论和基础,本文包括:

  • 对于Linux内核环境调试的搭建(以ARMv8为主,ARMv7为辅)
  • 调试相关基础知识说明
  • 不断的追加一些调试方法论

1. ARM64的实验平台

关于交叉表编译环境和一些平台的要求,可以参考:在MACBOOK上搭建ARMv8架构的ARM开发环境 。 MAC系统只能编译一些baremental相关的applications,并不能编译内核和linux的应用。对应对于QEMU环境参考:Starting with JLink debugger or QEMU

1.1 实验平台

1.1.1 初始化板级环境

我们的实验平台如图,选择树莓派的4B,是以BCM2711(ARMv8 Cortex-A72)为主的实验平台。

树莓派默认的启动对于一个完整的ARMv8的环境,应该包含:

  • AFT👇🏻
  • uboot👇🏻
  • Kernel

但由于树莓派本身的bootrom限制,只能按照以下的方式启动12

对于device的实验系统按照以下步骤进行:

我已经在每一个仓库的readme里面指引如何编译和配置了,这里不再赘述了。最终到这里完成ATF->uboot->kernel的完整流程。

1.1.2 host调试环境

这里分为两个环境,一个是baremental环境,一个是kernel环境。两个环节比较类似,但是还有不同,因为baremental环境工具比较少,不像是linux kernel可以使用大量的工具。但是底层逻辑都是一样。

调试ARM板子需要和ARM板子建立联系的方式,对于baremental和linux内核,都需要辅助的硬件工具,比如DS-5、JLINK仿真器;而对于Linux userspace的应用程序,由于Linux内核强大的任务支持,直接就可以使用gdbserver在device板子上就可以了。

baremental及linux内核的调试,本质也是使用仿真器开启一个gdbserver监听,host端使用gdb接入加载elf符号(kernel是vmlinux)一步步调试。

本节讲述使用jlink仿真器来调试ARMv8(穷,买不起DStream,我也只是在公司使用过)。在HOST端使用vscode作为调试界面(gdb client),Device使用JLink的openocd环境(gdb server)。

电路连接

JTAG接口 树莓派IO定义
JTAG接口 树莓派管脚号 树莓派排管脚名称
VTref 01 3.3v
TRST 15 GPIO22
TDI 37 GPIO26
TMS 13 GPIO27
TCK 22 GPIO25
RTCK 16 GPIO23
TDO 18 GPIO24
GND 39 GND

config.txt

调试还需要在config.txt上做一些手脚:

[pi4]
kernel=loop.bin

[all]
arm_64bit=1 
enable_uart=1 
uart_2ndstage=1 

enable_jtag_gpio=1
gpio=22-27=a4
init_uart_clock=48000000
init_uart_baud=115200

loop.bin位置,可以换成uboot也可以换成裸机环境。 这里面有如何启动OPENOCD进入调试环境及JLINK的配置文件:Starting with JLink debugger or QEMU

host config

调试机使用vscode,主要要配置一个launch.json文件,告诉如何连接gdb server:

{
    "version": "0.2.0",
    "configurations": [
        {
            // 自定义,名字,看起来有意义就行,用来给你选的;
            "name": "armv7-debug",
            // 调试的是 go 程序
            "type": "cppdbg",
            // attach 进程的方式
            "request": "launch", // launch
            // auto、debug、remote
            "mode": "debug",
            // 调试的程序,当运行单个文件时{workspaceFolder}可改为{file}
            "program": "${workspaceFolder}/linux/test_pid/test_pid.elf", // ${workspaceFolder}/filename or ${file} or ${workspaceFolder}
            "cwd": "${workspaceFolder}/linux/test_pid/",
            "miDebuggerPath": "/opt/cross-compile/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/aarch64-linux-gnu-gdb",
            "miDebuggerServerAddress": "localhost:3333",
            "env": {
                // "REDIS_ADDR": "localhost:1270",
                // "REDIS_PWD": ""
            },
            "args": [
                "-f", "server2.json"
            ]
        },
    ]
}

里面需要改一些参数,比如elf加载地址之类的。

编译关闭优化与开启符号

如果我们使用调试版的编译数据,一定要注意:

  • 使用-g,让elf文件带符号,这样人容易读;
  • 关闭优化:
    • -O0:表示关闭所有的编译器优化(调试时候需要选的,否则调试时候光标乱跳)
    • -O1:表示基本的优化
    • -O2:表示比基本的优化进行更高层级的优化(Linux内核默认编译选项)

如果是Linux内核可以在Makefile中的O2改为O0。

这个就是调试uboot界面的程序:

可以看到每一步的变量和寄存器的值。

uboot重定位

这里不得不提一下在调试的时候需要十分注意的uboot重定位的问题。由于我们的计算机是分层存储结构的,编译的程序从host编译存储在[host硬盘上]->[设备端外存] -> [DDR] -> [Cache(SRAM)] -> [CPU],这种多级的分层的存储结构就导致每经过一次存储介质的转存,就要对程序重定位一次,否则CPU就没有办法找到这部分程序。

  • HOST硬盘:使用交叉编译器对程序进行编译
    • 汇编器生成的目标文件.o 地址仅仅是符号和符号间相对应的逻辑地址,没有任何的意义
    • 链接器链接多个目标文件生成的elf地址,此时和多个.o文件的符号建立联系,使用objdump可以看到的是链接地址
  • 设备外存:我们使用各种方法把elf二进制文件转移到设备外存上,例如EMMC,SD卡,FLASH等等。此时设备存储在这些外存的文件系统中依旧是保持编译后的链接地址。
  • DDR阶段:大多数程序是为了提高执行效率是把外存上的二进制文件加载到DDR中的,那么DDR加载位置的地址就是加载地址。由此引出了CPU运行的时候的运行地址,运行地址就是PC指针的位置,PC到哪里运行地址就是哪里。因此运行地址和加载地址可以一致也可以不一致:
    • 如果一致:使用位置无关指令/位置有关指令都可以运行;
    • 如果不一致:只有使用位置无关指令才可以运行。
  • SRAM阶段
    • SRAM阶段其实地址是0,如果加载程序,必须要加载到0的位置,如果此时DDR的加载地址比如是0x400000,需要保证DDR复制到SRAM的程序需要使用位置无关指令。
    • DDR数据加载到SRAM这部分工作是需要BOOTROM程序来完成的。

问题:为什么要刻意的设置加载地址、运行地址、链接地址这些不一致的地址

因为考虑到SRAM的容量非常小,也想更高效的利用比外存速度更快的DDR。所以需要分批进行加载和处理。uboot实际上就是这样做的,这也是经常说的uboot重定位。

过程是:

  • bootrom在复位阶段把外存上的前4KB的代码加载到SRAM中,这部分可以理解为uboot的stage1;
  • bootrom初始化ddr内存,把uboot真身解析出链接地址(比如0x40000000),并把程序放置到0x400000000位置;
  • 此时CPU运行状态是,执行地址是0x00,链接地址是0x400000000。
  • bootrom执行完毕之后,使用LDR直接跳转到0x400000000的地址开始执行uboot真身。此时链接地址和运行地址完全一致。

这就是uboot重定位的过程,我们调试的时候要有意识的知道,当我们加载程序调试的时候,要十分小心链接地址和PC执行的地址。我们上面使用telnet加载uboot的u-boot.bin的时候实际上已经过了BOOTROM阶段,只是把链接地址和PC都指向了0x80000,属于重定位后的结果。

MMU陷阱

uboot重定位是一个我们需要小心的问题。而启动内核的时候还需要注意MMU的陷阱,因为内核启动过程需要启动MMU,一旦启动MMU之后,在baremental按照物理地址寻址的方式变成了按照虚拟地址寻址。对于这部分处理过程,我们必须门清。

最直观的调试感受是,在调试.stext指定的内核head.S入口的时候,mmu开启之前的代码根本无法设定断点。这个根本原因就是无论是vmlinux的linker文件,还是 system.map符号记录表,都是使用虚拟地址管理的。当我们加载内核的符号的时候,也都是虚拟地址表示,gdb也只认指定的符号地址,而在开启MMU之前的汇编代码只能使用物理地址才能访问到。因此,在开启MMU之前,我们必须推算出head.S的入口函数的物理地址是多少,然后调试的时候强行指定物理地址。

当跳转到Linux内核的时候,uboot需要把Linux内核image加载到DDR中,这个无论是通过nfs还是fatload方式实际上都是加载到kernel_addr_r的DDR上面。然后uboot跳转到内核stext函数入口地址。

所有的符号地址都是虚拟地址

内核启动的时候也会有个一个重定位的过程。这个过程在__primary_switch汇编函数中。

SYM_FUNC_START_LOCAL(__primary_switch)
#ifdef CONFIG_RANDOMIZE_BASE
	mov	x19, x0				// preserve new SCTLR_EL1 value
	mrs	x20, sctlr_el1			// preserve old SCTLR_EL1 value
#endif

	adrp	x1, init_pg_dir
	bl	__enable_mmu
#ifdef CONFIG_RELOCATABLE
#ifdef CONFIG_RELR
	mov	x24, #0				// no RELR displacement yet
#endif
	bl	__relocate_kernel
#ifdef CONFIG_RANDOMIZE_BASE
	ldr	x8, =__primary_switched
	adrp	x0, __PHYS_OFFSET
	blr	x8

	/*
	 * If we return here, we have a KASLR displacement in x23 which we need
	 * to take into account by discarding the current kernel mapping and
	 * creating a new one.
	 */
	pre_disable_mmu_workaround
	msr	sctlr_el1, x20			// disable the MMU
	isb
	bl	__create_page_tables		// recreate kernel mapping

	tlbi	vmalle1				// Remove any stale TLB entries
	dsb	nsh
	isb

	set_sctlr_el1	x19			// re-enable the MMU

	bl	__relocate_kernel
#endif
#endif
	ldr	x8, =__primary_switched
	adrp	x0, __PHYS_OFFSET
	br	x8
SYM_FUNC_END(__primary_switch)

这个函数就是开启mmu之后对内核进行重定位的函数。通过br指令跳转到这个函数中。

primary_switched

我们可以通过一些小手段来推导出vmlinux重定位之前的物理地址,然后在gdb启动调试的时候强制指定stext链接地址为物理地址,调试到primary_switch的时候,linux内核会自动重定位到虚拟内存地址。

找到链接文件:vmlinux.lds.S

可知readelf读出的内核映像.head.text段的起始虚拟地址是0xffff ffc0 0800 0000,可以推出KIMAGE_VADDR的虚拟地址是0xffff ffc0 0000 0000,所以head.text的偏移量是0x0800 0000,加上DDR内存的其实地址0x4000 0000得到这个的物理地址为0x4800 0000

相应地我们也可以推出其他的ADDR:

  • .head.text :0x4800 0000
  • .text : 0x4810 0000
  • .rodata:....
  • .init.text :....

要注意一下,入口函数primary_entry实际被映射到__INIT位置

在init.h中找到__INIT字段被映射到了init.text段,我们从init.text段就可以确定入口函数的地址。

我们在使用GDB的时候,可以用GDB的add-symbol-file的功能加载和读取vmlinux的符号表:

(gdb) add-symbol-file vmlinux 0x48100000 -s .head.text 0x48100000 -s .text .....
把PC强制移动到入口函数地址,也就是.init.text段的位置(假设.init.text的地址是0x41a6000000):

(gdb) set $pc=0x41a6000000

接下来就可以调试入口函数的汇编代码了。

REF

Footnotes

  1. aspberry-pi-4-boot-flow

  2. ARM Trusted Firmware on Raspberry Pi 4