Gcc 的 _ _ tribute _ _ ((打包))/# 杂注包是否不安全?

在 C 语言中,编译器将按照声明的顺序布局结构的成员,在成员之间或最后一个成员之后插入可能的填充字节,以确保每个成员正确对齐。

Gcc 提供了一个语言扩展 __attribute__((packed)),它告诉编译器不要插入填充,从而允许结构成员不对齐。例如,如果系统通常要求所有 int对象都具有4字节对齐,则 __attribute__((packed))可能导致以奇数偏移量分配 int结构成员。

引用海湾合作委员会的文件:

“ pack”属性指定变量或结构字段 应该具有尽可能小的对齐方式——对变量使用一个字节, 属性指定一个较大的值,则为字段指定一个位 “对齐”属性。

显然,这种扩展的使用可能导致较小的数据需求但较慢的代码,因为编译器必须(在某些平台上)生成代码来一次访问一个错误对齐的成员一个字节。

但是有没有不安全的情况呢?编译器是否总是生成正确(尽管较慢)的代码来访问封装结构的不对齐成员?它是否有可能在所有情况下都这样做?

168841 次浏览

是的,__attribute__((packed))在某些系统上可能是不安全的。这种症状可能不会出现在 x86上,这只会使问题更加隐蔽; 在 x86系统上进行测试不会发现问题。(在 x86上,不对齐的访问是在硬件中处理的; 如果取消引用指向奇数地址的 int*指针,它会比正确对齐的时候慢一点,但是会得到正确的结果。)

在其他一些系统上,比如 SPARC,试图访问对齐不正确的 int对象会导致总线错误,使程序崩溃。

还有一些系统中,不对齐的访问会悄悄地忽略地址的低阶位,导致它访问错误的内存块。

考虑以下方案:

#include <stdio.h>
#include <stddef.h>
int main(void)
{
struct foo {
char c;
int x;
} __attribute__((packed));
struct foo arr[2] = { { 'a', 10 }, {'b', 20 } };
int *p0 = &arr[0].x;
int *p1 = &arr[1].x;
printf("sizeof(struct foo)      = %d\n", (int)sizeof(struct foo));
printf("offsetof(struct foo, c) = %d\n", (int)offsetof(struct foo, c));
printf("offsetof(struct foo, x) = %d\n", (int)offsetof(struct foo, x));
printf("arr[0].x = %d\n", arr[0].x);
printf("arr[1].x = %d\n", arr[1].x);
printf("p0 = %p\n", (void*)p0);
printf("p1 = %p\n", (void*)p1);
printf("*p0 = %d\n", *p0);
printf("*p1 = %d\n", *p1);
return 0;
}

在使用 gcc 4.5.2的 x86 Ubuntu 上,它会产生以下输出:

sizeof(struct foo)      = 5
offsetof(struct foo, c) = 0
offsetof(struct foo, x) = 1
arr[0].x = 10
arr[1].x = 20
p0 = 0xbffc104f
p1 = 0xbffc1054
*p0 = 10
*p1 = 20

在 SPARCSolaris9和 gcc4.5.1上,它产生以下代码:

sizeof(struct foo)      = 5
offsetof(struct foo, c) = 0
offsetof(struct foo, x) = 1
arr[0].x = 10
arr[1].x = 20
p0 = ffbff317
p1 = ffbff31c
Bus error

在这两种情况下,编译程序时都不使用额外的选项,只使用 gcc packed.c -o packed

(使用单个结构而不是数组的程序不会可靠地表现出这个问题,因为编译器可以在奇数地址上分配结构,这样 x成员就可以正确地对齐。对于包含两个 struct foo对象的数组,至少一个或另一个将具有不对齐的 x成员。)

(在这种情况下,p0指向一个错误对齐的地址,因为它指向 char成员之后的一个封装的 int成员。因为 p1指向数组第二个元素中的同一个成员,所以它之前有两个 char对象——在 SPARC Solaris 上,数组 arr似乎被分配到一个偶数的地址,但不是4的倍数

当按名称引用 struct foo的成员 x时,编译器知道 x可能不对齐,并将生成其他代码以正确访问它。

一旦 arr[0].xarr[1].x的地址存储在指针对象中,编译器和正在运行的程序都不知道它指向的是一个未对齐的 int对象。它只是假设它是正确对齐的,导致(在某些系统上)总线错误或类似的其他故障。

我相信,在海湾合作委员会中解决这个问题是不切实际的。一个通用的解决方案将需要,每次尝试解引用一个指针到任何类型的非平凡的对齐要求,或者(a)在编译时证明指针不指向一个封装的结构的错误对齐的成员,或者(b)生成更笨重和更慢的代码,可以处理对齐或错误对齐的对象。

我提交了一份 GCC 漏洞报告。正如我所说的,我不认为修复它是切实可行的,但是文档应该提到它(目前没有)。

更新 : 从2018-12-20开始,这个 bug 被标记为 FIXED。补丁将出现在 gcc9中,并添加一个新的 -Waddress-of-packed-member选项,默认情况下启用该选项。

当被包装成员的地址的结构或联盟被采取,它可以 导致未对齐的指针值。此修补程序将添加 - 封装成员的地址,以检查对齐指针分配和警告未对齐的地址以及未对齐的指针

我刚刚从源代码构建了这个版本的 gcc:

c.c: In function ‘main’:
c.c:10:15: warning: taking address of packed member of ‘struct foo’ may result in an unaligned pointer value [-Waddress-of-packed-member]
10 |     int *p0 = &arr[0].x;
|               ^~~~~~~~~
c.c:11:15: warning: taking address of packed member of ‘struct foo’ may result in an unaligned pointer value [-Waddress-of-packed-member]
11 |     int *p1 = &arr[1].x;
|               ^~~~~~~~~

只要您总是通过 .(点)或 ->表示法通过 struct 访问值,那么它就是完全安全的。

没有的安全之处在于获取未对齐数据的指针,然后在不考虑这一点的情况下访问它。

此外,尽管已知 struct 中的每个条目是未对齐的,但也知道它是未对齐的 以一种特殊的方式,所以整个 struct 必须按照编译器的预期进行对齐,否则就会出现问题(在某些平台上,或者将来如果发明了一种优化未对齐访问的新方法)。

如上所述,不要将指针指向已打包的结构的成员。这简直是在玩火。当你说 __attribute__((__packed__))或者 #pragma pack(1)的时候,你真正在说的是“嘿,gcc,我真的知道我在做什么。”当事实证明您没有这样做时,您不能正确地指责编译器。

也许我们可以责怪编译器的自满。虽然 gcc 确实有一个 -Wcast-align选项,但默认情况下不启用它,也不启用 -Wall-Wextra。这显然是由于 gcc 开发人员认为这种类型的代码是一种脑死亡的“ 令人厌恶”,不值得解决——可以理解的蔑视,但是当一个没有经验的程序员碰巧遇到它时,它没有帮助。

考虑以下几点:

struct  __attribute__((__packed__)) my_struct {
char c;
int i;
};


struct my_struct a = {'a', 123};
struct my_struct *b = &a;
int c = a.i;
int d = b->i;
int *e __attribute__((aligned(1))) = &a.i;
int *f = &a.i;

在这里,a的类型是一个打包结构(如上所述)。类似地,b是指向打包结构的指针。表达式 a.i的类型(基本上)是带有1字节对齐的 int b3。cd都是正常的 int。在读取 a.i时,编译器为不对齐访问生成代码。当你阅读 b->ib的类型仍然知道它的打包,所以没有问题,他们的。e是一个指向单字节对齐的 int 的指针,因此编译器也知道如何正确地解除对它的引用。但是,当您进行赋值 b0时,您将一个未对齐的 int 指针的值存储在一个对齐的 int 指针变量中——这就是您出错的地方。我同意,gcc 应该通过 b4启用这个警告(甚至在 b1或 b2中也没有启用)。

(下面是一个虚构的例子。)打包结构的一个主要用途是您希望向其提供含义的数据流(比如256字节)。如果我举一个较小的例子,假设我有一个程序运行在我的 Arduino 上,它通过串行发送一个16字节的数据包,其含义如下:

0: message type (1 byte)
1: target address, MSB
2: target address, LSB
3: data (chars)
...
F: checksum (1 byte)

然后我可以声明

typedef struct {
uint8_t msgType;
uint16_t targetAddr; // may have to bswap
uint8_t data[12];
uint8_t checksum;
} __attribute__((packed)) myStruct;

然后,我可以通过 astruct.targetAddr 引用 targetAddr 字节,而不是纠结于指针算法。

现在,随着对齐的发生,在内存中使用 void * 指针指向接收到的数据并将其强制转换为 myStruct * 将无法工作 除非,编译器将 struct 视为打包的(也就是说,它按指定的顺序存储数据,并且在本例中使用了正好16个字节)。对于未对齐的读操作有性能损失,因此对程序正在积极处理的数据使用打包结构不一定是一个好主意。但是,当您的程序提供了一个字节列表时,打包结构使得编写访问内容的程序变得更加容易。

否则,您最终将使用 C + + 并编写一个具有访问器方法的类,这些方法在后台执行指针算法。简而言之,打包结构是为了有效地处理打包数据,打包数据可能是您的程序要处理的内容。在大多数情况下,您的代码应该从结构中读取值,使用它们,并在完成后将它们写回。所有其他应该做的外包装结构。问题的一部分在于 C 试图向程序员隐藏的底层的东西,以及如果这些东西对程序员真的很重要的话所需要的跳环。(你几乎需要在语言中使用一个不同的“数据布局”结构,这样你就可以说“这个东西有48字节长,foo 指的是13字节的数据,应该这样解释”; 还有一个单独的结构化数据结构,你可以说“我想要一个包含两个 int 的结构,名为 alice 和 bob,还有一个名为 carol 的 float,我不在乎你怎么实现它”——在 C 中,这两个用例都被硬塞进了结构化结构中

使用此属性肯定是不安全的。

它打破的一个特殊的东西是 union的能力,它包含两个或更多的结构来写一个成员和读另一个,如果结构有一个共同的初始序列的成员。C11标准第6.5.2.3节规定:

为了简化工会的使用,制定了一项特别保证: 如果一个联合包含几个共享一个 初始序列(见下文) ,如果联合对象 当前包含这些结构之一时,它是被允许的 检查其中任何一个的共同起始部分 已完成的联合类型的声明是可见的 结构共享一个公共的初始序列,如果对应 成员具有兼容的类型(对于位字段,宽度相同) 对于一个或多个初始成员的序列。

...

以下是一个有效的片段:

union {
struct {
int    alltypes;
}n;
struct {
int    type;
int    intnode;
} ni;
struct {
int    type;
double doublenode;
} nf;
}u;
u.nf.type = 1;
u.nf.doublenode = 3.14;
/*
...
*/
if (u.n.alltypes == 1)
if (sin(u.nf.doublenode) == 0.0)
/*
...
*/

当引入 __attribute__((packed))时,它打破了这一点。以下示例在 Ubuntu 16.04 x64上运行,使用 gcc 5.4.0,禁用了优化:

#include <stdio.h>
#include <stdlib.h>


struct s1
{
short a;
int b;
} __attribute__((packed));


struct s2
{
short a;
int b;
};


union su {
struct s1 x;
struct s2 y;
};


int main()
{
union su s;
s.x.a = 0x1234;
s.x.b = 0x56789abc;


printf("sizeof s1 = %zu, sizeof s2 = %zu\n", sizeof(struct s1), sizeof(struct s2));
printf("s.y.a=%hx, s.y.b=%x\n", s.y.a, s.y.b);
return 0;
}

产出:

sizeof s1 = 6, sizeof s2 = 8
s.y.a=1234, s.y.b=5678

尽管 struct s1struct s2有一个“公共的初始序列”,但应用于前者的封装意味着相应的成员不存在于相同的字节偏移量。结果是写入成员 x.b的值与从成员 y.b读取的值不同,尽管标准规定它们应该是相同的。