C99标准规定:可以定义一个变长数组。也就是说,数组的长度在编译时是未确定的,在程序运行的时候才确定,甚至可以由用户指定大小。
int len;
int a[len];
除此之外,GNU C 还支持零长度数组。其定义如下:
int a[0];
零长度数组有一个奇特的地方,就是它不占用内存存储空间。当使用sizeof查看其大小,通过运行结果可以看到,零长度数组在内存中不占用存储空间,长度大小为0。
零长度数组一般单独使用的机会很少,它常常作为结构体的一个成员,构成一个变长结构体。
在一个变长结构体中,零长度数组不占用结构体的存储空间,但是我们可以通过使用结构体的成员 a 去访问内存,非常方便。变长结构体的使用示例如下:
struct buffer
{
int len;
int a[0];
}
int main(void)
{
struct buffer *buf;
buf = (struct buffer *)malloc(sizeof(struct buffer)+20);
buf->len = 20;
strcpy(buf->a, "hello yiqixue!\n");
puts(buf->a);
free(buf);
return 0;
}
在这个程序中 , 我们使用 malloc 申请一片内存 ,大小为 sizeof(buffer)+20,即 24 字节,其中 4 字节为结构体buffer的大小。
成员变量 len 用来表示内存的长度 20,20字节空间是我们使用的内存空间。我们可以通过结构体成员a直接访问这片内存。
关于零长度数组的应用可以参考:C语言中长度为零的数组详解。
指针与零长度数组
思考一个问题:为什么不使用指针来代替零长度数组?
大家可能常常听到:数组名在作为函数参数进行参数传递时,就相当于一个指针。注意,数组名在作为参数传递时,传递的确实是一个地址,但数组名绝不是指针,两者不是同一个东西。
数组名用来表征一块连续内存空间的地址,而指针是一个变量,编译器要给它单独分配一个内存空间,用来存放它指向的变量的地址。
看一段示例程序:
struct buffer1
{
int len;
int a[0];
};
struct buffer2
{
int len;
int *a;
};
int main(void)
{
printf("buffer1:%d\n", sizeof(struct buffer1));
printf("buffer2:%d\n", sizeof(struct buffer2));
return 0;
}
在32位CPU平台上,这段程序的运行结果为:
buffer1: 4
buffer2: 8
对于一个指针变量,编译器要为这个指针变量单独分配一个存储空间,然后在这个存储空间上存放另一个变量的地址。
而对于数组名,编译器不会再给它分配一个单独的存储空间,它仅仅是一个符号,和函数名一样,用来表示一个地址 。
那么为什么不用指针,而用零长数组呢?如果使用指针,指针本身占用存储空间不说,它远远没有零长度数组用得巧妙,零长度数组不会对结构体定义造成冗余,而且使用起来很方便。
扩展关键字 attribute
GNU C 增加了一个 __attribute__
关键字用来声明一个函数、变量或类型的特殊属性。
声明这个特殊属性的主要用途就是,指导编译器在编译程序时进行特定方面的优化或代码检查。例如,我们可以通过属性声明来指定某个变量的数据对齐方式。
__attribute__
的用法非常简单,当我们定义一个函数、变量或类型时,直接在它们名字旁边添加下面的属性声明即可:
__attribute__((ATTRIBUTE))
需要注意的是,__attribute__
后面是两对小括号,不能只写一对,否则编译就会报错。括号里面的 ATTRIBUTE 表示要声明的属性。
目前 __attribute__
支持十几种属性声明:
- section.
- aligned.
- packed.
- format.
- weak.
- alias.
- noinline.
- always_inline.
- ……
在这些属性中,aligned 和 packed 用来显式指定一个变量的存储对齐方式。在正常情况下,当我们定义一个变量时,编译器会根据变量类型给这个变量分配合适大小的存储空间,按照默认的边界对齐方式分配一个地址。
而使用 __atttribute__
这个属性声明,就相当于告诉编译器:按照我们指定的边界对齐方式去给这个变量分配存储空间 。
char c2 __attribute__((aligned(8))) = 4;
int val __attribute__((section(".data")));
有些属性可能还有自己的参数。如 aligned(8) 表示这个变量按 8 字节地址对齐,属性的参数也要使用小括号括起来。
如果属性的参数是一个字符串,则小括号里的参数还要用双引号引起来。
当然,我们也可以对一个变量同时添加多个属性说明。在定义变量时,各个属性之间用逗号隔开:
char c2 __attribute__((packed,aligned(4)));
char c2 __attribute__((packed,aligned(4))) = 4;
__attribute__((packed,aligned(4))) char c2 = 4;
以上示例中,我们对一个变量添加两个属性声明,这两个属性都放在 __attribute__(())
的两对小括号里面,属性之间用逗号隔开。
注意,属性声明要紧挨着变量,上面的三种声明方式都是没有问题的。
面的声明方式在编译的时候可能就通不过:
char c2 = 4 __attribute__((packed,aligned(4)));
接下来,我们讲解几个特殊属性。
section 属性的主要作用是:在程序编译时,将一个函数或变量放到指定的段,即放到指定的section中。
一个可执行文件主要由代码段、数据段、BSS段构成。代码段主要存放编译生成的可执行指令代码,数据段 和 BSS 段用来存放全局变量、未初始化的全局变量。代码段、数据段和 BSS 段构成了一个可执行文件的主要部分。
除了这三个段,可执行文件中还包含其他一些段。用编译器的专业术语讲,还包含其他一些 section,如只读数据段、符号表等 。
在 Linux 环境下,使用 GCC 编译生成一个可执行文件 a.out,使用 readelf 命令,就可以查看这个可执行文件中各个 section 的基本信息,如大小、起始地址等。
在这些 section 中,.text section 就是我们常说的代码段;.data section 是数据段;.bss section 是 BSS 段。
在 GNU C 中,我们可以通过 __attribute__
的 section 属性,显式指定一个函数或变量,在编译时放到指定的 section里面。
我们知道,未初始化的全局变量默认是放在 .bss section 中的,即默认放在 BSS 段中。现在我们就可以通过 section 属性声明,把这个未初始化的全局变量放到数据段 .data 中:
int val = 8;
int val_n __attribute__((section(".data")));
int main(void)
{
return 0;
}
GNU C通过 attribute 来声明 aligned 和 packed 属性,指定一个变量或类型的对齐方式。
这两个属性用来告诉编译器:在给变量分配存储空间时,要按指定的地址对齐方式给变量分配地址。
如果你想定义一个变量,在内存中以8字节地址对齐,就可以这样定义:
int a __attribute__((aligned(8));
通过aligned属性,我们可以显式地指定变量a在内存中的地址对齐方式。
注意,aligned 有一个参数,表示要按几字节对齐,地址对齐的字节数必须是 2 的幂次方,否则编译就会出错。
通过 aligned 属性声明,虽然可以显式地指定变量的地址对齐方式,但是也会因边界对齐造成一定的内存空洞,浪费内存资源。
按照地址对齐的一个原因为:这种对齐设置可以简化 CPU 和内存 RAM 之间的接口和硬件设计。
对于 32 位的计算机系统来说,在CPU读取内存时,硬件设计上可能只支持 4 字节或 4 字节倍数对齐的地址访问,CPU每次向内存 RAM 读写数据时,一个周期可以读写 4 字节。
结构体对齐
编译器在给一个结构体变量分配存储空间时,不仅要考虑结构体内各个基本成员的地址对齐,还要考虑结构体整体的对齐。为了结构体内各个成员地址对齐,编译器可能会在结构体内填充一些空间;为了结构体整体对齐,编译器可能会在结构体的末尾填充一些空间 。
详细可参考:结构体大小 你算对了吗
根据结构体的对齐规则,结构体的整体对齐要按结构体所有成员中最大对齐字节数或其整数倍对齐,或者说结构体的整体长度要为其最大成员字节数的整数倍,如果不是整数倍则要补齐 。
指定结构体某个成员变量的地址对齐方式,成员变量 b 按照 4 字节对齐:
struct data
{
char a;
short b __attribute__((aligned(4)));
int c;
};
该结构体大小为 12 字节。这是因为,我们显式指定 short 变量以 4 字节地址对齐,导致变量 a 的后面填充了 3 字节空间。int型变量 c 也要4字节对齐,所以变量b的后面也填充了2字节,导致整个结构体的大小为12字节。
显式指定整个结构体的对齐方式:
struct data
{
char a;
short b;
int c;
} __attribute__((aligned(16)));
这个结构体成员共占 8 个字节。正常来说,整个结构体的对齐只要按最大成员的对齐字节数(4字节)对齐即可。
但是,经过显式指定结构体整体以 16 字节对齐后,编译器就会在这个结构体的末尾填充 8 字节以满足 16 字节对齐的要求,最终导致结构体的总长度变为16字节。
注意,我们通过这个属性声明,其实只是建议编译器按照这种大小地址对齐,但不能超过编译器允许的最大值。编译器对每个基本数据类型都有默认的最大边界对齐字节数。若超过了,则编译器只能按照它规定的最大对齐字节数来给变量分配地址。
属性packed
packed 属性一般用来减少地址对齐,指定变量或类型使用最可能小的地址对齐方式。
struct data
{
char a;
short b __attribute__((packed));
int c __attribute__((packed));
};
上例中,结构体成员 b 和 c 使用 packed 属性,告诉编译器尽量使用最小的地址(1字节)对齐分配地址空间。这个结构体大小为 7 字节。
也可以对整个结构体添加 packed 属性,这和分别对每个成员添加packed属性效果是一样的:
struct data
{
char a;
short b;
int c;
}__attribute__((packed));
对一个变量或类型同时使用 aligned 和 packed 属性声明,既避免了结构体内各成员因地址对齐产生内存空洞,又指定了整个结构体的对齐方式。
struct data
{
char a;
short b;
int c;
}__attribute__((packed,aligned(8)));
在上面的程序中,结构体内所有成员所占的存储空间为 7 字节,成员之间没有空洞。
使用 aligned(8) 指定结构体按 8 字节地址对齐,所以编译器要在结构体后面填充 1 字节,这样整个结构体的大小就变为8字节,按8字节地址对齐。
已剪辑自: https://mp.weixin.qq.com/s/omTXePTMcH83-820zmBSAw
不同编译器,由于开发环境、硬件平台、性能优化的需要,除了支持 C 语言标准,还会自己做一些扩展。
本系列文章,归纳总结 GCC 编译器对C语言标准的扩展语法,并逐一进行讲解。
前期文章:
接下来继续讲解其他扩展语法内容。
属性声明
GNU C 增加了一个 __attribute__
关键字用来声明一个函数、变量或类型的特殊属性。
声明这个特殊属性的主要用途就是,指导编译器在编译程序时进行特定方面的优化或代码检查。例如,我们可以通过属性声明来指定某个变量的数据对齐方式。
__attribute__
的用法非常简单,当我们定义一个函数、变量或类型时,直接在它们名字旁边添加下面的属性声明即可:
__attribute__((ATTRIBUTE))
下面几个小节,继续讲解一些特殊属性声明。
属性声明 format
GNU通过 __attribute__
扩展的 format 属性,来指定变参函数的参数格式检查。
一些自定义的打印函数往往是变参函数,用户在调用这些接口函数时参数往往不固定,那么编译器在编译程序时,怎么知道我们的参数格式对不对呢?如何对我们传进去的实参做格式检查呢?
__attribute__
的 format 属性这时候就派上用场了。它的使用方法如下:
__attribute__((format(archetype, string-index, first-to-check)))
void LOG(const char *fmt, ...) __attribute__((format(printf, 1, 2)))
定义一个变参函数 LOG,属性 format(printf,1,2) 有3个参数,第1个参数 printf 是告诉编译器,按照 printf() 函数的标准来检查;第 2 个参数表示在 LOG() 函数所有的参数列表中格式字符串的位置索引;第3个参数是告诉编译器要检查的参数的起始位置。
使用示例
LOG("yi qi xue qian ru shi, %d\n", 100);
在这个 LOG() 函数中有 2 个参数,第 1 个参数是格式字符串,第 2 个参数是要打印的一个常量值 10,用来匹配格式字符串中的占位符。
通过 format(printf,1,2) 属性声明,告诉编译器:LOG() 函数的参数,其格式字符串的位置在所有参数列表中的索引是 1,即第一个参数;要编译器帮忙检查的参数,在所有的参数列表里索引是2。
如果一个字符串中含有格式匹配符(例如 %d),也称为占位符,那么这个字符串就是格式字符串。
知道了 LOG() 参数列表中格式字符串的位置和要检查的参数位置,编译器就会按照检查 printf 的格式打印一样,对 LOG() 函数进行参数检查了。
如果 LOG 定义如下:
void LOG(int num, const char *fmt, ...) __attribute__((format(printf, 2, 3)))
这个函数定义多了一个参数 num,格式字符串在参数列表中的位置发生了变化(在所有的参数列表中,索引由1变成了2),要检查的第一个变参的位置也发生了变化(索引从原来的2变成了3)。
补充说明,实现自己的打印函数有一些优势:
- 实现自己需要的打印格式
- 实现打印开关控制和优先级控制
- 根据需要不断添加功能
属性声明 weak
GNU C通过 weak 属性声明,可以将一个强符号转换为弱符号。使用方法如下。
void __attribute__((weak)) func(void);
int num __attribute__((weak));
先来解释一下强符号和弱符号相关的知识。
在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已。符号可以分为强符号和弱符号:
- 强符号:函数名,初始化的全局变量名
- 弱符号:未初始化的全局变量名。
强符号和弱符号主要用来解决在程序链接过程中,多个同名全局变量、同名函数的冲突问题。编译器对于这种同名符号冲突,在做符号决议时,一般会选用强符号,丢掉弱符号。
注意,一个项目中,不能同时存在两个强符号。如果在一个多文件的工程中定义两个同名的函数或全局变量,那么链接器在链接时就会报重定义错误。
允许强符号和弱符号同时存在;若同时定义一个初始化的全局变量和一个未初始化的全局变量,这种写法在编译时是可以编译通过的。
当同名的符号都是弱符号时,会选择在内存中的存储空间大的那一个。
举例如下,使用GNU C扩展的weak属性,将一个强符号转换为弱符号 :
// func.c 文件中的内容
int a __attribbute__((weak)) = 1;
void func(void)
{
printf("func: a = %d\n", a);
}
//main.c 文件中的内容
int a = 4;
void func(void);
int main(void)
{
printf("main: a = %d\n", a);
func();
return 0;
}
编译运行后,可以看到执行结果:
$ gcc -o test main.c func.c
$ ./test
main: a = 4
func: a = 4
我们通过 weak 属性声明,将 func.c 中的全局变量 a 转化为一个弱符号,然后在 main.c 中同样定义一个全局变量 a,并初始化 a 为 4。链接器在链接时会选择 main.c 中的这个强符号,所以在两个文件中,变量a的打印值都是4。
函数的强符号和弱符号
链接器对于同名函数冲突,同样遵循相同的规则。函数名本身就是一个强符号,在一个工程中定义两个同名的函数,编译时肯定会报重定义错误。
我们可以通过 weak 属性声明,将其中一个函数名转换为弱符号。
// func.c 文件中的内容
int a __attribbute__((weak)) = 1;
void func(void)
{
printf("func: a = %d\n", a);
printf("func.c: strong symbol\n");
}
//main.c 文件中的内容
int a = 4;
void __attribbute__((weak)) func(void)
{
printf("a = %d\n", a);
printf("main.c: weak symbol\n");
}
int main(void)
{
func();
return 0;
}
编译后执行结果如下:
a = 4
func.c: strong symbol
在 main.c 中定义了一个同名的 func() 函数,然后通过weak属性声明将其转换为一个弱符号。
链接器在链接时会选择func.c中的强符号,当我们在 main() 函数中调用 func() 函数时,实际上调用的是func.c 文件里的 func() 函数。
全局变量 a 在 func.c 中定义的是一个弱符号,所以在 func() 函数中打印的是 main.c 中的全局变量a的值。
弱符号的用途
在编译阶段,当编译器只看到一个源文件中引用一个变量或函数的声明,而没有看到其定义时,编译器一般编译不会报错,编译器会认为这个符号可能在其他文件中定义。
在链接阶段,链接器会到其他文件中找这些符号的定义,若未找到,则报未定义错误。
如果一个函数被声明为弱符号时,链接器找不到这个函数的定义时,也不会报错。编译器会将这个函数名,设置为0或一个特殊的值。
当程序运行调用这个函数时,跳转到零地址或一个特殊的地址会报错,产生一个内存错误(段错误)。
也就是说,编译、链接不会有问题,到运行阶段就会出错。
void __attribute__((weak)) func();
int main(void)
{
func();
return 0;
}
弱符号的这个特性,在库函数中应用得很广泛。如你在开发一个库时,基础功能已经实现,有些高级功能还没实现,那么你可以将这 些函数通过 weak 属性声明转换为一个弱符号。
通过这样设置,即使还没有定义函数,我们在应用程序中只要在调用之前做一个非零的判断就可以了,并不影响程序的正常运行。等以后发布新的库版本,实现了这些高级功能,应用程序也不需要进行任何修改,直接运行就可以调用这些高级功能。
属性声明:alias
GNU C 扩展了一个 alias 属性,这个属性很简单,主要用来给函数定义一个别名。
void __f(void)
{
printf("__f\n");
}
void f() __attribbute__((alias("__f")));
int main(void)
{
f();
return 0;
}
通过alias属性声明,我们可以给 __f()
函数定义一个别名 f()
,以后如果想调用 __f()
函数,则直接通过f()调用即可。
在Linux内核中,你会发现 alias 有时会和 weak 属性一起使用。如有些函数随着内核版本升级,函数接口发生了变化,我们可以通过 alias 属性对这个旧的接口名字进行封装,重新起一个接口名字。
// f.c 文件
void __f(void)
{
printf("__f\n");
}
void f() __attribute__((weak, alias("__f")));
//main.c 文件
void __attribute__((weak)) f(void);
void f(void)
{
printf("f()\n");
}
int main(void)
{
f();
return 0;
}
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
_f")));
int main(void)
{
f();
return 0;
}
通过alias属性声明,我们可以给 `__f()` 函数定义一个别名 `f()`,以后如果想调用 `__f()` 函数,则直接通过f()调用即可。
在Linux内核中,你会发现 alias 有时会和 weak 属性一起使用。如有些函数随着内核版本升级,函数接口发生了变化,我们可以通过 alias 属性对这个旧的接口名字进行封装,重新起一个接口名字。
// f.c 文件
void __f(void)
{
printf(“__f\n”);
}
void f() attribute((weak, alias(“__f”)));
//main.c 文件
void attribute((weak)) f(void);
void f(void)
{
printf(“f()\n”);
}
int main(void)
{
f();
return 0;
}
[外链图片转存中...(img-C6CLASqI-1726011968211)]
[外链图片转存中...(img-M2ZRWZ2A-1726011968212)]
[外链图片转存中...(img-AkM74nTz-1726011968213)]
**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**