调试结构体与联合体:GDB 打印与内存 dump 分析
调试嵌入式程序,说白了就是跟内存打交道。结构体和联合体这种复合类型,在内存里怎么排布的?指针指到哪里了?值对不对?这些光靠看代码是看不出来的。我做了这么多年嵌入式,最怕的就是那种「看起来代码没问题,跑起来就是不对」的情况。这时候,GDB 和内存 dump 就是我的救命稻草。
GDB 打印结构体:基础操作
先来个最简单的例子。假设你有这么个结构体:
struct sensor_data {
uint8_t id;
uint16_t temperature;
uint32_t pressure;
char name[16];
};
struct sensor_data sensor = {
.id = 0x5A,
.temperature = 0x1F4,
.pressure = 0x3E8,
.name = "TEMP_S1"
};
在 GDB 里,直接 print sensor 就能看到所有字段的值。但说实话,我很少这么干。为什么?因为嵌入式系统里结构体往往嵌套好几层,直接打印出来满屏都是,反而看不清。
我习惯用 print /x sensor 以十六进制显示,或者用 print /t sensor 看二进制位。特别是调试寄存器映射的结构体时,十六进制一眼就能看出哪个 bit 被置位了。
ptype sensor 可以查看结构体的类型定义,不用来回翻代码。这在调试第三方库时特别有用。
打印结构体指针与成员
结构体指针在嵌入式里太常见了。比如我们经常用指针访问外设寄存器:
struct uart_regs {
volatile uint32_t DR; // 数据寄存器
volatile uint32_t SR; // 状态寄存器
volatile uint32_t CR; // 控制寄存器
};
#define UART1_BASE ((struct uart_regs *)0x40011000)
在 GDB 里,print *UART1_BASE 就能看到所有寄存器的当前值。但要注意,如果指针是 void * 类型,GDB 不知道它指向什么结构体。这时候需要强制转换:
(gdb) print *(struct uart_regs *)0x40011000
我曾经在调试一个 I2C 驱动时,发现设备地址总是不对。用 GDB 打印结构体指针,才发现是字节对齐问题——结构体里有个 uint8_t 后面跟了个 uint32_t,编译器偷偷塞了 3 个字节的 padding。嗯,这就是为什么我后来写结构体都习惯按 4 字节对齐来排字段。
联合体的调试:小心覆盖
联合体调试比结构体麻烦。因为所有成员共享同一块内存,你看到的值取决于你用什么类型去解释它。
union data_word {
uint32_t word;
uint8_t bytes[4];
struct {
uint16_t low;
uint16_t high;
} halves;
};
在 GDB 里,print data_word.word 和 print data_word.bytes 看到的是同一块内存的不同视角。我建议你同时打印多个成员,对比着看:
(gdb) print /x data_word.word
$1 = 0x12345678
(gdb) print /x data_word.bytes
$2 = {0x78, 0x56, 0x34, 0x12}
(gdb) print /x data_word.halves
$3 = {low = 0x5678, high = 0x1234}
看到没?小端模式下,低字节在低地址。这个顺序搞反了,数据解析就全错了。我有个同事曾经因为这个问题,在解析网络协议时整整折腾了两天。
x/ 命令直接看内存。
内存 dump 分析:x 命令的妙用
GDB 的 x 命令(examine)是调试结构体的利器。它的格式是:x/[数量][格式][大小] 地址。
比如我想看 sensor 结构体在内存里的原始布局:
(gdb) x/32bx &sensor
0x20000000: 0x5a 0x00 0xf4 0x01 0xe8 0x03 0x00 0x00
0x20000008: 0x54 0x45 0x4d 0x50 0x5f 0x53 0x31 0x00
0x20000010: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
你看,id = 0x5A 在第一个字节,但后面跟了个 0x00 的 padding。然后 temperature 是 0x01F4,以小端方式存在 0x20000002 和 0x20000003 两个字节里。这就是为什么我强调要看内存 dump——代码里看不到这些 padding 和字节序细节。
我常用的几个 x 命令组合:
x/16bx addr— 以十六进制字节显示,最常用x/4wx addr— 以 4 字节字显示,适合看寄存器x/8hx addr— 以半字(2 字节)显示x/s addr— 当作字符串显示,适合看 name 字段
实战:用 GDB 验证结构体大小与对齐
有时候编译器会自作主张地调整结构体布局。我习惯在 GDB 里验证一下:
(gdb) print sizeof(struct sensor_data)
$4 = 24
(gdb) print &sensor.id
$5 = (uint8_t *) 0x20000000
(gdb) print &sensor.temperature
$6 = (uint16_t *) 0x20000002
(gdb) print &sensor.pressure
$7 = (uint32_t *) 0x20000004
看到没?id 在 0x00,temperature 在 0x02,中间跳过了 0x01。这就是 padding。如果你用 __attribute__((packed)),这些 padding 就没了,但访问速度会变慢。
SVG:结构体调试流程
联合体调试的特殊场景
联合体在嵌入式里常用于解析通信协议。比如一个 CAN 报文的数据段,可能根据 ID 不同有不同的含义。这时候 GDB 的 print 命令就有点不够用了。
我推荐的做法是:先用 x/8bx 把原始字节 dump 出来,然后手动对照协议文档解析。虽然麻烦,但最可靠。等确认了字节序和偏移量之后,再写代码用联合体去访问。
// 假设收到一个 CAN 报文
union can_data {
uint8_t raw[8];
struct {
uint16_t rpm;
uint16_t speed;
uint32_t mileage;
} engine;
struct {
uint8_t battery_level;
uint8_t temperature;
uint16_t reserved;
uint32_t error_code;
} battery;
};
在 GDB 里:
(gdb) x/8bx can_msg.data
0x20001000: 0x0a 0x00 0x3c 0x00 0x00 0x00 0x01 0x00
(gdb) print can_msg.engine
$8 = {rpm = 10, speed = 60, mileage = 1}
(gdb) print can_msg.battery
$9 = {battery_level = 10, temperature = 0, reserved = 15360, error_code = 1}
你看,同一块内存,用 engine 视角看是合理的,用 battery 视角看就完全不对了。这就是联合体的陷阱——你得确保用对了成员。
总结一下调试要点
| 场景 | 推荐命令 | 看什么 |
|---|---|---|
| 查看结构体定义 | ptype | 成员类型、嵌套关系 |
| 查看成员值 | print /x | 十六进制值,避免十进制误导 |
| 检查内存布局 | x/16bx | padding、字节序、对齐 |
| 验证结构体大小 | print sizeof | 是否与预期一致 |
| 联合体多视角 | print + x | 不同成员解释同一内存 |
调试结构体和联合体,说白了就是跟内存布局较劲。GDB 给了你一双透视眼,能直接看到变量在内存里的真实样子。我个人觉得,与其花时间猜编译器怎么排布结构体,不如直接 dump 出来看一眼。省时省力,还不会出错。
嗯,最后提醒一句:如果你在调试时发现结构体大小跟 sizeof 算出来的不一样,别急着怀疑编译器。先看看是不是有 #pragma pack 或者 __attribute__((packed)) 在起作用。我见过太多人在这上面栽跟头了。