1 技术背景

1.1 stub 初探

GDB作为 GNU 项目一款知名的调试工具大家应该都不陌生,我们可以通过其很方便地对本地代码进行调试。当我们在本地调试不便的时候,也可以选择在目标机上启动gdbserver,在调试机上运行 GDB 并通过 IP 地址和端口号连接到目标机,从而实现远程调试。

但是 gdbserver 存在其本身的限制:它所需要的操作系统环境基本上与 GDB 所需要的环境是一致的。换句话来说,也就是目标机和调试机都要拥有可以运行 GDB 的操作系统环境。那么如果我们要自己编写一个操作系统并运行在真实的硬件上时,能够采用什么方法来调试呢?

在这个时候,remote stub技术就派上用场了。我们可以通过一根串口线(例如 RS-232)连接两台主机:一台作为目标机(存放了自己编写的 OS),另一台作为调试机运行 GDB。如何让两台机器通过串口进行通信呢?这需要我们在两端都实现串口的通信协议。stub 就是那个在目标端实现了串口通信协议的文件,我们会将它与自己的 OS 共同编译;而调试端的串口通信协议由 GDB 在remote.c源文件中进行了实现。

1.2 串口略讲

上文提及了串口的概念,这里便多说两句。

串口是 IBM-PC 兼容计算机上常见的传统通信端口,但其已被 USB 和其他现代外围设备接口逐渐取代。但操作系统开发人员对串行端口青睐有加,因为它们比 USB 更容易实现驱动程序,并且在许多 x86 系统中仍然很常见。现代串行端口通常实现 RS-232 标准,并且可以使用各种不同的连接器接口。DE-9 接口是现代系统中串行端口最常用的一种连接器。

至于有关串口更详细的概念以及如何调试串口,互联网上有许多不错的资料,本文也就不再赘述了。但在实际操作中,有以下几点要提醒大家注意:

  • RS-232 的串口线包含直连和交叉两种,根据两端设备的不同,我们需要选择不同的串口线。选购的方式可以参考这个回答。因为计算机的内置串口是数据终端(DTE)设备,我在这里选用了交叉线。

    • 简单交叉线

      图示 描述已自动生成

    • 简单直连线

      图示 描述已自动生成

  • 如果要判断串口数据收发是否正常,可以用面包线短接计算机串口的 2、3 引脚,并使用串口调试程序进行判断(Linux 下我用的 cutecom,Windows 下用的 UartAssist)。

  • 正常情况下,Linux 下串口的设备名为ttyS0,Windows 下为COM1。

1.3 RSP 概述

在 1.1 节中我们介绍到 stub 需要实现串口的通信协议,而该协议就是 RSP(Remote Serial Protocal)。RSP 是一种简单的,通过串口线、网络等至少支持半双工通信的媒介进行ASCII消息传输的协议。

如果一个目标机的体系结构已经在 GDB 中被定义过,并且该目标机实现了 RSP 协议的服务器端,那么调试器将能够远程连接到该目标。

该协议支持多种连接类型:直接串行设备、UDP/IP、TCP/IP 和 POSIX 管道。该协议是分层的,大致遵循下图的 OSI 模型。

形状 中度可信度描述已自动生成

1.3.1 客户端-服务器 关系

GDB 程序作为 RSP 的客户端,目标机作为 RSP 的服务器。客户端发出数据包,这些数据包是对信息或行动的请求。根据客户端数据包的性质,服务器可能会用自己的数据包来回应。

服务器发送数据包的唯一情况就是回复来自客户端的需要相应的数据包。

1.3.2 表示层:数据包传输
RSP 包的基本格式如下图所示。

RSP 包以$号作为数据包的开始,后跟一个或多个用于组成要发送的消息的 ASCII 字节,并以#作为数据包的结束。随后,还有两个 16 进制的 ASCII 字符checksum作为要发送的消息的校验和,其具体的计算方式是数据包中所有字符求和再用 256 求余数。

消息的接收方会返回 ‘+’ 表示正确接收数据,检验和有效,或 ‘-‘ 表示没有正确接收数据。当返回 ‘-‘ 时,GDB会将错误码返回给用户,并无条件挂起GDB进程。

1.3.3 应用层:远程串行协议

从客户端到服务器的 RSP 命令是文本字符串,后面可以有参数。每条命令都在自己的数据包中发送。这些数据包可分为四组:

  1. 不需要确认的数据包。这些命令是:fiIkRtvFlashDone
  2. 需要一个简单的确认包的数据包。这种确认要么是 OK,要么是 Enn(其中 nn 是错误号码),或者对于某些命令返回空包(意味着 “不支持”)。这些命令是:!ADGHMPQxxxxTvFlashErasevFlashWriteXzZ
  3. 返回结果数据或错误代码的数据包。这些命令是 :?cCgmpqxxxxsSvxxxx
  4. 不应再使用的已弃用数据包。这些命令是:bBdr

数据包命令的详细说明可参考官方文档。部分 GDB 命令的实现如下:

图形用户界面, 表格 中度可信度描述已自动生成

1.3.4 实例

如果要将 0xc320 写入内存 0x4015cc 中,GDB 会调用set 0x4015cc = 0xc320。

其底层的通信为:

$M4015CC,2:C320#6d

其中 ‘M’ 命令的格式是 ‘M addr,length:XX…’ 。意为将数据 XX… 写入从地址 addr 开始的 length 个可寻址的内存单元中。

目标机收到数据并验证检验码后正确,会首先返回

+

接着返回状态

$OK#9a

这样,一个通过 GDB 操作内存数据的通信协议就完成了。关于 RSP 协议的更详细的内容,大家可以参考 Howto: GDB Remote Serial Protocol

2 gdbstub

2.1 概览

GDB 源码中内置了多种计算机体系结构下适用的 stub,如i386-stub.c、sparc-stub.c等,但无一例外需要二次开发,针对串口的地址进行读写,从而实现收发数据包、实现异常处理等功能。为了简化流程,我们可以使用已开发得较为完善的gdbstub

gdbstub 依赖于 32 位的 x86 架构。该项目的各个文件中有两个文件起到了核心作用——gdbstub_x86_int.nasm和gdbstub.h。接下来我会以这两个文件为依据,梳理一下 gdbstub 起作用的总体流程。

2.2 处理流程

gdbstub 的处理流程非常清晰:生成异常处理函数向量表;获取 OS 的 IDT 并将 IDT 的 1 号和 3 号异常与对应的异常处理函数进行绑定(1 号异常是单步异常,3 号异常是断点异常);在每一次 1 号和 3 号异常被触发的时候执行对应的异常处理程序。

图示 描述已自动生成

2.2.1 生成异常处理函数向量表

在gdbstub_x86_int.nasm文件中,我们看到其定义了一个全局变量gdb_x86_int_handlers数组,该数组的每个元素是个 32 位的地址,该地址指向何处?我们接着往下看。

在此,我们发现了数组元素指向的位置。它对应着一段与数组序号对应的代码段。这段代码会将异常号压栈,并跳转到gdb_x86_int_handler_common所在的位置。

文本 描述已自动生成

可能大家会对上述代码有所疑惑:为什么有的异常处理需要将 0 压栈,有的却不需要呢?这是因为有些异常是拥有错误码的,在这些拥有错误码的异常触发时会自动将错误码压栈。因此,为了使栈中的各元素序列保持一致,我们在处理没有错误码的异常时,需要将 0 压栈,表示什么也不做。

表格 描述已自动生成

好了,言归正传,现在我们来看看gdb_x86_int_handler_common都做了什么吧。由下图可以看出,这段代码首先是将各个寄存器压栈(作为gdb_x86_int_handler的参数),然后调用了gdb_x86_int_handler函数,最后再将压栈的寄存器弹出,恢复现场,然后返回。

文本 描述已自动生成

到这里为止,异常处理函数向量表的全貌就展现在我们的眼前了。gdb_x86_int_handlers数组的每一个元素都会指向一个地址,最终调用gdb_x86_int_handler函数,也就是自定义的异常处理函数。至于gdb_x86_int_handler的具体实现逻辑,我会在之后进行说明。

2.2.2 绑定异常处理函数

gdbstub.h中我们将gdb_sys_init()作为 stub 的入口。它将 1 号异常(Debug)和 3 号异常(Breakpoint)与对应的异常处理函数进行绑定,随后调用int3开启 3 号中断,等待调试机上的 GDB 发送数据包并进行相应的处理。

1
2
3
4
5
6
7
8
9
void gdb_sys_init(void)
{
/* Hook current IDT. */
gdb_x86_hook_idt(1, gdb_x86_int_handlers[1]);
gdb_x86_hook_idt(3, gdb_x86_int_handlers[3]);

/* Interrupt to start debugging. */
asm volatile ("int3");
}

在以下代码中,我们可以了解程序是如何将异常号与异常处理函数进行绑定的。

  • 通过sidt指令获取到 IDTR 中的基址,该基址指向中断描述符表 IDT 的地址。
  • 通过中断向量号索引当前中断在 IDT 中的位置,也就是门描述符。
  • 将中断处理程序所在段的段选择子和段内偏移地址、特权级等写入门描述符内对应的位置。

这样一来,就实现了绑定功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Get current IDT.
static void gdb_x86_store_idt(struct gdb_idtr *idtr)
{
asm volatile (
"sidt %0"
/* Outputs */ : "=m" (*idtr)
/* Inputs */ : /* None */
/* Clobbers */ : /* None */
);
}

// Get current code segment (CS register).
static uint32_t gdb_x86_get_cs(void)
{
uint32_t cs;

asm volatile (
"push %%cs;"
"pop %%eax;"
/* Outputs */ : "=a" (cs)
/* Inputs */ : /* None */
/* Clobbers */ : /* None */
);

return cs;
}

// Hook a vector of the current IDT.
static void gdb_x86_hook_idt(uint8_t vector, const void *function)
{
struct gdb_idtr idtr;
struct gdb_idt_gate *gates;

gdb_x86_store_idt(&idtr);
gates = (struct gdb_idt_gate *)idtr.offset;
gates[vector].flags = 0x8E00;
gates[vector].segment = gdb_x86_get_cs();
gates[vector].offset_low = (((uint32_t)function) ) & 0xffff;
gates[vector].offset_high = (((uint32_t)function) >> 16) & 0xffff;
}

中断描述符表寄存器 IDTR 为 48 位,其中高 32 位存放着中断描述符表 IDT 的地址。

表格 描述已自动生成

保护模式下,中断描述符表每个描述符占有 8 字节,该描述符被称为门。门有不同的种类,包括任务门、陷阱门、中断门等等,我们此处用的就是中断门。中断门描述符的格式如下所示。

表格 描述已自动生成

2.2.3 异常处理函数的实现

好了,终于要窥探中断处理函数的内部细节了。还记得在 2.2.1 节的gdb_x86_int_handler_common中调用的gdb_x86_int_handler()方法吗?它就是异常处理函数的入口,我们可以看到它调用了gdb_x86_interrupt()方法。在该方法中顺序执行了以下步骤:

  1. 判断当前是否是 1 号或 3 号中断,如果是,则将 GDB 信号设置为3,即 SIGTRAP;否则设置为 7。
  2. 将上下文环境赋给 gdb_state.registers[]。
  3. 调用gdb_main()方法。
  4. 更新上下文环境。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void gdb_x86_int_handler(struct gdb_interrupt_state *istate)
{
gdb_x86_interrupt(istate);
}

/*
* Debug interrupt handler.
*/
static void gdb_x86_interrupt(struct gdb_interrupt_state *istate)
{
/* Translate vector to signal */
switch (istate->vector) {
case 1: gdb_state.signum = 5; break;
case 3: gdb_state.signum = 5; break;
default: gdb_state.signum = 7;
}

/* Load Registers */
gdb_state.registers[GDB_CPU_I386_REG_EAX] = istate->eax;
gdb_state.registers[GDB_CPU_I386_REG_ECX] = istate->ecx;
gdb_state.registers[GDB_CPU_I386_REG_EDX] = istate->edx;
gdb_state.registers[GDB_CPU_I386_REG_EBX] = istate->ebx;
gdb_state.registers[GDB_CPU_I386_REG_ESP] = istate->esp;
gdb_state.registers[GDB_CPU_I386_REG_EBP] = istate->ebp;
gdb_state.registers[GDB_CPU_I386_REG_ESI] = istate->esi;
gdb_state.registers[GDB_CPU_I386_REG_EDI] = istate->edi;
gdb_state.registers[GDB_CPU_I386_REG_PC] = istate->eip;
gdb_state.registers[GDB_CPU_I386_REG_CS] = istate->cs;
gdb_state.registers[GDB_CPU_I386_REG_PS] = istate->eflags;
gdb_state.registers[GDB_CPU_I386_REG_SS] = istate->ss;
gdb_state.registers[GDB_CPU_I386_REG_DS] = istate->ds;
gdb_state.registers[GDB_CPU_I386_REG_ES] = istate->es;
gdb_state.registers[GDB_CPU_I386_REG_FS] = istate->fs;
gdb_state.registers[GDB_CPU_I386_REG_GS] = istate->gs;

gdb_main(&gdb_state);

/* Restore Registers */
istate->eax = gdb_state.registers[GDB_CPU_I386_REG_EAX];
istate->ecx = gdb_state.registers[GDB_CPU_I386_REG_ECX];
istate->edx = gdb_state.registers[GDB_CPU_I386_REG_EDX];
istate->ebx = gdb_state.registers[GDB_CPU_I386_REG_EBX];
istate->esp = gdb_state.registers[GDB_CPU_I386_REG_ESP];
istate->ebp = gdb_state.registers[GDB_CPU_I386_REG_EBP];
istate->esi = gdb_state.registers[GDB_CPU_I386_REG_ESI];
istate->edi = gdb_state.registers[GDB_CPU_I386_REG_EDI];
istate->eip = gdb_state.registers[GDB_CPU_I386_REG_PC];
istate->cs = gdb_state.registers[GDB_CPU_I386_REG_CS];
istate->eflags = gdb_state.registers[GDB_CPU_I386_REG_PS];
istate->ss = gdb_state.registers[GDB_CPU_I386_REG_SS];
istate->ds = gdb_state.registers[GDB_CPU_I386_REG_DS];
istate->es = gdb_state.registers[GDB_CPU_I386_REG_ES];
istate->fs = gdb_state.registers[GDB_CPU_I386_REG_FS];
istate->gs = gdb_state.registers[GDB_CPU_I386_REG_GS];
}

我们主要来看gdb_main()方法的实现。该方法就是对串口进行驱动编程,实现 RSP 服务器端的响应。

从宏观上看,该方法的内部是通过一个while()循环在不断地读取从调试机发送来的数据包,并进行解析,根据不同的 RSP 命令执行不同的操作并返回相应的数据包。这一逻辑过程的实现可以抽象成三个部分:从串口收/发数据包、解析/编码数据包以及根据指令进行相应的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
int gdb_main(struct gdb_state *state)
{
address addr;
char pkt_buf[256];
int status;
unsigned int length;
unsigned int pkt_len;
const char *ptr_next;

gdb_send_signal_packet(state, pkt_buf, sizeof(pkt_buf), state->signum);

while (1) {
/* Receive the next packet */
status = gdb_recv_packet(state, pkt_buf, sizeof(pkt_buf), &pkt_len);
if (status == GDB_EOF) {
break;
}

if (pkt_len == 0) {
/* Received empty packet.. */
continue;
}

ptr_next = pkt_buf;

/*
* Handle one letter commands
*/
switch (pkt_buf[0]) {

/*
* 省略……
*/

/*
* Read Registers
* Command Format: g
*/
case 'g':
/* Encode registers */
status = gdb_enc_hex(pkt_buf, sizeof(pkt_buf),
(char *)&(state->registers),
sizeof(state->registers));
if (status == GDB_EOF) {
goto error;
}
pkt_len = status;
gdb_send_packet(state, pkt_buf, pkt_len);
break;

/*
* 省略……
*/

/*
* Continue
* Command Format: c [addr]
*/
case 'c':
gdb_continue(state);
return 0;

/*
* Single-step
* Command Format: s [addr]
*/
case 's':
gdb_step(state);
return 0;

case '?':
gdb_send_signal_packet(state, pkt_buf, sizeof(pkt_buf),
state->signum);
break;

/*
* Unsupported Command
*/
default:
gdb_send_packet(state, NULL, 0);
}

continue;

error:
gdb_send_error_packet(state, pkt_buf, sizeof(pkt_buf), 0x00);
}

return 0;
}

2.2.3.1 从串口收/发数据包

收/发数据包的处理逻辑非常类似,两者之间是逆过程,因此我们以发送数据包为例,自底向上地了解其实现的具体细节。在开始之前,我梳理了一下发送数据包这一过程的方法调用链,如下所示。

最底层的串口通信方法非常简单,将值写入串口号对应的寄存器即可。关于串口各端口的含义,我会在后文进行介绍。

1
2
3
4
5
6
7
8
9
static void gdb_x86_io_write_8(uint16_t port, uint8_t val)
{
asm volatile (
"outb %%al, %%dx;"
/* Outputs */ : /* None */
/* Inputs */ : "a" (val), "d" (port)
/* Clobbers */ : /* None */
);
}

其上一层的方法对其做了一层封装,需要判断当前的传输缓冲区是否为空,非空无法传送数据。这里用到了串口编程的知识,详细内容可翻看 2.4 节。

1
2
3
4
5
6
7
static int gdb_x86_serial_putchar(int ch)
{
/* Wait for THRE (bit 5) to be high */
while ((gdb_x86_io_read_8(SERIAL_PORT + SERIAL_LSR) & (1<<5)) == 0);
gdb_x86_io_write_8(SERIAL_PORT + SERIAL_THR, ch);
return ch;
}

再上一层没有什么好说的,单纯的调用,应该是为后期支持不同的系统架构抽象出的接口。

int gdb_sys_putchar(struct gdb_state *state, int ch)

{

return gdb_x86_serial_putchar(ch);

}

此处再封装一层,通过对之前方法的重复调用,实现向串口写入一系列字节的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
* Write a sequence of bytes.
*
* Returns:
* 0 if successful
* GDB_EOF if failed to write all bytes
*/
static int gdb_write(struct gdb_state *state, const char *buf, unsigned int len)
{
while (len--) {
if (gdb_sys_putchar(state, *buf++) == GDB_EOF) {
return GDB_EOF;
}
}

return 0;
}

下面的方法是整个数据包能够发送的核心。还记得 RSP 包的基本格式吗?$<packet-data>#<checksum>,遗忘的朋友可翻看 1.3.2 节的内容。该方法发送的数据包严格遵守该格式:首先发送$字符,接着借助上文的gdb_write()方法发送具体的数据序列(注:该数据序列为 ASCII 码格式,会由上层函数进行保证,下文会提及),最后将#与计算出的检验和共同发送到串口。发送完之后不要忘记从串口中读取一个字符——这是调试机上 GDB 返回的响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* Transmits a packet of data.
* Packets are of the form: $<packet-data>#<checksum>
*
* Returns:
* 0 if the packet was transmitted and acknowledged
* 1 if the packet was transmitted but not acknowledged
* GDB_EOF otherwise
*/
static int gdb_send_packet(struct gdb_state *state, const char *pkt_data,
unsigned int pkt_len)
{
char buf[3];
char csum;

/* Send packet start */
if (gdb_sys_putchar(state, '$') == GDB_EOF) {
return GDB_EOF;
}

/* Send packet data */
if (gdb_write(state, pkt_data, pkt_len) == GDB_EOF) {
return GDB_EOF;
}

/* Send the checksum */
buf[0] = '#';
csum = gdb_checksum(pkt_data, pkt_len);
if ((gdb_enc_hex(buf+1, sizeof(buf)-1, &csum, 1) == GDB_EOF) ||
(gdb_write(state, buf, sizeof(buf)) == GDB_EOF)) {
return GDB_EOF;
}

return gdb_recv_ack(state);
}

static int gdb_recv_ack(struct gdb_state *state)
{
int response;

/* Wait for packet ack */
switch (response = gdb_sys_getc(state)) {
case '+':
/* Packet acknowledged */
return 0;
case '-':
/* Packet negative acknowledged */
return 1;
default:
/* Bad response! */
GDB_PRINT("received bad packet response: 0x%2x\n", response);
return GDB_EOF;
}
}

最上层的四个方法都是针对不同的场景,对gdb_send_packet()进行特定封装。我们以 gdb_send_error_packet()为例,它向串口发送一个错误数据包,以 ‘E’ 开头,后面接着具体的数据。上个方法gdb_send_packet()中发送的数据序列需要保证其为 ASCII 格式,就是在此处进行编码的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
* Send a error packet (E AA).
*/
static int gdb_send_error_packet(struct gdb_state *state, char *buf,
unsigned int buf_len, char error)
{
unsigned int size;
int status;

if (buf_len < 4) {
/* Buffer too small */
return GDB_EOF;
}

buf[0] = 'E';
status = gdb_enc_hex(&buf[1], buf_len-1, &error, 1);
if (status == GDB_EOF) {
return GDB_EOF;
}
size = 1 + status;
return gdb_send_packet(state, buf, size);
}

好了,到此为止,我们整个数据包发送的流程就已经理清了。接收数据包的过程非常类似,大家可以自行阅读相关代码并理解。

2.2.3.2 解析/编码数据包

这一部分的代码较为简单,不作详细讲解。我将相关代码整理并贴在下方,大家可自行阅读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/*
* Decode data from its hex-value representation to a buffer.
*
* Returns:
* 0 if successful
* GDB_EOF if the buffer is too small
*/
static int gdb_dec_hex(const char *buf, unsigned int buf_len, char *data,
unsigned int data_len)
{
unsigned int pos;
int tmp;

if (buf_len != data_len*2) {
/* Buffer too small */
return GDB_EOF;
}

for (pos = 0; pos < data_len; pos++) {
/* Decode high nibble */
tmp = gdb_get_val(*buf++, 16);
if (tmp == GDB_EOF) {
/* Buffer contained junk. */
GDB_ASSERT(0);
return GDB_EOF;
}

data[pos] = tmp << 4;

/* Decode low nibble */
tmp = gdb_get_val(*buf++, 16);
if (tmp == GDB_EOF) {
/* Buffer contained junk. */
GDB_ASSERT(0);
return GDB_EOF;
}
data[pos] |= tmp;
}

return 0;
}

/*
* Get the corresponding value for a ASCII digit character.
*
* Supports bases 2-16.
*/
static int gdb_get_val(char digit, int base)
{
int value;

if ((digit >= '0') && (digit <= '9')) {
value = digit-'0';
} else if ((digit >= 'a') && (digit <= 'f')) {
value = digit-'a'+0xa;
} else if ((digit >= 'A') && (digit <= 'F')) {
value = digit-'A'+0xa;
} else {
return GDB_EOF;
}

return (value < base) ? value : GDB_EOF;
}
/*
* Encode data to its hex-value representation in a buffer.
*
* Returns:
* 0+ number of bytes written to buf
* GDB_EOF if the buffer is too small
*/
static int gdb_enc_hex(char *buf, unsigned int buf_len, const char *data,
unsigned int data_len)
{
unsigned int pos;

if (buf_len < data_len*2) {
/* Buffer too small */
return GDB_EOF;
}

for (pos = 0; pos < data_len; pos++) {
*buf++ = gdb_get_digit((data[pos] >> 4) & 0xf);
*buf++ = gdb_get_digit((data[pos] ) & 0xf);
}

return data_len*2;
}

/*
* Get the corresponding ASCII hex digit character for a value.
*/
static char gdb_get_digit(int val)
{
if ((val >= 0) && (val <= 0xf)) {
return digits[val];
} else {
return GDB_EOF;
}
}

static const char digits[] = "0123456789abcdef";

2.2.3.3 根据指令进行相应的处理
对指令的处理过程我们以 ‘g’ 为例。’g’ 指令的作用是读取寄存器的值。我们将寄存器的值存储在state->registers中,只需要对这些值进行编码,转换成 ASCII 格式并将其封装为数据包发送至串口即可。其余指令的实现大体上都是以 2.2.3.1 节和 2.2.3.2 节为基础,再进行特定的逻辑处理,也就不在此赘述了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* Read Registers
* Command Format: g
*/
case 'g':
/* Encode registers */
status = gdb_enc_hex(pkt_buf, sizeof(pkt_buf),
(char *)&(state->registers),
sizeof(state->registers));
if (status == GDB_EOF) {
goto error;
}
pkt_len = status;
gdb_send_packet(state, pkt_buf, pkt_len);
break;

这里尤其要强调一下 ‘s’ 与 ‘c’ 指令。大家注意,在执行完这两个指令对应的处理过程后,其所在的函数gdb_main()直接返回了,返回到我们上文提及的gdb_x86_interrupt()方法去更新上下文环境;接着返回到gdb_x86_int_handler(),完成了中断处理的整个过程,执行被调试程序的下一条指令。遗忘这一层调用关系的朋友可以重新回看本小节的开头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* Continue
* Command Format: c [addr]
*/
case 'c':
gdb_continue(state);
return 0;

/*
* Single-step
* Command Format: s [addr]
*/
case 's':
gdb_step(state);
return 0;

我们接着看看gdb_continue()与gdb_step()究竟做了什么。持续往下跟进,我们可以看到这两个方法都修改了gdb_state.registers[GDB_CPU_I386_REG_PS]第 8 位的值。而gdb_state.registers[GDB_CPU_I386_REG_PS]已经在之前的gdb_x86_interrupt()方法中被赋值为eflags寄存器的值。那么eflags寄存器的第 8 位究竟有什么作用呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*
* Continue program execution at PC.
*/
int gdb_continue(struct gdb_state *state)
{
gdb_sys_continue(state);
return 0;
}

/*
* Step one instruction.
*/
int gdb_step(struct gdb_state *state)
{
gdb_sys_step(state);
return 0;
}

/*
* Continue program execution.
*/
int gdb_sys_continue(struct gdb_state *state)
{
gdb_state.registers[GDB_CPU_I386_REG_PS] &= ~(1<<8);
return 0;
}

/*
* Single step the next instruction.
*/
int gdb_sys_step(struct gdb_state *state)
{
gdb_state.registers[GDB_CPU_I386_REG_PS] |= 1<<8;
return 0;
}

eflags寄存器第 8 位为 Trap Flag。当该位为 1 时,CPU 执行完一条指令后会产生单步异常(1 号异常),进入异常处理程序后 TF 自动置 0。调试器通过处理这个单步异常实现对程序的中断控制。持续地把 TF 置 1,程序就可以每执行一句中断一次,从而实现调试器的单步跟踪功能。

在上方的代码中可以很明显地看出 ‘s’ 指令将 TF 置 1,为之后的单步调试做准备;而 ‘c’ 指令要不间断地执行被调试程序(遇到 breakpoint 的情况除外),为了不触发单步异常,因此要将 TF 置 0。

图片包含 表格 描述已自动生成

2.3 修改 gdbstub

对于整个 gdbstub 在 x86 架构上的运作流程,我们已经在上文介绍得很清楚了。但为了其能够更加适配我们自己的系统,还要对它进行微调。

  1. 在gdbstub.h文件的开头加上如下定义。这是为了启用 x86 架构,否则默认为模拟的系统结构。 图片包含 文本 描述已自动生成
  2. 由于我们编写的 mini-os 中已经声明过uint8_t、uint16_t、uint32_t,为了避免重复,我在这里将其注释,并增加了<stdint.h>头文件。

文本 描述已自动生成

  1. 增加串口初始化方法init_serial(),并在gdb_sys_init()中进行调用。

    文本 描述已自动生成

2.4 对串口 IO 端口进行编程

2.4.1 端口地址

首先,我们需要确定串口 IO 端口的地址。由于与机器的连接方式以及 BIOS 的配置方式不同,IO 端口的地址也有可能发生改变,但一般来说,各 COM 口对应的 IO 端口地址如下所示。当然,我们也可以在 BIOS 的 Data Area 中查询到该信息。

通讯端口 IO 端口
COM1 0x3F8
COM2 0x2F8

在获得 COM 口的基地址后,我们可以添加一个偏移值以访问其中的数据寄存器。

IO 端口偏移 DLAB的设置 映射到此端口的寄存器
+0 0 数据寄存器。从接收缓冲区读取此寄存器。写入该寄存器会写入发送缓冲区。
+1 0 中断使能寄存器。
+0 1 当 DLAB 设置为 1 时,这是用于设置波特率的除数的最低有效字节。
+1 1 DLAB 设置为 1 时,这是除数的最高有效字节。
+2 - 中断识别和 FIFO 控制寄存器
+3 - 线路控制寄存器。该寄存器的最高有效位是 DLAB。
+4 - MODEM 控制寄存器。
+5 - 线路状态寄存器。
+6 - MODEM 状态寄存器。
+7 - 暂存器。

2.4.2 线路协议

通过电线传输的串行数据可以有许多不同的参数设置。通常情况下,发送设备和接收设备需要向每个串行控制器写入相同的协议参数值,以便通信成功。

一般情况下 8N1(8位,无奇偶性,一个停止位)几乎是默认的。

  • 2.4.2.1 波特率*

串行控制器(UART)有一个内部时钟,以每秒 115200 次的速度运行,还有一个时钟除数,用来控制波特率。这与可编程中断定时器(PIT)使用的系统类型完全相同。

为了设置端口的速度,计算给定波特率所需的除数,并将其编入除数寄存器。例如,除数为 1 会得到115200 波特,除数为 2 会得到 57600 波特,3 会得到 38400 波特,等等。

不要试图使用 0 的除数来获得无限的波特率,这是不可行的。大多数串行控制器将产生一个未指定的和不可预测的波特率(无论如何,无限波特将意味着无限的传输错误,因为它们是成比例的)。

给控制器设置除数的步骤:

  1. 设置线路控制寄存器的最有效位。这是DLAB位,允许访问除数寄存器。
  2. 将除数值的最小有效字节发送到[PORT + 0]。
  3. 将除数值的最有意义的字节发送到 [PORT + 1]。
  4. 清除线路控制寄存器的最重要位。

2.4.2.2 数据位

一个字符中的比特数是可变的。当然,位数越少越快,但它们储存的信息越少。如果你只发送 ASCII 文本,你可能只需要 7 位。

通过写入行控制寄存器 [PORT + 3 ]的两个最小有效位来设置这个值。

Bit 1 Bit 0 Character Length (bits)
0 0 5
0 1 6
1 0 7
1 1 8

2.4.2.3 停止位

串行控制器可以被配置为在每个数据字符后发送一些比特。这些可靠的位可以被控制器用来验证发送和接收设备是否处于相位。

如果字符长度具体为 5 位,停止位只能设置为 1 或 1.5。对于其他长度的字符,停止位只能设置为 1 或 2。

要设置停止位的数量,设置线路控制寄存器 PORT + 3] 的第 2 位。

Bit 2 Stop Bits
0 1
1 1.5 / 2 (depending on character length)

2.4.2.4 奇偶校验位

可以使控制器在传输的每个数据字符的末尾增加或期待一个奇偶校验位。有了这个奇偶校验位,如果数据的一个位被干扰颠倒了,就可以提出一个奇偶校验错误。奇偶校验类型可以是 NONE、EVEN、ODD、MARK 或 SPACE。

如果奇偶校验设置为 NONE,将不添加奇偶校验位,也不期望有奇偶校验。如果发射器发送了一个,而接收器却没有预期到,这很可能会导致错误。

如果奇偶校验设置为 MARK 或 SPACE,奇偶校验位将分别被期望总是设置为 1 或 0。

如果奇偶校验被设置为 EVEN 或 ODD,控制器通过将所有数据位和奇偶校验位的值相加来计算奇偶校验的准确性。如果端口被设置为 EVEN 奇偶校验,其结果必须是偶数。如果它被设置为 ODD 奇偶校验,结果必须是奇数。

要设置端口奇偶校验,请设置线路控制寄存器 [PORT + 3] 的第 3、4 和 5 位。

Bit 5 Bit 4 Bit 3 Parity
- - 0 NONE
0 0 1 ODD
0 1 1 EVEN
1 0 1 MARK
1 1 1 SPACE

2.4.3 中断使能寄存器

要在中断模式下与串口通信,必须正确设置中断使能寄存器。为了确定哪些中断应该被启用,必须向中断使能寄存器写入一个具有下列位的值(0 = 禁用,1 = 启用)。

Bit Interrupt
0 Data available
1 Transmitter empty
2 Break/error
3 Status change
4-7 Unused

2.4.4 MODEM 控制寄存器

MODEM 控制寄存器是硬件握手寄存器的一半。虽然大多数串行设备不再使用硬件握手,但在所有与16550 兼容的 UARTS 中仍然包括这些线路。这些可以作为一般用途的输出端口,也可以用来实际执行握手。通过写入调制解调器控制寄存器,它将把这些线设置为有效。

Bit Name Meaning
0 Data Terminal Ready (DTR) Controls the Data Terminal Ready Pin
1 Request to Send (RTS) Controls the Request to Send Pin
2 Out 1 Controls a hardware pin (OUT1) which is unused in PC implementations
3 Out 2 Controls a hardware pin (OUT2) which is used to enable the IRQ in PC implementations
4 Loop Provides a local loopback feature for diagnostic testing of the UART
5 0 Unused
6 0 Unused
7 0 Unused

2.4.5 线路状态寄存器

线路状态寄存器对于检查错误和启用轮询非常有用。

Bit Name Meaning
0 Data ready (DR) Set if there is data that can be read
1 Overrun error (OE) Set if there has been data lost
2 Parity error (PE) Set if there was an error in the transmission as detected by parity
3 Framing error (FE) Set if a stop bit was missing
4 Break indicator (BI) Set if there is a break in data input
5 Transmitter holding register empty (THRE) Set if the transmission buffer is empty (i.e. data can be sent)
6 Transmitter empty (TEMT) Set if the transmitter is not doing anything
7 Impending Error Set if there is an error with a word in the input buffer

2.4.6 MODEM 状态寄存器

该寄存器提供来自外围设备的控制线的当前状态。除了这些当前状态信息,MODEM 状态寄存器的四个位提供了变化信息。每当来自 MODEM 的控制输入改变状态时,这些位被设置为逻辑 1。每当 CPU 读取MODEM 状态寄存器时,它们就被重置为逻辑 0。

Bit Name Meaning
0 Delta Clear to Send (DCTS) Indicates that CTS input has changed state since the last time it was read
1 Delta Data Set Ready (DDSR) Indicates that DSR input has changed state since the last time it was read
2 Trailing Edge of Ring Indicator (TERI) Indicates that RI input to the chip has changed from a low to a high state
3 Delta Data Carrier Detect (DDCD) Indicates that DCD input has changed state since the last time it ware read
4 Clear to Send (CTS) Inverted CTS Signal
5 Data Set Ready (DSR) Inverted DSR Signal
6 Ring Indicator (RI) Inverted RI Signal
7 Data Carrier Detect (DCD) Inverted DCD Signal

如果 MCR 的第 4 位(LOOP 位)被设置,上面的 4 位将反映调制解调器控制寄存器中设置的 4 条状态输出线。

2.4.7 实例

以之前提到的串口初始化程序为例,我们来说明如何对串口 IO 端口进行编程。

1
2
3
4
5
6
7
void init_serial() {
gdb_x86_io_write_8(SERIAL_PORT + 1, 0x00); // Disable all interrupts
gdb_x86_io_write_8(SERIAL_PORT + 3, 0x80); // Enable DLAB (set baud rate divisor)
gdb_x86_io_write_8(SERIAL_PORT + 0, 0x03); // Set divisor to 3 (lo byte) 38400 baud
gdb_x86_io_write_8(SERIAL_PORT + 1, 0x00); // (hi byte)
gdb_x86_io_write_8(SERIAL_PORT + 3, 0x03); // 8 bits, no parity, one stop bit
}
  1. 向相对于基地址 0x3F8 偏移量为 1 的中断使能寄存器中写入 0x00,意为关闭所有的串口中断。
  2. 向相对于基地址 0x3F8 偏移量为 3 的线路控制寄存器中写入 0x80。线路控制寄存器的最高位为 DLAB(Divisor Latch Access Bit),即除数锁存存取位,这是为后面设置波特率做准备。
  3. 向相对于基地址 0x3F8 偏移量为 0 的寄存器中写入 0x03。当 DLAB 设置为 1 时,这是用于设置波特率的除数的最低有效字节。例如,除数 1 将产生 115200 波特,除数 2 将产生 57600 波特,3 将产生 38400 波特等。
  4. 向相对于基地址 0x3F8 偏移量为 1 的寄存器中写入 0x00,意味设置波特率的除数的最高有效字节为 0。
  5. 相对于基地址 0x3F8 偏移量为 3 的线路控制寄存器中写入 0x03,意为关闭 DLAB,并设置为无奇偶校验位与 1 位停止位。

对串口的 IO 端口编程就简单介绍到这里,各个端口各个位的具体含义大家可以参考 Serial PortPORTS

3 编译、运行与测试

3.1 编译

  1. 在项目中新建一个 gdbstub 文件夹,内含gdbstub_x86_int.nasm和gdbstub.h两个文件。

文本 描述已自动生成

  1. 在 mini-os 的main.c中引入gdbstub.h头文件,并在函数kernel_main()中调用gdb_sys_init()方法。

  2. 在os/Makefile文件增加如下的编译命令。

    图形用户界面, 文本 描述已自动生成)

3.2 运行

  1. 在调试机上设置波特率。由于之前初始化串口时,我们设置目标机的波特率为 38400,为保证两机通信正常,需要设置同样的波特率。
1
sudo stty -F /dev/ttyS0 ispeed 38400 ospeed 38400 cs8
  1. 将目标机上编译好的代码文件(mini-os)复制一份放入调试机中,并用如下命令启动 GDB。其中kernel.gdb.bin是带调试符号的可执行文件。
1
sudo gdb kernel.gdb.bin
  1. 用如下命令连接串口。其中/dev/ttyS0是本机的串口号。
1
target remote /dev/ttyS0

经过上述步骤就可以愉快地调试程序啦!

电脑的屏幕 描述已自动生成

电脑屏幕截图 描述已自动生成

4 总结

由于查阅许多资料,依然没能找到一篇系统地讲解如何调试真机上的 OS 的文章,因此决定将自己这一探索经历分享出来,希望可以帮助到更多的人。

在这次实践中,有过许多次难有进展的时间段,只能不断地查资料、编码、编译、运行、重启电脑、Debug,反反复复,甚至在临睡前、骑车回寝室的路上都会思考接下来的步骤(插入一句,骑车回去的时候头脑最清楚)。这篇文章展现出来的都是成功的尝试,只是总体过程的冰山一角。

但是在这一过程中我也复习了很多之前没怎么注意的细小的知识点,重新回顾了 Makefile 文件的用法、ELF 文件的基本格式、汇编语言,了解了串口编程,加深了对底层的了解,的确是一次很有收获的经历。

× 请我吃糖~
打赏二维码