【Linux】特别篇--GNU C编译器扩展语法

前言:本章是我参考《嵌入式C语言自我修养》的GUN C编译器扩展语法这一章,对其中的内容进行了摘录、总结与归纳,并写了一些关于自己的理解,这边还是推荐大家去购买原作的,因为里面用通俗的语言解决了很多原理上的问题,让我对C语言又有了一个新的认识。至于这里有人会问,那你直接去翻书不就得了嘛,为什么还要写到这里,我的回答是,第一,我需要对学的知识进行总结归纳,第二,书中的内容太多,需要进行一定的缩减,第三,分享给其他人学习,第四,以后忘了的知识可以直接在这里查找,比翻书方便。

一、C标准与C编译器

1.1 C 标准与内容

标准

大家在日常生活中有没有想过,为什么你买的各种有线鼠标插上电脑就可以使用,那是因为它们都使用的是USB接口,所有生产鼠标的厂家都会使用USB这个接口标准,这样的统一标准带来了很多好处,比如电脑的接口为啥只有那么几个。

C语言也有自己的标准,它是由 ANSI(AMERICAN NATIONAL STANDARDS INSTITUTE: 美国国家标准协会,简称 ANSI)联合 ISO(国际化标准组织)召集各个编译器厂商大佬,各种技术团体,经过艰难的磋商,终于在1989年达成一致,发布了 C 语言标准,后来第二年又做了一些改进。因为是在 1989 年发布的,所以人们一般称其为 C89 或 C90 标准,或者叫做 ANSI C

内容

最初的C 标准文档主要就是规定 C 语言编程的一些语法惯例,比如:定义各种关键字、数据类型,定义各种运算规则,各种运算符的优先级和结合性,数据类型转换,变量的作用域,函数原型,函数嵌套层数,函数参数个数限制,标准库函数。

C 标准发布后,大家都遵守这个标准:程序员开发程序时,按照这种标准写;编译器厂商开发编译器时,也按照这种标准去解析、翻译程序。不同的编译器厂商支持统一的标准,这样大家写的程序,使用不同的编译器,都可以正确编译、运行,大大提高程序的开发效率,推动了 IT 行业的发展。

C 标准的发展

C 标准也是不断完善和扩展自己的标准的,就跟移动通信一样,也是从 2G、3G、4G 到 5G 不断发展变化的。C 标准也经历了下面四个阶段的发展:

  • K&R C
  • ANSI C
  • C99
  • C11

K&R C 一般也被称为传统 C,在C 标准没有出来前,大家普遍遵循的一个编程规范。
ANSI C ,上面讲过的,最初的C 标准,目前各种编译器默认支持的 C 语言标准。
C99 , ANSI 1999 年在 C89 标准的基础上新发布的一个标准,准对 ANSI C 标准做了一些扩充,例如,新增了布尔型,允许对结构体特定成员赋值。
C11 ,2011年发布的最新 C 语言标准,修改了 C 语言标准的一些 Bug、新增了一些特性,例如支持多线程。


1.2 C 编译器

标准是一回事,各种编译器支不支持是另一回事。这就跟手机一样,早期的手机可能只支持 2G 通信,后来可以支持 3G,在后来可以支持 4G了,并且可以兼容 2G/3G。目前 5G 标准发布,但是不是所有的手机都支持5G。

不同编译器,对 C 标准的支持也不一样。大多数编译器支持 ANSI C,这是目前默认的 C 标准。有的编译器可以支持 C99,或者支持C99 标准的部分特性。目前对 C99 标准支持最好的是 GNU C 编译器,这也是本篇文章要重点讲的。

扫描二维码关注公众号,回复: 14778703 查看本文章

1.3 编译器对C标准的支持与扩展

不同编译器,出于开发环境、硬件平台、性能优化的需要,除了支持 C 标准外,还会自己做一些扩展。

在51单片机上用 C 语言开发程序,我们经常使用 Keil for C51 集成开发环境。你会发现 Keil for C51 或其他 IDE 里的 C 编译器会对 C 语言标准作很多扩展。比如增加各种关键字:

  • data:RAM 的低128B空间,单周期直接寻址;
  • code:表示程序存储区;
  • bit:位变量,常用来定义单片机的 P0~P3 管脚;
  • sbit:特殊功能位变量;
  • sfr:特殊功能寄存器;
  • reentrant:重入函数声明。

如果你在程序中使用以上这些关键字,那么你的程序就只能使用51编译器来编译运行,你使用其它的编译器,比如 VC++6.0,是编译通不过的。
同样的道理,GCC 编译器,也对 C 标准做了很多扩展:

  • 零长度数组
  • 语句表达式
  • 内建函数
  • attribute特殊属性声明
  • 标号元素
  • case 范围

二、指定元素初始化

2.1 指定初始化数组元素

在 GNU C 中,通过数组元素索引,我们就可以给某个指定的元素直接赋值。如下:

int b[100] = {
    
     [10] = 1, [20] = 2 };

如果我们想给数组中某一个索引范围的数组元素初始化,可以采用下面的方法。如下:

int b[100] = {
    
     [10 ... 30] = 1, [50 ... 60] = 2 };

GNU C 支持使用...表示范围扩展,这个特性不仅可以使用在数组初始化中,也可以使用在 switch-case 语句中。比如下面的程序:

#include<stdio.h>
int main(void)
{
    
    
	int i = 4;
	switch(i)
	{
    
    
		case 1:
		printf("1\n");
		break;
		case 2 ... 8:
		printf("%d\n",i);
		break;
		case 9:
		printf("9\n");
		break;
		default:
		printf("default!\n");
		break;
	}
	return 0;
}

要注意...两边要留空格。


2.2 指定初始化数组元素

跟数组类似,在标准 C 中,结构体变量的初始化也要按照固定的顺序。在 GNU C 中我们也可以指定结构体某个成员的初始化。如下:

struct student{
    
    
	char name[20];
	int age;
};
int main(void)
{
    
    
	struct student stu=
	{
    
    
		.name = "xiaoming",
		.age = 21
	};
	printf("%s:%d\n",stu2.name,stu2.age);
	return 0;
}

在 Linux 内核驱动中,大量使用 GNU C 的这种指定初始化方式,通过结构体成员来初始化结构体变量。比如在字符驱动程序中,我们经常这样来初始化:

const static struct file_operations f_fops = {
    
    
	.owner    =    THIS_MODULE,
	.open     =    myled_open,
	.write    =    myled_write,
	.release  =    myled_release,
};

因为在结构体 file_operations 里面定义了很多结构体成员,而在实际的驱动中,我们只需要初始化部分成员变量,我们便可以通过访问结构体的成员来指定初始化。


三、语句表达式

3.1 表达式、语句和代码块

表达式

什么是表达式呢?表达式就是由一系列操作符和操作数构成的式子

  • 操作符可以是 C 语言标准规定的各种算术运算符、逻辑运算符、赋值运算符、比较运算符等。
  • 操作数可以是一个常量,也可以是一个变量。
  • 表达式也可以没有操作符
  • 表达式一般用来数据计算或实现某种功能的算法。

语句

表达式的后面加一个 ; 就构成了一条基本的语句
编译器在编译程序、解析程序时,不是根据物理行,而是根据分号 ; 来判断一条语句的结束标记的。如下面这种写法也算一种语句:

i =
2 +
3
;

代码块

不同的语句,使用大括号{}括起来,就构成了一个代码块。
C 语言允许在代码块里定义一个变量,这个变量的作用域也仅限于这个代码块内。如下表达式:

int main(void)
{
    
    
	int i = 3;
	printf("i=%d\n",i);
	{
    
    
		int i = 4;
		printf("i=%d\n",i);
	}
	printf("i=%d\n",i);
	return 0;
}
结果为:
i=3
i=4
i=3

3.2 语句表达式

定义

GNU CC 标准作了扩展,允许在一个表达式里内嵌语句,允许在表达式内部使用局部变量、for 循环和 goto 跳转语句。这样的表达式,我们称之为语句表达式。语句表达式的格式如下:

({
    
     表达式1; 表达式2; 表达式3; })

与一般表达式一样,语句表达式也有自己的值。**语句表达式的值为内嵌语句中最后一个表达式的值。**使用例子如下:

#include <stdio.h>

int main(void)
{
    
    
	int sum = 0;
	sum =
	({
    
    
		int s = 0;
		for( int i = 0; i < 10; i++)
		s = s + i;
		s;
	});
	printf("sum = %d\n",sum);
	return 0;
}

打印结果:

sum = 45

语句表达式内使用 goto 跳转

在语句表达式内,我们同样也可以使用 goto 进行跳转。例子如下:

#include <stdio.h>

int main(void)
{
    
    
	int sum = 0;
	sum =
	({
    
    
		int s = 0;
		for( int i = 0; i < 10; i++)
		s = s + i;
		goto here;
		s;
	});
	printf("sum = %d\n",sum);
here:
	printf("here:\n");
	printf("sum = %d\n",sum);
	return 0;
}

3.3 在宏定义中使用语句表达式

语句表达式的亮点在于定义复杂功能的宏。使用语句表达式来定义宏,不仅可以实现复杂的功能,而且还能避免宏定义带来的歧义和漏洞。如下面的例子

请定义一个宏,求两个数的最大值。
我们的定义方法可以如下:

#define MAX(x,y) x > y ? x : y
#define MAX(x,y) (x) > (y) ? (x) : (y)
#define MAX(x,y) ((x) > (y) ? (x) : (y))
#define MAX(x,y) ({
      
       \
	int _x = x; \
	int _y = y; \
	_x > _y ? _x : _y; \
})
#define MAX(type,x,y) ({
      
       \    //type是要比较变量的类型
	type _x = x; \
	type _y = y; \
	_x > _y ? _x : _y; \
})
#define max(x, y) ({
      
       \
	typeof(x) _x = (x); \    //typeof,获取x的类型
	typeof(y) _y = (y); \
	(void) (&_x == &_y);\    //作用:1、x,y两个的类型不相同时,给一个警告,2、消除比较结果没有用到的警告
	_x > _y ? _x : _y; 
})

从上到下,缺点不断完善。


四、typeof

4.1 typeof的介绍

GNU C 扩展了一个类似于关键字的 typeof,用来获取一个变量或表达式的类型。

通过使用 typeof,我们可以获取一个变量或表达式的类型。使用例子如下:

int i ;
typeof(i) j = 20;

typeof(int *) a;

int f();
typeof(f()) k;   //获取函数返回值的类型

4.2 typeof的其他用法

typeof (int *) y; // 把 y 定义为指向 int 类型的指针,相当于int *y;
typeof (int) *y; //定义一个执行 int 类型的指针变量 y
typeof (*x) y; //定义一个指针 x 所指向类型 的指针变量y
typeof (int) y[4]; //相当于定义一个:int y[4]
typeof (*x) y[4]; //把 y 定义为指针 x 指向的数据类型的数组
typeof (typeof (char *)[4]) y;//相当于定义字符指针数组:char *y[4];
typeof(int x[4]) y; //相当于定义:int y[4]

五、container_of 宏

5.1 介绍

container_of (ptr, type, member)宏定义在Linux内核中,作用是根据结构体某一成员的地址,获取这个结构体的首地址
具体定义如下:

#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
#define container_of(ptr, type, member) ({
      
       \
	const typeof( ((type *)0)->member ) *__mptr = (ptr); \
	(type *)( (char *)__mptr - offsetof(type,member) );})

我们慢慢来分析。

首先,宏offsetof获取结构体成员在结构体中的偏移量TYPE表示某一结构类型,MEMBER表示某一结构体成员,这里是将0转化为结构体类型的地址,在使用->取结构体成员,然后使用&取地址,得到结构体成员在结构体变量在储存地址为0时的偏移地址,这就是结构体成员在结构体中的偏移量,size_t表示C中任何对象所能达到的最大长度,本系统中是表示无符号整数,即unsigned int。

其次,分析container_of,ptr:结构体成员地址,type:结构体类型,member:结构成员,typeof( ((type *)0)->member )获取结构体成员类型成员,指针变量__mptr保存结构体成员地址,( (char *)__mptr - offsetof(type,member) );}) 结构体成员当前地址减去结构体成员在结构体中的偏移量,就得到了结构体变量当前地址,在使用(type *)将地址储存数据类型转化为结构体类型。


5.2 container_of的使用

使用例子如下:

struct student
{
    
    
	int age;
	int num;
	int math;
};
int main(void)
{
    
    
	struct student stu = {
    
     20, 1001, 99};
	int *p = &stu.math;
	struct student *stup = NULL;
	stup = container_of( p, struct student, math);
	printf("%p\n",stup);
	printf("age: %d\n",stup->age);
	printf("num: %d\n",stup->num);
	return 0;
}

六、零长数组

零长度数组就是长度为0的数组。
ANSI C 标准规定:定义一个数组时,数组的长度必须是一个常数,即数组的长度在编译的时候是确定的。在ANSI C 中定义一个数组的方法如下:

int a[15];

·C99 新标准规定·:可以定义一个变长数组。如下:

int len;
int a[len];

GNU C 又对其进行了扩展,支持零长度数组。如下:

int a[0];

零长度数组不占用内存存储空间。它常常作为结构体的一个成员,构成一个变长结构体。例子如下:

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 wanglitao!\n");
	puts(buf->a);
	free(buf);
	return 0;
}

七、GNU C 的扩展关键字:attribute

GNU C 增加一个 atttribute 关键字用来声明一个函数、变量或类型特殊属性。声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查。使用方式如下:

__atttribute__((ATTRIBUTE))

ATTRIBUTE 代表的就是要声明的属性。支持的属性有:

  • section
  • aligned
  • packed
  • format
  • weak
  • alias
  • noinline
  • always_inline

7.1 aligned 和 packed

告诉编译器按照我们指定的边界地址对齐去给这个变量分配存储空间

aligned 用来建议编译器按照指定大小地址对齐,使用方式如下:

int a __attribute__((aligned(8));

通过 aligned 属性,我们可以直接显式指定变量 a 在内存中的地址对齐方式。aligned 有一个参数,表示要按几字节对齐,使用时要注意,地址对齐的字节数必须是2的幂次方,否则编译就会出错。
另外,我们通过这个属性声明,其实只是建议编译器按照这种大小地址对齐,但不能超过编译器允许的最大值。

packed用来指定变量或类型使用最可能小的地址对齐方式。使用方式如下:

struct data{
    
    
	char a;
	short b __attribute__((packed));
	int c __attribute__((packed));
};
int main(void)
{
    
    
	struct data s;
	printf("size: %d\n",sizeof(s));
	printf("&s.a: %p\n",&s.a);
	printf("&s.b: %p\n",&s.b);
	printf("&s.c: %p\n",&s.c);
}

结果如下:
size: 7
&s.a: 0028FF30
&s.b: 0028FF31
&s.c: 0028FF33

在 Linux 内核中,我们经常看到 aligned 和 packed 一起使用,即对一个变量或类型同时使用 aligned 和 packed 属性声明。这样做的好处是,既避免了结构体内因地址对齐产生的内存空洞,又指定了整个结构体的对齐方式。使用方式如下:

struct data{
    
    
char a;
short b ;
int c ;
}__attribute__((packed,aligned(8)));
int main(void)
{
    
    
struct data s;
printf("size: %d\n",sizeof(s));
printf("&s.a: %p\n",&s.a);
printf("&s.b: %p\n",&s.b);
printf("&s.c: %p\n",&s.c);
}

运行结果:
size: 8
&s.a: 0028FF30
&s.b: 0028FF31
&s.c: 0028FF33

7.2 section

一个可执行目标文件,它主要由代码段(.text section)、数据段(.data section)、BSS段(.bss section)构成。其默认的规则如下。

section 组成
代码段( .text) 函数定义、程序语句
数据段( .data) 初始化的全局变量、初始化的静态局部变量
BSS段( .bss) 未初始化的全局变量、未初始化的静态局部变量

可执行文件中除了以上的section,还会包含其它一些 section,比如只读数据段、符号表等等。

**使用atttribute 来声明一个 section 属性,主要用途是在程序编译时,将一个函数或变量放到指定的段,即 section 中。**使用例子如下:

int global_val = 8;
int uninit_val __attribute__((section(".data")));  //将本应放入.bss段的变量放入.data段
int main(void)
{
    
    
	return 0;
}

7.3 format

GNU 通过 attribute 扩展的 format 属性,用来指定变参函数的参数格式检查。使用格式如下:

__attribute__((format(archetype, string-index, first-to-check)))
void LOG(const char *fmt, ...) __attribute__((format(printf,1,2)));

archetype:按照检查的格式,上面的例子的printf,指的是按照printf的参数框架检查。
string-index:字符串所在的参数位置。
first-to-check:变参第一个参数位置。
format(printf,1,2):按照printf的参数输入检查,字符串在第一个位置,变参是在第二个位置。

在举个例子方便理解:

void LOG(int num, char *fmt, ...) __attribute__((format(printf,2,3)));

按照printf的参数输入检查LOG这个函数,字符串是在第二个位置,变参是第三个位置。

变参函数,顾名思义,跟 printf 函数一样:参数的个数、类型都不固定。我们在函数体内因为预先不知道传进来的参数类型和个数,所以实现起来会稍微麻烦一点。首先要解析传进来的实参,保存起来,然后才能接着像普通函数一样,对实参进行处理。如下面我们实现的一个变参函数。

void print_num(int count, ...)
{
    
    
	int *args;
	args = &count + 1;
	for( int i = 0; i < count; i++)
	{
    
    
		printf("*args: %d\n", *args);
		args++;
	}
}

变参的地址是紧挨着第一个参数的后面,因此这里可以用一个指针保存并访问。

对于变参函数,编译器或计算机系统一般会提供一些宏给程序员使用,用来解析函数的参数。如下:

  • va_list:定义在编译器头文件中 typedef char* va_list; 。
  • va_start(args,fmt):根据参数 fmt 的地址,获取 fmt 后面参数的地址,并保存在 args 指针变量中。
  • va_end(args)释放 args 指针,将其赋值为 NULL。
  • vprintf (const char*, __VALIST),定义在stdio.h中,是打印字符串,第一参数给上字符串,第二个参数是参数列表的指针。

因此修改后的打印函数可以是这样:

#define DEBUG //打印开关
void __attribute__((format(printf,1,2))) LOG(char *fmt,...)
{
    
    
#ifdef DEBUG
	va_list args;
	va_start(args,fmt);
	vprintf(fmt,args);
	va_end(args);
#endif
}
int main(void)
{
    
    
	int num = 0;
	LOG("I am litao, I have %d car\n", num);
	return 0;
}

这个便是自定义的日志打印函数,当这个 DEBUG 宏没有定义,LOG 函数就是个空函数。通过这个宏,我们就实现了打印函数的开关功能,在实际调试中比较实用,非常方便。


7.4 weak

**GNU C 通过 attribute 声明weak属性,可以将一个强符号转换为弱符号。**例子如下:

void __attribute__((weak)) func(void);
int num __attribte__((weak);

在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已。符号可以分为强符号弱符号

  • 强符号:函数名、初始化的全局变量名;
  • 弱符号:未初始化的全局变量名;

在一个项目中,不能同时存在两个强符号,比如你在一个多文件的工程中定义两个同名的函数,或初始化的全局变量,那么链接器在链接时就会报重定义的错误。

当同名的符号都是弱符号,谁在内存中存储空间大,就选谁。

当函数被声明为一个弱符号时,会有一个奇特的地方:当链接器找不到这个函数的定义时,也不会报错。编译器会将这个函数名,即弱符号,设置为0或一个特殊的值。只有当程序运行时,调用到这个函数,跳转到0地址或一个特殊的地址才会报错。例如下面代码:

//func.c
int a __attribute__((weak)) = 1;
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    
    
	printf("main:a = %d\n", a);
	func();
	return 0;
}

编译运行结果:

$ gcc -o a.out main.c func.c
main: a = 4
Segmentation fault (core dumped)

为了防止函数运行出错,我们可以在运行这个函数之前,先做一个判断,即看这个函数名的地址是不是0,然后再决定是否调用、运行。
这样就可以避免段错误了,示例代码如下。

//func.c
int a __attribute__((weak)) = 1;
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    
    
	printf("main:a = %d\n", a);
	if (func)
		func();
	return 0;
}

7.5 alias

GNU C 扩展了一个 alias 属性,这个属性很简单,主要用来给函数定义一个别名。
使用例子如下:

void __f(void)
{
    
    
	printf("__f\n");
}
void f() __attribute__((alias("__f")));
int main(void)
{
    
    
	f();
	return 0;
}

在 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;
}

当我们在 main.c 中新定义了 f() 函数时,在 main 函数中调用 f() 函数,会直接调用 main.c 中新定义的函数;当 f() 函数没有新定义时,就会调用 __f() 函数。


猜你喜欢

转载自blog.csdn.net/qq_51447215/article/details/127747436