实践出真知--你的字节对齐和堆栈认知可能是错误的

本文通过实例探讨了字节对齐和堆栈在实际操作中的复杂性,揭示了编译环境、平台及编译器配置对字节对齐和堆栈行为的影响。在32位环境中,结构体的字节对齐并不总是按预期进行,堆栈的增长方向也可能因编译选项而变。作者强调在调试和理解程序行为时,需要充分考虑这些因素。
摘要由CSDN通过智能技术生成

目录

零 引子

一 固有认知

1 字节对齐

2 堆栈

二 真实情况

1 字节对齐

2 堆栈

三 总结


零 引子

最近在查一个问题时,需要查看程序的堆栈。但在分析堆栈时,出现了一些意料之外的情况。这打破了我对字节对齐和堆栈的固有认知。就如标题所指示,实践出真知,有时候我们的固有印象可能是错误的,并可能因此为问题的解决带来额外的不必要麻烦。具体是什么情况呢,听龙赤子给您慢慢道来。

一 固有认知

对于有开发经验的C/C++码农来讲,字节对齐和堆栈相关的知识是入门必备技能。面试中,这类问题也是高频出现。可见它们是非常基础,非常重要的知识。

同样,对于有开发经验的码农来讲,很大可能会觉得字节对齐和堆栈问题是小case,自己早已深入理解,掌握透彻了。来,我们看看,你对这两个知识点的认知是不是这样的:

1 字节对齐

字节对齐与结构体有关。CPU访存时,为了提高效率,一般会按照4字节对齐(32位CPU)方式进行。所以,对于代码中定义的结构体,在内存中保存时,并非按照它们定义的那样存放。举个例子,对于下面这个结构体定义:

struct test_align {
    char a;
    int  b;
    char c;
    int  d;
};

如果char占用1个字节,int占用4个字节,在32位CPU系统中,其占用内存并非如实际定义中的1 + 4 + 1 + 4 = 10字节。你是不是认为它需要 4 + 4 + 4 + 4 = 16字节。我们实际验证一下,毕竟这次的主题就是实践出真知嘛。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>

struct test_align {
    char a;
    int  b;
    char c;
    int  d;
};

int main (int argc, char** argv) {
  struct test_align testAlign;
  printf ("Size of test align struct is %ld\n", sizeof(testAlign));
  return 0;
}

g++编译并执行

可以看到,如期望的结果,输出了16.

字节对齐在网络编程或者跟设备相关的编程中用的较多。有时候为了方便解析设备数据,我们可能会使用pack强制让结构体按照字节方式对齐,也就是紧凑模式。这样中间就不会有插入的对齐字节。

好的,目前为止,一切还在可控之中。我们再来看堆栈。

2 堆栈

严格来讲,我们这里要讨论的是栈。在很多资料中,可以看到这方面的介绍,且都是放在一起进行的。如果你是学习型选手,对于堆栈,你的理解是不是这样:

栈是一种先进后出的结构(队列则是先进先出),程序中的局部变量、函数参数等保存在栈上,动态分配的内存(如malloc)则保存在堆中。栈中的内存会自动释放,堆中的则需要手动释放。大部分人的了解可能到这一点就结束了。更进一点,则可能会了解不要定义过大的局部变量,否则可能导致栈溢出错误。要正确释放堆中内存,否则可能导致内存泄漏。再更进一点,栈存在满增满减,空增空减的问题,堆内存是库通过brk系统调用从内核中申请的,brk以页为单位分配,库则在此基础上管理获取的页,以字节为单位给应用分配。栈向下增长(从高地址到低地址),而堆一般是向上增长(从低地址到高地址)。

对于堆栈,了解到最后一点的,已经不错了。

好了,在上面的基础知识或者说是常识上,我们来看看实际中会有什么差异。

二 真实情况

还是分开来看。先说字节对齐。

1 字节对齐

对于如下结构体定义,它在内存中的占用情况是什么样的呢(32位CPU)

struct test_align {
  int field32;
  long long field64;
};

我相信很多人都会回答是 4 + 8 = 12.博主本人一开始也是这样惯性思维,认为是12.那么,是不是呢,我们实际试试(因为现在电脑基本都是64位环境,所以我在一个arm32位环境下进行测试):

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>

struct test_align {
    char a;
    int  b;
    char c;
    int  d;
};

struct test_align1 {
    int field32;
    long long field64;
};

int main (int argc, char** argv) {
    struct test_align testAlign;
    printf ("Size of test align struct is %ld\n", sizeof(testAlign));

    struct test_align1 testAlign1;
    testAlign1.field32 = 1;
    testAlign1.field64 = 2;

    printf("Size of test align1 struct is %ld \n", sizeof(testAlign1));

    return 0;
}

可以看到,实际返回了16,也就是按照8字节对齐了。为什么要用这个例子呢,因为博主在调试epoll时遇到了问题,这个结构体跟epoll里event结构体定义是类似的。

struct uv__epoll_event {
  uint32_t events;
  uint64_t data;
};

在用gdb获取堆栈地址上的内容时,一开始跟实际的对应不上,

原来是对齐方式跟固有思维冲突了。按照8字节对齐,那么上面每两行正好就是一个结构体内存占用。第一行是事件集合,第二行是描述符。

通过这个例子,我们可以看到,对齐问题并非那么简单。代码中通过pack可以强制对齐方式,编译器也可以配置对齐方式,平台字节数也可能影响对齐方式。

我们强制编译器按照4字节方式对齐,则输出结果就是12。

实际中,如果对齐可能影响代码逻辑正确性的话,建议还是在实际的编译环境和平台上测试一下。或者明确指定对齐方式。不过强制指定的方式可能影响到其他结构体,最好限制范围,只在相关结构体上进行,如果按照上面编译器配置方式,则本不需要强制的结构体也可能被强制而导致总体性能受影响。

2 堆栈

对于堆栈,基础内容前面已经介绍了。这里主要补充增长方向相关的内容。因为这也与我们调试息息相关。

当用gdb查看堆栈时,可能会想到,我们的局部变量在栈上是如何增长的。当然,gdb可以直接打印局部变量,但是,了解增长方向还是有用的。

很多人可能会认为,堆栈是向下增长的,所以先定义的变量在高地址处,后定义的变量在低地址处。网上也有很多这样的例子。那么,真实情况是什么样的,我们动手实践一下看看:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>

struct test_align {
    char a;
    int  b;
    char c;
    int  d;
};

struct test_align1 {
    int field32;
    long long field64;
};

struct uv__epoll_event {
    uint32_t events;
    uint64_t data;
};

int main (int argc, char** argv) {
    struct uv__epoll_event events[1024];
    struct uv__epoll_event e;

    struct test_align testAlign;
    printf ("Size of test align struct is %ld\n", sizeof(testAlign));

    struct test_align1 testAlign1;
    testAlign1.field32 = 1;
    testAlign1.field64 = 2;
    printf("Size of test align1 struct is %ld \n", sizeof(testAlign1));

    printf("Local events addr is %p \n", events);
    printf("Local e addr is %p \n", &e);

    return 0;
}

在代码main函数的开始,我们定义了两个本地变量。然后先在本地机器上编译,运行。

可以看到,先定义的在高地址0x7fff500f4080,后定义的在低地址 0x7fff500f4060,这符合我们关于栈向下增长的预期。

我们再到前面的arm平台环境下试试看。

可以看到,此时,先定义的局部变量分配了0xbed60c70地址,后定义的局部变量分配了地址0xbed64c90,栈是向上增长的。

如果我们给编译器增加编译选项-fstack-protector,则结果会怎样?

此时情况发生了变化,堆栈开始向下增长,符合了最初的预期。

这个例子说明,栈的增长方向也是跟编译器配置相关的。就当前测试所用arm平台,在内核层,堆栈是向下增长的。

另外,对于第一个本地变量数组,其内部的数组项是怎样的呢?下标高的是否在低地址呢?感兴趣的读者可以验证一下。这一点目前还是符合预期的,就是按照正常思路,从低地址向高地址增加。

三 总结

通过上面两个例子看到,字节对齐和堆栈的具体情况,跟编译环境是非常相关的,如果你需要gdb调试自己的程序,且需要观察堆栈,如果感觉跟预期不符,那么验证一下上面两点是十分必要的。还有,就是如果调试涉及内核,比如系统调用,可能内核的堆栈增长跟应用是不一样的。最后,这里面其实还有很多内容在这里并没有涉及到,感兴趣的读者可以进一步挖掘。怀疑--验证--怀疑--验证,是不错的实践型学习方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值