调试结构体与联合体: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.wordprint 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}

看到没?小端模式下,低字节在低地址。这个顺序搞反了,数据解析就全错了。我有个同事曾经因为这个问题,在解析网络协议时整整折腾了两天。

注意: 联合体里如果有位域,GDB 打印时可能不会按你期望的方式显示。这时候最好用 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 就没了,但访问速度会变慢。

核心要点: 调试结构体时,永远不要假设编译器会按你写的顺序排布字段。用 GDB 看一眼内存,比猜一百遍都管用。

SVG:结构体调试流程

结构体调试流程 1. 查看类型定义 ptype / pahole 2. 打印结构体 print /x struct 3. 检查成员地址 &member 偏移量 发现
问题? 是 → 内存 dump x/16bx addr 否 → 验证通过 sizeof 符合预期 分析字节序 小端/大端? 修正代码

联合体调试的特殊场景

联合体在嵌入式里常用于解析通信协议。比如一个 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 视角看就完全不对了。这就是联合体的陷阱——你得确保用对了成员。

曾经踩过的坑: 我在调试一个 BMS(电池管理系统)时,联合体里有个位域定义错了顺序。GDB 打印出来每个字段的值都对不上,折腾了三个小时才发现是 bit order 的问题。从那以后,我调试联合体位域时一定会用 x 命令看原始二进制。

总结一下调试要点

场景 推荐命令 看什么
查看结构体定义 ptype 成员类型、嵌套关系
查看成员值 print /x 十六进制值,避免十进制误导
检查内存布局 x/16bx padding、字节序、对齐
验证结构体大小 print sizeof 是否与预期一致
联合体多视角 print + x 不同成员解释同一内存

调试结构体和联合体,说白了就是跟内存布局较劲。GDB 给了你一双透视眼,能直接看到变量在内存里的真实样子。我个人觉得,与其花时间猜编译器怎么排布结构体,不如直接 dump 出来看一眼。省时省力,还不会出错。

嗯,最后提醒一句:如果你在调试时发现结构体大小跟 sizeof 算出来的不一样,别急着怀疑编译器。先看看是不是有 #pragma pack 或者 __attribute__((packed)) 在起作用。我见过太多人在这上面栽跟头了。

公众号:蓝海资料掘金营,微信 deep3321