很多人开发C程序就像写脚本那样,查错,靠的是眼睛看;但是作为系统开发程序员,bug单纯靠看是查不出来的,于是就用printf,于是每一次查错都不可避免要机械地写printf、编译、烧写、运行,没有IDE的时候烧写、运行还不太方便。如果能在命令行下面设断点、单步调试什么的,想必是极好的。

有时候开发一些程序还不得不在Ubuntu下弄,比方说国外的不少开源项目像px4、openmv之类的,又比方说Linux、nuttx之类的系统开发,等等。当然诸如Eclipse之类的IDE配置一下也能聊胜于无,但是配置它们的复杂度不亚于在命令行下面弄,而且连接板子、开gdb server这些工序只能在命令行下完成;好处是调试时候能用鼠标操作。

其实,命令行下面用gdb调试,熟悉基本命令之后,用起来相当顺手;在TUI模式下面调试,视觉效果不亚于使用IDE。

需要安装的软件

gdb

包管理装就行:

$ sudo apt-get install gdb-arm-none-eabi

当然也可以选用别的,比方说

  • arm-uclinuxeabi-2010q1里面就有arm-uclinuxeabi-gdb。不过这个的TUI有问题。
  • SourceForge的uclinux下载页面(崩掉了)

ubuntu下jlink驱动

segger官网下载页面上找32-bit或64-bit的deb。如果你用的是盗版jlink,则5点几的老版本驱动可能更稳定

segger官网
segger官网

使用dpkg命令安装

$ sudo dpkg -i jlink_5.10.16_x86_64.deb

现在就有JLinkxxxxx的命令了,常用的是JLinkExeJLinkGDBServer

jlink的手册里详述了命令行工具的用法和参数。jlink功能很强大,写好配置文件,并且初始化好板子的存储器接口,甚至可以将程序烧到片外nor flash上面(因为现在nor flash基本上都符合CFI规范了)。

编译参数

用gcc编译程序时加上-g参数即可,链接时不用加。需要注意的是,如果使用链接脚本的话,不要删去无关段,因为调试信息就在一系列的debug段里面。
gcc的调试参数还分级别。不过一般而言一个-g就够了。

在一些工程里面,需要在Makefile里面改CFLAGS来添加编译参数;或者make CFLAGS=xxxxxx这样来弄。
而对于Linux内核工程来说,它专门有一个配置叫CONFIG_DEBUG_INFO,选择它就行,不需要翻找Makefile。

JLinkExe基本命令

JLinkExe基本上就用来烧程序。假设要烧录的芯片是STM32F429ZIT6,用jlink连好板子后,开启JLinkExe

$ JLinkExe -device stm32f429zi -if swd -speed 4000

命令中选用了SWD接口,速度4000kHz。

$ JLinkExe -device stm32f429zi -if swd -speed 4000
SEGGER J-Link Commander V5.10p (Compiled Feb 26 2016 19:06:09)
DLL version V5.10p, compiled Feb 26 2016 19:06:05

Connecting to J-Link via USB...O.K.
Firmware: J-Link ARM-OB STM32 compiled Aug 22 2012 19:52:04
Hardware version: V7.00
S/N: 20090928
License(s): RDI,FlashDL,FlashBP,JFlash,GDBFull
Emulator has Trace capability
VTref = 3.300V


Type "connect" to establish a target connection, '?' for help
J-Link>

这时已经识别到了jlink,用connect命令来连接板子:

J-Link>connect
Device "STM32F429ZI" selected.


Found SWD-DP with ID 0x2BA01477
Found SWD-DP with ID 0x2BA01477
Found Cortex-M4 r0p1, Little endian.
FPUnit: 6 code (BP) slots and 2 literal slots
CoreSight components:
ROMTbl 0 @ E00FF000
ROMTbl 0 [0]: FFF0F000, CID: B105E00D, PID: 000BB00C SCS
ROMTbl 0 [1]: FFF02000, CID: B105E00D, PID: 003BB002 DWT
ROMTbl 0 [2]: FFF03000, CID: B105E00D, PID: 002BB003 FPB
ROMTbl 0 [3]: FFF01000, CID: B105E00D, PID: 003BB001 ITM
ROMTbl 0 [4]: FFF41000, CID: B105900D, PID: 000BB9A1 TPIU
ROMTbl 0 [5]: FFF42000, CID: B105900D, PID: 000BB925 ETM
Cortex-M4 identified.
J-Link>

倘若连不上,或许板子布线的不好,可以适当降低调试接口的时钟频率,1000kHz、500kHz、100kHz等等。
也有可能此时板子正处在一种很诡异的状态,它程序或许在跑,但是调试口连不上(比方说我在SDRAM里面跑程序的话有时候是连不上的);此时,先按着复位键,输入connect命令回车,然后马上松开复位键,基本上都可以连上。
还有可能是你程序写的不好,比方说,你超频得太厉害了(本来是25MHz的晶振,你当成8MHz来初始化了),这种情况你按着复位键也连不上,只能够用镊子短路晶振,复位一下,然后再connect。
有些时候程序貌似没问题但是也连不上,短接晶振之后居然连上了,我有一次调PX4FLOW时候就是这种情况。这没办法,只能老老实实查代码。
当然也有些可能是芯片烧掉了。STM32内核烧掉的情况真不少见,拆机件都不是特别靠谱。总之,jlink居然都连不上板子,想必是有些问题了。

问号可以查询JLinkExe都有哪些命令。输出很多。其中有几个是经常要用的:

  • r,即reset,复位板子
  • g,即go,代码继续运行
  • h,即halt,暂停运行
  • regs,查看寄存器。看寄存器用处挺大的,看PC、LR、PSR值,会知道此时CPU到底正常与否,到底发生了什么fault。
  • mem8mem16mem32,查看对应内存地址的值。比方说出现了hardfault,这时候查看NVIC的hardfault寄存器,看看哪个fault上访;比方说出现了bus fault,查看是哪种总线错误,查看出错的地址等等。用处相当大。
  • w1w2w4,将值写入对应的内存地址。写脚本时候可以用这些命令来初始化外设寄存器。这时就要老老实实翻手册,计算哪个寄存器应该写哪些值。
  • erase,擦除整个片内flash
  • loadbin,烧写程序,格式是loadbin 哪个bin 哪个地址。蛋疼的是,它只认.bin后缀的文件。

用loadbin命令烧写程序。比方说烧写本目录下的xipImage.bin:

J-Link>loadbin xipImage.bin 0x08008000         
Downloading file [xipImage.bin]...Comparing flash   [100%] Done.
Erasing flash     [100%] Done.
Programming flash [100%] Done.
Verifying flash   [100%] Done.
J-Link: Flash download: Flash programming performed for 2 ranges (262144 bytes)
J-Link: Flash download: Total time needed: 12.326s (Prepare: 0.163s, Compare: 0.739s, Erase: 3.303s, Program: 8.100s, Verify: 0.006s, Restore: 0.013s)
O.K.
J-Link>r
J-Link>g
J-Link>

烧完之后记得复位、启动。

这些命令都可以写在脚本里面,然后JLinkExe -commanderscript xxxxxx来运行脚本。比方说要烧写一批板子,当然写脚本方便啦。

有时候烧程序会失败,直接failed to download RAM code。这或许是板子布线不好,降低调试时钟频率就行;又或许是板子上电容没焊全:大芯片有十几个电源脚,这些脚外面的104电容一个都不能少。
有时候烧写老是卡在某个特定地址的flash块上面,擦又擦不掉,这时降低调试时钟频率或许会有用。
如果死活烧不进去,或许芯片烧了。(我有一次用拆机件,发现启动时有时候PC是0x08000000之后的地址,有时候又是0x1fff0000之后的地址,而后者是STM32出厂bootloader的地址;这说明它有时候从bootloader启动,有时候又从片内flash启动,从哪启动取决于boot0引脚的电平,而测得它的电平是稳定的。这表明,芯片内部boot0引脚的某个地方可能坏了。一分钱一分货)

退出程序,用q,或者按Ctrl+C退出。

使用arm-none-eabi-gdb

交叉工具链的gdb用法跟gdb几乎一样。只不过交叉工具链必须要gdb server。jlink提供了JLinkGDBServer,连接板子时参数跟JLinkExe差不多,同样要表明什么芯片、什么调试口、速度几何。默认端口是2331。

假设我们要调试大名鼎鼎的Linux内核。它的elf文件是vmlinux,就在编译目录下面。而且内核配置了CONFIG_DEBUG_INFO

开一个终端,开gdb server:

$ JLinkGDBServer -device stm32f429zi -if swd -speed 4000
  ...    
Connecting to J-Link...                                    
J-Link is connected.                                       
  ...
Checking target voltage...                                 
Target voltage: 3.30 V                                     
Listening on TCP/IP port 2331                              
Connecting to target...Connected to target                 
Waiting for GDB connection...

在另外一个终端,开gdb:

$ arm-none-eabi-gdb vmlinux
GNU gdb (7.11.90.20160917-0ubuntu1+9build1) 7.11.90.20160917-git             
Copyright (C) 2016 Free Software Foundation, Inc.
...
Reading symbols from vmlinux...done. 
(gdb) 

在gdb里面连接gdb server

(gdb) target remote :2331

然后就可以愉快地调试了!

在start_kernel()处的断点
在start_kernel()处的断点

以上是设在大名鼎鼎的start_kernel()处的断点。推荐使用tmux。上图开了两个窗口,上面的是TUI下面的arm-none-eabi-gdb,下面是JLinkGDBServer。还可以在旁边开一个窗口运行诸如picocom的串口助手,方便调试。

gdb常用指令

gdb有一本厚厚的说明书。其实调试时候并不需要那么多奇技淫巧,掌握基本的命令,在gdb下存活,然后碰到问题再翻手册,这才现实。

设置断点

  • b,设置程序断点,语法是b 函数名或者b 文件名:行数。如果要在特定地址设置断点,就是b *那个地址。值得一提的是,gcc拓展的c语法中,goto是可以直接跳到特定地址的,比方说 goto *0x08000000 就是跳到0x08000000地址去执行;如果是ANSI c的话要使用一些稍显晦涩的typedef才能达到目的。
  • watch,设置数据断点,watch后面加变量名。一般用来设置全局变量的断点(局部变量的话出去之后记得删掉断点)。还可以用c语言强制类型转换的语法,比方说要观察0x40023830处4个字节的变化,就是watch *(int*)0x40023830
  • info breakpoints,查看断点。
  • d,删除断点,d后面跟着标号表示删除哪个断点;上面查看断点可以看到他们的标号。

断点函数、变量都可以tab补全。

在底层,STM32不同区域的存储器断点设置策略不同。
对于片内flash这一段只读区域来说,stm32有一个硬件断点模块,可以设置有限个断点;
对于RAM这些可读可写区域来说,就会以经典的方式去弄:将那个地址的指令替换为bkpt断点指令,碰到之后再把原来指令换回去以便继续执行,这样断点数目就没有限制了;
但是需要注意的是,片外的存储器都是当做RAM的,即便接的是nor flash,它也会以后面的方式来设置断点,意味着片外nor flash是无论如何都设置不上断点的,除非在代码里面强行写bkpt指令;但是单步调试之类的是绝对不可能的。
如果程序跑在片外RAM那里,而且是由bootloader来搬运(比方说用uboot来从tftp服务器下载镜像)的话,在复位时候设置断点也是没有意义的,因为断点指令会被覆盖;此时就需要在bootloader搬运好程序之后断下来,以便在跳转到程序之前设置断点。因为很多情况下要反复重启板子来调试,所以最好写个gdb脚本。

流程控制

  • c或者continue,继续执行代码,直到碰到断点。
  • s,单步执行,碰到程序调用则进入程序。
  • n,单行执行,碰到程序调用就执行完那个程序。
  • si,单指令执行,碰到子程序调用指令则进入子程序。
  • ni,单行汇编执行,碰到子程序调用指令就执行完子程序。汇编指令级别的单步调试不仅用来调汇编,而且可以用来观察哪一步触发了fault,甚至可以发现编译器深层次的bug。
  • finish,子程序执行到返回。

查看调用堆栈

  • bt,直接输出调用栈

操作变量

  • p,后面跟变量,即查看这个变量。可以用c语言的语法,比方说查看指针指向的值,查看结构体成员变量,甚至强制类型转换。
  • x,查看内存区域的值,语法是x /FMT 地址。FMT处要填三个东西:个数、输出格式、长度;输出格式可以有二进制、八进制、十进制等等,长度有单字节、双字节、四字节等等。比方说:x /1xw 0x40023830,表示查看0x40023830处一个四字节整数,以十六进制输出。可以help x来查看详细内容:
    ```
    (gdb) help x
    Examine memory: x/FMT ADDRESS.
    ADDRESS is an expression for the memory address to examine.
    FMT is a repeat count followed by a format letter and a size letter.
    Format letters are o(octal), x(hex), d(decimal), u(unsigned decimal),
    t(binary), f(float), a(address), i(instruction), c(char), s(string)
    and z(hex, zero padded on the left).
    Size letters are b(byte), h(halfword), w(word), g(giant, 8 bytes).
    The specified number of objects of the specified size are printed
    according to the format. If a negative number is specified, memory is
    examined backward from the address.

Defaults for format and size letters are those previously used.
Default count is 1. Default address is following last thing printed
with this command or “print”.

* `set`,设置变量;语法:`set 变量=值`。

0x40023830这个地址是STM32的外设时钟使能寄存器,有时候外设莫名其妙不能初始化、不能工作,首先看看时钟是否开了。

### TUI界面--Text User Interface
TUI简直是神器,它划分了四种窗口即`src(源代码)`、`asm(反汇编)`、`regs(寄存器)`、`cmd(命令行)`,然后通过`layout`命令来排列组合这些窗口。有了TUI,调试时候就可以全键盘操作,视觉效果不亚于使用IDE。不妨通过`layout src`来进入TUI。
{% image gdb调试技巧/layoutsrc.jpeg '显示源代码' '' %}
单纯显示源代码,`layout src`

{% image gdb调试技巧/layoutasm.jpeg '显示反汇编' '' %}
显示反汇编,`layout asm`

{% image gdb调试技巧/layoutsplit.jpeg '显示源代码' '' %}
显示源代码和反汇编,`layout split`

{% image gdb调试技巧/layoutregs.jpeg '显示寄存器' '' %}
加一个窗口显示寄存器,`layout regs`

还有别的排列组合,可以通过`layout next`、`layout prev`来切换。

因为开了多个窗口,上下左右方向键就不单纯控制命令行命令输入了。`焦点`一般在源代码那里,上下方向键可用来前后翻看源代码。改变焦点用`focus src`、`focus asm`、`focus regs`、`focus cmd`来实现,也可以用`focus next`和`focus prev`来循环切换焦点。

要调整窗口大小,使用`winheight 哪个窗口 +行数`或者`winheight 哪个窗口 -行数`来实现。窗口即src、asm、regs、cmd四种。

退出TUI,先按`Ctrl+x`,再按`a`。

## gdb脚本
#### 执行一系列gdb命令
可以把gdb命令写到任何名字命名的文件里面,然后在调试时用`source`来执行这个脚本。

#### 自定义宏
比方说要查看一个链表的所有信息,假设链表定义如下:
``` c
struct node {
    int foo;
    struct node *next;
};

struct node *head;

要查看head指向的链表的所有内容,当然可以一个个地输命令p head->foop head->next->foop head->next->next->foo等等,显然这不优雅。

不妨定义这样一个宏,把它写入gdb脚本文件里面,或者直接在gdb命令行下面输入:

define plist
  set var $n = $arg0
  while $n
    p $n->foo
    set var $n = $n->next
  end
end

然后执行这个宏:

(gdb) plist head
$1 = ...
$2 = ...
...

后面就会逐行输出该链表的节点了。
值得一提的是,脚本里面还可以用printf "格式", 变量来格式化输出,跟c语言printf()一样。只需将上面第4行改为printf "%d ", $n->foo即可。