嵌入式C语言学习记录(二)——GNU拓展语法与C语言补充


version : v1.0 「2022.9.11」 最后补充

author: Y.Z.T.

摘要: 记录汇总自己在嵌入式开发过程中 学习的一些零散知识

简介: 简单汇总,方便自己查看


(👇 第一部分)
嵌入式C语言学习记录(一) —— ARM指令集与作用域
(👇 pdf 版)
pdf文件






4️⃣ GNU 拓展语法

  • ANSIC(C89)、(C99)、(C11)表示的是C语言标准
  • GNU拓展语法是GUN编译器对C语言标准的拓展

4.1 指定初始化

GNU C 支持指定初始化数组元素结构体成员

初始化数组元素示例:

1
2
int b[100] = {[10] = 1,[30] = 2};				// 给指定几个元素初始化
int b[100] = {[10 ... 30] = 1,[40 ... 60] = 2}; // 给某个索引范围的数组元素初始化

GNU C 支持使用 表示范围拓展 , 不仅可以使用在数组初始化中 , 也可以使用在switch-case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(void)
{
int i = 4;
switch(i)
{
case 1:
/* do something */
break;

case 2 ... 4:
/* do something */
break;

default:
break;
}
}

初始化结构体成员示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct
{
char name[20];
int age;
}test_t;

int main(void)
{
test_t test1 = {"name1",10}; // C语言标准初始化方式(即固定顺序直接初始化)

test_t test2 = // GNU C初始化方式(直接通过结构域名.name和.age,给结构体变量的某一个指定成员直接赋值)
{
.name = "name2",
.age =20;
};
}



4.2 语句表达式

  • 表达式: 表达式就是由一系列操作符和操作数构成的式子。

    1
    i = i++ + 3
  • 语句 : 在表达式后加一个 ; 就构成了一条基本语句 , ; 表示一条语句的结束

  • 语句表达式 :

    • GNU C对C语言标准作了扩展,允许在一个表达式里内嵌语句;
    • 允许在表达式内部使用局部变量、for循环和goto跳转语句;

    语句表达式格式:

    1
    ({表达式1 ; 表示2 ; 表达式3 ;})
    • 语句表达式 最外面使用小括号( ) 括起来,里面一对 大括号{ } 包起来的是代码块,
    • 代码块里允许内嵌各种语句。语句的格式可以是一般表达式,也可以是循环、跳转语句。
    • 语句表达式的值为内嵌语句中最后一个表达式的值

示例:

使用语句表达式求值

1
2
3
4
5
6
7
8
9
10
11
int main(void)
{
int sum = 0;
sum =
({
int s = 0;
for (int i = 0; i < 10; i++)
s = s + i;
s;
});
}
  • 在上面的程序中,通过语句表达式实现了从1到10的累加求和
  • 因为语句表达式的值等于最后一个表达式的值,所以在for循环的后面,我们要添加一个s;
  • s语句表示整个语句表达式的值 , 如果不加这一句,结果是sum=0
  • 如果将这一行语句改为100,最后sum的值就变成了100,这是因为语句表达式的值总等于最后一个表达式的值.


4.2.1 使用实例

  • 语句表达式的主要用途在于定义功能复杂的宏。
  • 使用语句表达式来定义宏,不仅可以实现复杂的功能,还能避免宏定义带来的歧义和漏洞。

示例:

定义一个宏 , 求两个数的最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* ANSIC */
#define MAX(x,y) ((x) > (y) ? (x) : (y)) // 经典宏陷阱 , 在 MAX(i++ , j++); 时会出现累加两次问题

/* GNU C */
#define MAX(x, y) ({ \ // 在语句表达式中定义两个临时变量,分别来暂时存储i和j的值,然后使用临时变量进行比较
int _x = x; \ // 这样就避免了两次自增、自减问题。
int _y = y; \
_x > _y ? _x : _y; \
})

/* GNU C 优化*/
#define max(x,y) ({ \
typeof(x) _x = (x); \ // typeof 关键字是GNUC 拓展的,用于获取一个变量或表达式的类型
typeof(x) _y = (y); \ // 使用typeof关键字来自动获取宏的 两个参数类型
(void)(&_x == &_y); \
_x > _y ? _x : _y; \
})

备注: 为什么存在 (void) (&_x == &_y); 这个看似很多余的语句

它的作用有两个 :

  • 一是用来给用户提示一个警告,对于不同类型的指针比较,编译器会发出一个警告,提示两种数据的类型不同。
  • 二是两个数进行比较运算 , 运算的结果却没有用到,有些编译器可能会给出一个warning,加一个(void)后,就可以消除这个警告。


4.3 typeof关键字

  • typeofGNU C扩展的一个关键字 , 用来获取一个变量或表达式的类型。
  • 使用typeof可以获取一个变量或表达式的类型。typeof的参数有两种形式 : 表达式类型

示例:

1
2
3
4
5
int i;
typeof(i) j = 20; // typeof(i) 就等于int, typeof(i)j=20 就相当于int j=20,
typeof(int *) a; // typeof(int*)a;相当于int*a
int f(); // f()函数的返回值类型是int,
typeof(f()) k; // typeof(f())k ; 就相当于int k;

其他用法:

1
2
3
4
5
6
7
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]


4.4 container_of宏

作用 : 根据结构体某一成员的地址,获取这个结构体的首地址

实现原理 :

  • 结构体中各个成员的地址在数值上等于结构体各成员相对于结构体首地址的偏移
  • 直接用结构体成员的地址 , 减去该成员在结构体内的偏移,就可以得到该结构体的首地址了。

宏定义原型:

image-20220912130718233


1
2
3
4
5
#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) ); })

可以看到这个宏有三个参数:

  • type为结构体类型;
  • member为结构体内的成员;
  • ptr为结构体内成员member的地址。



4.4.1 结构体的储存空间

编译器在给一个结构体变量分配储存空间时,有以下特点:

  • 根据每个成员的数据类型字节对齐方式,编译器会按照结构体中各个成员的顺序,在内存中分配一片连续的空间来存储它们
  • 结构体中的每个成员变量,从结构体首地址开始依次存放,每个成员变量相对于结构体首地址,都有一个固定偏移
  • 同一个编译环境下 , 各个成员相对于结构体首地址的偏移是固定不变的 . (当然 使用#pragma pack(x)typedef __packed struct 改变字节对齐方式 ,同时也会改变地址编译 )

示例:

1
2
3
4
5
6
 struct student
{
struct list_head list;//暂且将链表放在结构体的第一位
int ID;
int math;
};

struct student地址偏移

image-20220912134256201

示例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct A 				//  A 对齐为 4 ,大小为 16
{
int a; // a 偏移为 0
char b; // b 偏移为 4
int c; // c 偏移为 8(大于 4 + 1 的 4 的最小整数倍)
char d; // d 偏移为 12
};

struct B
{
char a1; // a1偏移为 0
int a2; // a2移为 4
char a3; // a3移为 8
short a4; // a4移为 10
};

struct B地址偏移

在这里插入图片描述

可以看到 :

  • 结构体中成员变量在内存中存储的其实是偏移地址
  • 也就是说结构体A的地址+成员变量的偏移地址 = 结构体成员变量的起始地址
  • 因此,可以根据结构体变量的起始地址和成员变量的偏移地址来反推出结构体A的地址。


4.4.2 container_of宏实现

4.4.2.1 (((type *)0)->member) 解析

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct student{
int age;
int num;
int math;
};

int main(void)
{
printf("&age=%p\n", &((struct student*)0)->age);
printf("&num = %p\n", &((struct student*)0)->num);
printf("&math= %p\n", &((struct student*)0)->math);
return 0;
}

运行结果:

1
2
3
&age = 00000000
&math= 00000004
&num = 00000008

如上所示:

  • ((TYPE *)0)将0转换为type类型的结构体指针 (即转换为一个指向结构体类型为student的常量指针)
  • 因为常量指针的值为0,即可以看作结构体首地址为0
  • 所以结构体中每个成员变量的地址即该成员相对于结构体首地址的偏移
  • (((type *)0)->member) 引用结构体中MEMBER成员。


4.4.2.2 const typeof( ((type *)0)->member ) *_mptr = (ptr);

这句代码意思是:

  • typeof()获取结构体里member成员属性的类型,

  • typeof( ((type *)0)->member ) 表达式使用typeof关键字 ,来获取结构体成员 MEMBER的数据类型

  • 然后定义一个该类型的临时指针变量__mptr, 用来储存宏中参数ptr的值

  • 并将ptr所指向的member地址赋给__mptr

  • 因为结构体成员的数据类型可以是任何数据类型 , 为了让这个宏兼容各种数据类型 , 所以定义了一个临时指针变量__mptr



4.4.2.3 offsetof(TYPE, MEMBER)

作用 : 这个宏的作用就是求出 MEMBER相对于0地址的一个偏移值。

原理 : 将0强制转换为一个指向TYPE类型的结构体常量指针 , 然后通过这个常量指针访问成员,获取成员MEMBER的地址, 其大小在数值上等于MEMBER成员在结构体TYPE中的偏移


可以看到这个宏有两个参数:

  • TYPE为结构体类型;
  • MEMBER为结构体TYPE内的成员;

注意:

size_t是标准C库中定义的,在32位架构中被普遍定义为:

1
typedef unsigned int size_t;

而在64位架构中被定义为:

1
typedef unsigned long size_t;

为了使程序有很好的移植性,因此内核使用size_t和,而不是intunsigned



4.4…2.4 (type *)((char *)_mptr - offsetof(type, member) );

这句代码的意思是:

  • 取结构体某个成员member的地址,减去这个成员在结构体type中的偏移,得到结构体type的首地址。

注意:

  • __mptr 转换成 char * 类型, 因为 offsetof 得到的偏移量是以字节为单位。
  • 在语句表达式的最后 因为返回的是结构体的首地址 , 所以整个地址还必须强制转换一下,转换为TYPE *
  • 即返回一个指向 TYPE结构体类型指针,所以最后一个表达式中会出现一个强制类型转换(TYPE *)


4.4.5 使用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typedef struct 
{
int id;
char name[30];
int math;
}Student;

int main()
{
Student stu; // 定义结构体类型变量
Student *sptr = NULL;
stu.id = 123456;
strcpy(stu.name,"name1");
stu.math = 90;

sptr = container_of(&stu.id,Student,id);
/* 宏展开为 */
// sptr = ({ const unsigned char *__mptr = (&stu.id);
// (Student *)( (char *)__mptr - ((size_t) &((Student *)0)->id) );});
printf("sptr=%p\n",sptr);

sptr = container_of(&stu.name,Student,name);
printf("sptr=%p\n",sptr);
sptr = container_of(&stu.math,Student,id);
printf("sptr=%p\n",sptr);
return 0;
}


4.6 零长度数组

零长度数组定义:

1
int a[0];

它的特点是 不占用内存存储空间


示例:

1
2
3
4
struct buffer{
int len;
int a[0];
};
  • int a[0] 仅仅意味着程序中通过buffer结构体实例的a[index]成员可以访问len之后的第index个地址
  • 它并 没有为a[]数组分配内存,因此sizeof(struct buffer)=sizeof(int)

用途: 零长度数组一般很少单独使用,它常常作为结构体的一个成员,构成一个变长结构体

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct buffer{
int len; // len 标志着下面数据的长度
int a[0]; // 数组名data是一个指针常量,标识着成员变量len后面的那个地址,但其本身并不占空间。
};

int main(void)
{
struct buffer *buf;
int this_length = 20;

buf = (struct buffer *)malloc(sizeof(struct buffer)+ this_length);
buf->len = this_length;
memset(buf->a, 'a', this_length);

puts(buf->a);

free(buf);
return 0;
}
  • sizeof(buffer) + 20,即24个字节大小。其中4个字节用来存储结构体指针 buf 指向的结构体类型变量,另外20个字节空间,才是我们真正使用的内存空间。
  • 使用零长度数组 ,使得这个结构体的长度可以随着程序动态的变化

注意: 零长度数组要放在结构体的最后



4.7 __attribute__ 关键字


说明 :

  • __attribute__GNU C 新增的一个关键字 , 可以用来设置函数属性变量属性类型属性
  • 函数属性可以把一些特性添加到函数声明中,从而可以使编译器在错误检查方面的功能更强大。

语法格式 : __atttribute__((ATTRIBUTE))


注意:

  • __attribute__前后都有两个下划线

  • __attribute__后面是两对小括号 , 不能只写一对

  • 括号里面的 ATTRIBUTE表示要声明的属性

    • 可以同时添加多个属性说明

    • 可以选择若干个单独的__attribute__,或者把它们写在一起

      1
      2
      3
      4
      5
      6
      7
      8
      /* 写在一起 */
      char c2 __attribute__((packed,aligned(4))); // 各个属性之间用逗号隔开
      __attribute__((packed,aligned(4))) char c2 = 4;

      /* 单独 */
      extern void die(const char *format, ...)
      __attribute__((noreturn))
      __attribute__((format(printf, 1, 2)));
  • 如上所示 , 属性声明要紧挨着变量 ,以上C2变量两种定义方式都是可行的



4.7.1 变量属性说明’

举例说明其中几种属性


变量属性:

  • cleanup
  • aligned
  • packed
  • common
  • nocommon
  • deprecated
  • mode
  • section
  • shared
  • tls_model
  • transparent_union
  • unused
  • vector_size
  • weak
  • dllimport
  • dlexport


(官方文档 👇)

(变量属性说明 - 官方文档)


4.7.1.1 属性: section

作用: 在程序编译时 , 将一个函数或变量放到 目标文件( .o文件) 的指定段中 , 即放到指定的section

格式:


说明:

一段源程序代码在编译生成可执行文件的过程中,函数变量是放在不同段中的 , 如图所示:

image-20220913221409508

  • 一个可执行文件主要由代码段数据段BSS段构成。
  • 代码段主要存放编译生成的可执行指令代码;
  • 数据段BSS段用来存放全局变量、未初始化的全局变量
  • 代码段、数据段和BSS段构成了一个可执行文件的主要部分。
  • 此外还有其他的段 ,如只读数据段符号表 、还包含其他一些section


说明:

image-20220913222112669

从C程序到可执行文件



示例:

1
2
3
4
5
6
int global_val = 8;
int uninit_val __attribute__((section(".data")));
int main(void)
{
return 0;
}
  • 通过 __atttribute__section 属性,显式指定一个函数或变量,在编译时放到指定的 section 里面
  • 未初始化的全局变量是放在 .bss section 中的,即放在 BSS 段
  • 现在通过 section 属性,把这个未初始化的全局变量放到数据段 .data 中。


4.7.1.2 属性: aligned

作用 : 指定一个变量或类型的对齐方式(使被设置的对象占用更多的空间

注意 : 对齐的字节数必须是2的幂次方


示例: 定义一个变量在内存中以指定字节数对齐

1
int x __attribute__ ((aligned (16))) = 0;    // 以16字节地址对齐

结构体对齐:

由前文 结构体储存方式 可知道 :

  • 结构体作为一种复合数据类型,编译器在给一个结构体变量分配存储空间时,会进行地址对齐;
  • 结构体整体对齐要按结构体所有成员中最大成员字节数的整数倍进行对齐.
  • 结构体成员按不同的顺序排放,可能会导致结构体整体长度不一致


示例:

1
2
3
4
5
6
7
8
9
10
11
 struct data{		// 结构体size为12 ((1 + (3)) + 4 + 2 = 10 ,补齐后为12)
char a; // 偏移为0; 占用1字节; 填充3字节
int b ; // 偏移为4; 占用4字节; 填充0字节
short c ; // 偏移为8; 占用2字节; 填充2字节
}

struct data{ // 结构体size为8 ((1+(1)) + 2 + 4 = 8 )
char a; // 偏移为0; 占用1字节; 填充1字节
short b ; // 偏移为2; 占用2字节; 填充0字节
int c ; // 偏移为4; 占用4字节; 填充4字节
};

__attribute__(aligned())属性

不仅可以显性指定结构体某个成员的地址对齐 ; 也可以显式指定整个结构体的对齐方式

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 让 short 型的变量 b 按4字节对齐 */
struct data{ // 结构体size为12 ((1 + (3)) + 4 + 4 = 12)
char a; // 偏移为0; 占用1字节; 填充3字节
short b __attribute__((aligned(4))); // 偏移为4; 占用4字节; 填充0字节
int c ; // 偏移为8; 占用4字节; 填充0字节
};

/* 显式指定结构体整体以16字节对齐 */
struct data{ // 结构体size为16 ((1+(1)) + 2 + 4 = 8 ; 末尾填充8个字节后长度为16)
char a; // 偏移为0; 占用1字节; 填充1字节
short b; // 偏移为2; 占用2字节; 填充0字节
int c ; // 偏移为4; 占用4字节; 填充4字节
}__attribute__((aligned(16)));

特别注意:

  • 修改字节对齐方式 ,最大不能超过编译器允许的最大值;

  • 如果连接器最大只支持16字节对齐,那么此时定义32字节对齐也是按16字节对齐



4.7.1.3 属性:packed

作用 : 指定一个变量或类型尽量使用最小的地址对齐方式来分配地址(一般用来设置减少地址对齐

注意 : 对齐的字节数必须是2的幂次方

用途 : 如用于封装结构体 , 为防止因内存空洞 导致与实际连续的寄存器地址不符合 ,则可以使用该属性.


示例:

1
2
3
4
5
struct data{							// 结构体size为7 ((1+ 2 + 4 = 7)
char a; // 偏移为0; 占用1字节; 填充0字节
short b __attribute__((packed)); // 偏移为1; 占用2字节; 填充0字节
int c __attribute__((packed)); // 偏移为3; 占用4字节; 填充0字节
};

实际应用:

  • 在实际应用中 , 经常可以看到alignedpacked 一起使用,即对一个变量或类型同时使用 alignedpacked 属性声明。
  • 这样做的好处是,既避免了结构体内因地址对齐产生的内存空洞,又指定了整个结构体的对齐方式

1
2
3
4
5
struct data{							// 结构体size为8 ((1+ 2 + 4 = 7 ; 末尾填充1个字节后 ,长度变为8)
char a; // 偏移为0; 占用1字节; 填充0字节
short b ; // 偏移为1; 占用2字节; 填充0字节
int c ; // 偏移为3; 占用4字节; 填充1字节
}__attribute__((packed,aligned(8)));

如上所示 :

  • 结构体 data 虽然使用 packed 属性声明,整个长度变为7
  • 同时又使用了 aligned(8) 指定其按8字节地址对齐,所以编译器要在结构体后面填充1个字节
  • 这样整个结构体的大小就变为8字节,按8字节地址对齐。


4.7.1.4 属性: deprecated

作用 : 弃用,如果在源文件在任何地方地方使用__attribute__((deprecated)) 函数,编译器将会发出警告.

注意 :deprecated属性也可用于函数类型


示例:

1
2
3
4
5
6
7
8
9
10
11
__attribute__((deprecated))  void test(int a)
{
return;
}

int main()
{
test(1); // 输出警告: void test(int a) is deprecated

return 0;
}


注意:

警告仅在使用时出现,并且仅在类型应用于本身未被声明为已弃用标识符、变量或函数时才会出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 变量 */
extern int old_var __attribute__ ((deprecated));
extern int old_var; // 这里不出现警告
int new_fn () {
return old_var; // 警告仅在这里出现
}

/* 函数 */
int old_fn () __attribute__ ((deprecated));
int old_fn (); // 这里不出现警告
int (*fn_ptr)() = old_fn; // 出现警告

/* 类型 */
typedef int T1 __attribute__ ((deprecated));
T1 x; // 出现警告
typedef T1 T2; // 出现警告
T2 y; // 不出现警告 ,因为未明确弃用 T2。
typedef T1 T3 __attribute__ ((deprecated)); // 不出现警告,因为T3 已被明确弃用
T3 z __attribute__ ((deprecated)); // 不出现警告, 因为T3 已被明确弃用


4.7.2 函数属性说明

举例说明其中几种属性


函数属性:

alias const dllimport
always_inline destructor eightbit_data
cdecl deprecated far
fastcall format format_arg
constructor dllexport function_vector
interrupt long_call/short_call malloc
model naked near
no_instrument_function noinline nonnull
noreturn nothrow pure
regparm saveall section
sentinel signal tiny_data
unused used weak

(官方文档 👇)

(函数属性说明 - 官方文档)



4.7.2.1 属性 : format

作用 :

  • 指定变参函数的参数格式检查 (用于解决在使用可变参数的函数调用时 , 编译器检查不出可变参数的类型或者个数是否正确的问题)
  • 作用是提示编译器检查函数调用的过程中,可变参数部分按照printf或其他的规则进行检查;
  • 若参数的个数或者类型不匹配,编译过程中将会发出警告

用途 : 比如实现对自定义的打印函数 传入的可变参数的检查

格式: format (archetype, string-index, first-to-check)

  • archetype参数 : printf, scanf,strftime , strfmon
  • string-index参数 : 指定哪个参数是格式字符串参数(从 1 开始)
  • first-to-check参数 : 是要检查格式字符串的第一个参数的编号*(即从函数的第几个参数开始按上述规则进行检查)。对于无法检查参数的函数,将第三个参数指定为零。(在这种情况下,编译器只检查格式字符串的一致性)*
  • C++有隐式的this函数 , string-index, first-to-check应该向后一位开始

示例: 以自定义的打印函数为例

1
2
3
4
5
6
7
8
9
10
11
/* 如下所示:
printf参数表示: 告诉编译器按照 printf 函数的检查标准来检查
第二个参数1表示: 格式化字符串在LOG函数的第一个传入参数
第二个参数2表示: LOG函数中要替代"%"占位符的参数排在第二位 ; (即“…”里的第一个参数在LOG函数参数总数排在第2)*/
void LOG(const char *fmt, ...) __attribute__((format(printf,1,2)));


/* 如下所示:
LOG函数多了个新的传入参数num , 则
format函数的 string-index和first-to-check参数应向后移动一位*/
void LOG(int num, char *fmt, ...) __attribute__((format(printf,2,3)));


4.7.2.1.1 拓展 ——变参函数的实现

简单介绍怎么设计一个 变参函数 , 打印传入的实参


基本思路:

  • 变参函数的参数存储由一个连续的参数列表组成,列表里存放的是每个参数的地址。
  • 有一个固定参数count , 该参数后面连续储存着后面一系列参数的地址
  • 通过获取count的地址 , 再通过&count + 1就可以依次访问下一个地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void my_print(int count, ...)
{
char *args; // 用于保存下一个参数地址(用char *类型指针实现,以兼容更多数据类型) )
args = &count + 4; // 获取第一个可变参数的地址(涉及指针运算,注意每个地址大小都是4字节)
for( int i = 0; i < count; i++)
{
printf("*args: %d\n", *args);
args+=4;
}
}

int main(void)
{
my_print(5,1,2,3,4,5); // 依次打印出1,2,3,4,5
return 0;
}


4.7.2.1.2 变参函数的优化

可通过调用变参函数宏 来获取参数列表 , 解决解析参数的问题


说明:

  • va_list
  • 通过一个类型为 va_list 的对象,包含了参数信息 , 称为参数指针
  • 它包含了栈中至少一个参数的位置 , 可以使用这个参数指针从一个可选参数移动到下一个可选参数
  • va_list 类型被定义在头文件 stdarg.h 中。
1
va_list argptr;  // 用 va_list 类型定义参数指针,以获取可选参数。

  • va_start
  • va_start 使用第一个可选参数的位置来初始化 参数指针
  • 该宏的第二个参数必须是该函数最后一个有名称参数的名称。
  • 必须先调用该宏,才可以开始使用可选参数。
1
void va_start(va_list argptr, lastparam); 

  • va_arg
  • 展开宏 va_arg 会得到当前 argptr 所引用的可选参数
  • 同时将 参数指针 移动到列表中的下一个参数。
  • va_arg 的第二个参数是刚刚被读入的参数的类型。
1
type va_arg(va_list argptr, type);

  • va_end
  • 当不再需要使用参数指针时,必须调用宏 va_end
  • 如果想使用宏 va_start 或者宏 va_copy 来重新初始化一个之前用过的参数指针,也必须先调用宏 va_end
1
void va_end(va_list argptr);

  • va_copy
  • va_copy 使用当前的 src 值来初始化参数指针 dest。
  • 就可以使用 dest 中的备份获取从 src 所引用的位置开始的参数列表
1
void va_copy(va_list dest, va_list src);

优化后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdarg.h>						// 可变参数宏定义在stdarg.h中

void my_print(int count,...)
{
va_list args; // 定义一个参数指针
va_start(args,count); // 初始化参数指针
for(int i = 0; i < count; i++)
{
int val = va_arg(args,int); // 取得int类型的可变参数值
printf("*args: %d\n", val); // 打印取出的值
}
va_end(args);
}

int main(void)
{
my_print(5,1,2,3,4,5);
return 0;
}


4.7.2.1.3 简单日志打印函数的实现

通过vsnprintf函数 , 解决打印功能实现的问题


printf系列函数说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>
int printf(const char *format, ...); //输出到标准输出
int fprintf(FILE *stream, const char *format, ...); //输出到文件
int sprintf(char *str, const char *format, ...); //输出到字符串str中
int snprintf(char *str, size_t size, const char *format, ...); //按size大小输出到字符串str中

//以下函数功能与上面的一一对应相同,只是在函数调用时,把上面的...对应的一个个变量用va_list调用所替代。在函数调用前ap要通过va_start()宏来动态获取。
#include <stdarg.h>
int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_list ap);



/**
* @brief vsnprintf函数说明
* @param[out] str : 把生成的格式化的字符串存放在这里
* @param[in] size :接受的最大字符数(无符号数) (非字节数,UNICODE一个字符两个字节),防止产生数组越界.
* @param[in] format :指定输出格式的字符串,它决定了需要提供的可变参数的类型、个数和顺序。
* @param[in] ap :va_list变量.
* @retval 执行成功,返回最终生成字符串的长度,
* 若生成字符串的长度大于size,则将字符串的前size个字符复制到str,同时将原串的长度返回(不包含终止符);
* 执行失败,返回负值,并置errno
* @attention none
*/
int vsnprintf(char *str, size_t size, const char *format, va_list ap);

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void log_printf(char *fmt, ...)
{
char log_str[LOG_OUTPUT_MAX_LEN]; // 输出缓冲区(把生成的格式化的字符串存放在这里)
int len = 0; // 用于记录当前已使用的长度
va_list arg; // 定义一个可变参数指针
va_start(arg, fmt); // 初始化参数指针

/* len累加用于记录已使用的缓冲区长度 ;
(LOG_OUTPUT_MAX_LEN - len)表示缓冲区剩余长度*/
len += vsnprintf(log_str, LOG_OUTPUT_MAX_LEN - len, fmt, arg);

/* &log_str[len]等效于( log_str + len ) 表示地址偏移,将目标缓冲区指向字符串尾部 */
len += snprintf(&log_str[len],LOG_OUTPUT_MAX_LEN - len, "[len = %d]",len) //&log_str[len]等同于log_str + len

va_end(arg);

/* 将格式化字符串输出,此处是用串口输出 */
__log_output((uint8_t *)log_str, len); // 类似printf("%s",log_str);
}

int main(void)
{
log_printf("test \r\n" );
}


运行结果:

image-20220917215405446



4.7.2.1.4 日志打印函数优化

优化方向:

  • 添加打印等级 , 根据设置的打印等级,模块打印的 log 信息也会不一样
  • 添加format属性声明 , 用于让编译器在编译的时候 , 检查log_printf函数的参数格式

代码实现一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/*********************************** log.c ************************************/

#define LOG_OUTPUT_LEVEL 4 // 输出等级(最大为4)
#define LOG_TIMESTAMP_EN 0 // 时间信息输出使能
#define LOG_FUNCTION_EN 0 // 函数信息输出使能
#define LOG_FILE_LINE_EN 0 // 文件信息输出使能

/* 打印等级 */
#define LOG_ASSERT 1
#define LOG_ERROR 2
#define LOG_WARINING 3
#define LOG_INFO 4

/* 打印等级输出信息表 */
char *LOG_LEVEL_TAGS[6] = {"NULL", "ASSERT", "ERROR", "WARNING", "INFO", "DEBUG"};

/**
* @brief 将打印信息缓存到缓冲区
* @param[out] buff : 把生成的格式化的字符串存放在这里
* @param[in] size :接受的最大字符数(传入vsnprintf)
* @param[in] fmt : 格式化字符串
* @retval 执行成功,返回最终生成字符串的长度,
*
* @attention none
*/
int log_printf_to_buffer(char *buff, int size, char *fmt, ...)
{
int len = 0; // 用于记录当前已使用的长度
va_list arg;
va_start(arg, fmt);

len += vsnprintf(buff, size, fmt, arg);
va_end(arg);
return len;
}

/*********************************** log.h ************************************/

/* 因为需要打印实时信息,如运行时间,当前函数和调用位置等 ; 所以使用函数宏的方式 */
#define __log_with_level(level, ...) \
do \
{ \
char log_str[LOG_OUTPUT_MAX_LEN]; \
int len = 0; \
if ((level <= LOG_OUTPUT_LEVEL) && (level <= 5)) \
{ \
len += snprintf(&log_str[len], LOG_OUTPUT_MAX_LEN - len, "\r\n"); \
if (LOG_TIMESTAMP_EN) \
{ \
len += snprintf(&log_str[len], LOG_OUTPUT_MAX_LEN - len, "[%s]",__TIME__ ); \
} \
len += snprintf(&log_str[len], LOG_OUTPUT_MAX_LEN - len, "[%s][%s]", "LOG", LOG_LEVEL_TAGS[level]); \
if (LOG_FUNCTION_EN) \
{ \
len += snprintf(&log_str[len], LOG_OUTPUT_MAX_LEN - len, "[%s]", __FUNCTION__); \
} \
if ((LOG_FILE_LINE_EN) && (level <= LOG_ERROR)) \
{ \
len += snprintf(&log_str[len], LOG_OUTPUT_MAX_LEN - len, "(%s,%d)", __FILE__, __LINE__); \
} \
len += log_printf_to_buffer(&log_str[len], LOG_OUTPUT_MAX_LEN - len, __VA_ARGS__); \
printf("%s",log_str); \
\
} \
} while (0)

/* 各输出等级打印函数 */
#define _log_assert(...) __log_with_level(LOG_ASSERT, __VA_ARGS__)
#define _log_error(...) __log_with_level(LOG_ERROR, __VA_ARGS__)
#define _log_waring(...) __log_with_level(LOG_WARINING, __VA_ARGS__)
#define _log_info(...) __log_with_level(LOG_INFO, __VA_ARGS__)

注意: 为什么要使用do { … } while(0)

  • 为了防止宏在条件、选择等分支中展开后 ,产生歧义
  • 使用do { … } while(0)这种结构 , 在宏展开后,是一个代码块,避免发生歧义


缺陷:

  • 因为 __attribute__ 关键字不能作用于宏 , 所以没办法让编译器进行可变参数的检查
  • 也无法使用内联函数 , 因为使用了变参数列表 , 所以内联函数无法展开

代码实现二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#define LOG_OUTPUT_LEVEL 3			// 输出等级(最大为4)
/*
0: 关闭打印
1: 只打印错误信息
2: 打印警告和错误信息
3: 打印所有信息
*/

/* 打印等级 */
#define LOG_ERROR 1
#define LOG_WARINING 2
#define LOG_INFO 3


/* 打印函数 */
void __attribute__((format(printf,1,2))) Log_warn(char *fmt, ...)
{
/* 若打印等级不符合,则只输出空函数 */
#if (LOG_OUTPUT_LEVEL >= LOG_WARINING)
char log_str[LOG_OUTPUT_MAX_LEN];
int len = 0;
va_list arg;
va_start(arg, fmt);

len += vsnprintf(log_str, LOG_OUTPUT_MAX_LEN - len, fmt, arg);

va_end(arg);
printf("%s",log_str);
#endif
}

void __attribute__((format(printf,1,2))) Log_info(char *fmt, ...)
{
//...
}

void __attribute__((format(printf,1,2))) Log_error(char *fmt, ...)
{
//...
}



4.7.2.2 属性: weak

作用 : 将一个强符号转换为弱符号 , 可用于函数和变量

用途 : 主要用于定义可以在用户代码中覆盖的库函数

格式:

1
2
void  __attribute__((weak))  func(void);			// 函数声明
int num __attribte__((weak); // 变量声明

说明:

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

不同场景下的同名符号:

  • (强符号 + 强符号): 在一个工程中同时定义两个同名的函数全局变量 , 在链接器链接目标文件的时候会报重定义错误
  • (强符号 + 弱符号) : 在一个工程中同时定义两个同名的初始化全局变量未初始化全局变量 , 编译器一般会选用强符号丢掉弱符号
  • (弱符号 + 弱符号) : 谁的体积大,即谁在内存中存储空间大,使用谁。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//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) // 在调用弱符号函数时 , 先判断函数地址是否为0
func();
return 0;
}

输出结果:

1
main: a = 4

如上所示 :

  • 链接器在链接时会选择 main.c 中的这个强符号(即a = 4)
  • 当函数被声明为一个弱符号时 , 如果链接器找不到定义时 , 不会报错 ,而是将这个弱符号设置为0
  • 只有当程序运行时 , 调用到这个函数时 , 才会产生内存错误


4.7.2.3 属性 : alias

作用 : 用于给函数定义一个别名

用途 : 主要与weak属性一起使用 , 通过alias属性对旧的函数接口做封装 , 起一个新接口的名字

格式 :

1
2
/* __f()函数定义一个别名f() , 可以通过调用f()函数来直接调用_f() */
void f() __attribute__((alias("__f")));

注意:

  • alias 有时会和 weak 属性一起使用
  • 如果mian.c新定义了f()函数 , 则调用的新定义的f()函数
  • f() 没有定义时 , 就会调用_f()函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//f.c
void __f(void)
{
printf("__f()\n");
}
/* 给_f()函数声明一个别名f(), 并声明为弱符号函数 */
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;
}



4.7.2.4 属性: noinline & always_inline

作用:

  • noinline: 通过声明noinline使指定的内联函数不展开
  • always_inline: 通过声明always_inline使指定的内联函数展开

用途:

  • 因为通过inline关键字修饰的函数 , 并不一定在编译时会展开
  • 如函数体太大、存在循环、存在指针、函数调用频繁等情况 ,编译器一般不会做内联展开
  • 通过使用noinlinealways_inline显式的指定编译器是否做内联展开

格式:

1
2
static  inline __attribute__((noinline)) int func();
static inline __attribute__((always_inline)) int func();

4.7.3 类型属性说明


类型属性 :

  • aligned
  • packed
  • transparent_union
  • unused
  • deprecated
  • may_alias

(官方文档 👇)

(类型属性说明 - 官方文档)



4.8 内建函数

内建函数 :

  • GNU C提供了大量内建函数 , 内建函数是在编译器内部实现的函数 , 可以如关键字一样直接调用
  • 通常以__builtin开头
  • 主要是在编译器内部使用 , 为编译器服务的


用途:

  • 用来处理变长参数列表;
  • 用来处理程序运行异常、编译优化、性能优化;
  • 查看函数运行中的底层信息、堆栈信息等;
  • C 标准库函数的内建版本。


说明 :

  • 在开发中 , 一般不会使用内建函数
  • 因为有些函数 , 有助于我们对程序运行的底层机制、编译优化等的理解
  • 而且在Linux内核中也经常会使用 , 所以还是应该去了解


简单介绍几种常见的内建函数

4.8.1 __builtin_*return_*address(LEVEL)

作用 :

  • 返回当前函数或其调用者的返回地址

  • 参数LEVEL指定调用栈的级数;

    • 0 : 表示当前函数的返回地址;
    • 1 :表示当前函数的调用者的返回地址;
    • 2: 获取上二级函数的返回地址
1
2
3
4
5
6
7
8
int main(void)
{
int *p;
p = __builtin_return_address(0);
printf("main return address: %p\n",p); // 获取当前函数的返回地址

return 0;
}


说明:

  • C 语言函数在调用过程中,会将当前函数的返回地址寄存器等现场信息保存在堆栈中,然后才会跳到被调用函数中去执行。
  • 当被调用函数执行结束后,根据保存在堆栈中的返回地址,就可以直接返回到原来的函数中继续执行。


4.8.2 __builtin_constant_p(EXP)

作用 : 用于判断参数EXP在编译时 是否为常量

说明 : 如果EXP是常量返回 1 ; 否则返回 0

用途 ; 主要用于宏定义中 , 根据宏的参数是常量还是变量 , 实现的方法也不同

1
2
3
4
5
6
7
8
9
/* 根据参数是否为常数 , 实现不同的版本 */
#define _dma_cache_sync(addr, sz, dir) \
do { \
if (__builtin_constant_p(dir)) \
__inline_dma_cache_sync(addr, sz, dir); \
else \
__arc_dma_cache_sync(addr, sz, dir); \
} \
while (0);


4.8.3 __builtin_expect(exp,c)

作用 : 用于为编译器提供分支预测信息 ,

说明 : 无论参数C的值是什么 , 该内建函数的返回值都是exp , c必须是编译时的常数

用途 : Linux内核编程时常用的likely()unlikely()就是通过__builtin_expect(exp,c)实现的

1
2
3
4
/* 将参数x转换为布尔类型 , 然后与1和0直接做比较 , 告诉编译器x为真或假的可能性很高 */

#define likely_notrace(x) __builtin_expect(!!(x), 1) // 告诉编译器x发生的概率很高
#define unlikely_notrace(x) __builtin_expect(!!(x), 0) // 告诉编译器x发生的概率很低


注意:

为什么要进行两次取非操作

  • 为了把传入参数转换为布尔类型
  • 如果参数x是0, 两次取非后还是0
  • 如果参数是非0 , 两次取非后会变成1


4.9 可变参数宏

说明 :

在标准C语言中支持可变参数函数( 具体见变参函数的实现 ) ;

而在GNU C中,宏也可以接受可变数目的参数


注意:变参宏的实现形式其实跟变参函数差不多 :

  • ... 表示变参列表,变参列表由不确定的参数组成,各个参数之间用逗号隔开。
  • 可变参数宏使用 __VA_ARGS__ 预定义标识符来表示前面的变参列表
  • 预处理器在将宏展开时,会用变参列表替换掉宏定义中的所有 __VA_ARGS__ 标识符。
  • 也可以用args...表示变参列表 , 在后面的宏定义中直接使用arg表示变参
1
2
3
4
5
/* 使用__VA_ARGS__ */
#define _log_info(...) __log_with_level(LOG_INFO, __VA_ARGS__)

/* 使用args... */
#define _log_info(args...) __log_with_level(LOG_INFO, arg)


4.9.1 拓展 —— 宏定义中的特殊符号

宏定义语句中存在一些特殊的符号 :

  • 语句连接符 \ : 用于在复杂宏定义中 , 将上下行连接起来 , 表示上下行同属于一行

  • 符号# :将其后面的宏参数进行字符串化操作,简单说就是在对它所引用的宏变量 通过替换后在其左右各加上一个双引号。

  • 符号#@ : 将标记转换为相应的单个字符 , 注意:仅对单一标记转换有效 .

1
#define B(x) #@x     // B(1) 即'1' ; B(a)即'a' ; 对B(abc)无效
  • 义参数连接符 ##:主要用于将宏定义中的两个token链接起来,这里的token可以是宏的变量,也可以是任意参数或者标记。(宏展开时会将## 两边的字符合并 , 并删除##这个连接符)
1
#define f(a,b) a##b     // 输入f(a,1)输出为a1


4.9.2 通过##连接符对宏进行优化

1
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)  // 定义一个简单的可变参数宏 

如上所示 :

  • 如上的定义方式存在漏洞 :

    1
    printf("test",);		// 如果变参为空时,宏展开后会产生语法错误
  • 可以使用##连接符来避免产生这个语法错误

    1
    #define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__) 
  • 当变参列表非空时,## 的作用是连接 fmt,和变参列表,各个参数之间用逗号隔开,宏可以正常使用

  • 当变参列表为空时,##会将固定参数 fmt 后面的逗号删除掉,这样宏也就可以正常使用了。




5️⃣ C语言补充

5.1 内联函数与宏

5.1.1 宏与带参宏

宏定义#define 本质是替换,从宏变成最终替换文本的过程称为宏展开


5.1.1.1 带参宏

带参宏的用法与函数调用不完全相同,可能存在部分陷阱

1
2
3
4
5
6
7
8
# define SQUARE(X) X*X

int main(void)
{
x = 5;
SQUARE(x+2);
SQUARE(++x);
}

注意 : 上面两个运行结果分别是 1742

原因如下:

  • SQUARE(x+2); 被替换成(5+2 * 5 +2 = 17)
  • SQUARE(++x); 被替换成(++x * ++x = 6*7 =42)

5.1.1.2 总结:
  • 宏起到的作用只是替换,而不提供计算;
  • 宏是在代码处不加任何验证的简单替代
  • 宏不可以在运行时调试

5.1.2 内联函数

5.1.2.1 定义:

一个用inline声明的函数称为内联函数 , 内联函数一般前面会使用staticextern修饰

如:

1
inline double square(double x){return x*x;}

内联函数可以定义在头文件中,因为内联函数具有内部链接,所以在多个文件中定义同一个内联函数不会产生什么问题



  • inline是指嵌入代码,就是在调用函数的地方不是跳转,而是把代码直接写到那里去。
  • 对于短小的代码来说,inline可以带来一定的效率提升,而且和函数宏相比,inline 更安全可靠
  • 可是这个是以增加空间消耗为代价的 , 因为内联函数相当于代码复制,在运行时,将内联的代码复制上去。

内联函数一般使用情况:

  • 一个函数不断被重复调用。
  • 函数体积小
  • 且函数不包含指针赋值递归循环等语句。

注意:

  • 一般来说,我们写小程序没有必要定义成inline
  • 但是如果要完成一个工程项目,当一个简单函数被调用多次时,则应该考虑用inline
  • 使用static和inline关键字修饰它 , 并可以视情况使用noinlinealways_inline进行声明


5.1.3 宏与内联函数

相比于函数宏 , 内联函数具有以下优势:

  • 参数类型检查。内联函数虽然具有宏的展开特性,但其本质仍是函数,编译过程中,编译器仍可以对其进行参数检查,而宏就不具备这个功能。
  • 便于调试。函数支持的调试功能有断点、单步……,内联函数也同样可以。
  • 返回值。内联函数有返回值,返回一个结果给调用者。这个优势是相对于 ANSI C 而言的。不过现在宏也可以有返回值和类型了,比如使用语句表达式定义的宏。
  • 接口封装。有些内联函数可以用来封装一个接口,而宏不具备这个特性。


5.1.3.1 总结:
  • 宏是在代码处不加任何验证的简单替代,而内联函数是将代码直接插入调用处,而减少了普通函数调用时的资源消耗。
  • 不是函数,只是在编译前(编译预处理阶段)将程序中有关字符串替换成宏体。
  • inline函数是函数,但在编译中不单独产生代码,而是将有关代码嵌入到调用处。
  • 编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;


5.1.4内联函数为什么用static修饰

内联函数为什么定义在头文件中

  • 内联函数的使用方法和宏类似
  • 通过定义在头文件中 , 任何想要使用该内联函数的源文件 ,只需要包含该头文件 ,而不需要重新定义一遍

内联函数为什么要用static修饰

  • 因为我们使用 inline 定义的内联函数,编译器不一定会内联展开
  • 那么当多个文件都包含这个内联函数的定义时,编译时就有可能报重定义错误
  • 而使用 static 修饰,可以将这个函数的作用域局限在各自本地文件内,避免了重定义错误。




5.2 预定义宏

C标准规定了一些预定义宏( 前文关于日志函数优化 部分就使用了部分预定义宏)


预定义宏:

image-20220920221113126


注意: 使用时注意这些宏的作用域


5.3 #pragma

作用 : #pragma 是一个C语言中的预处理指令,用于设定编译器的状态或者指示编译器完成一些特定的动作

注意:

  • #pragma 所定义的很多指示字是编译器特有的
  • #pragma 在不同的编译器间是不可移植的
  • 预处理器将忽略它不认识的 #pragma 指令

示例:

ARM-MDK下支持的#pragma指令:

image-20220922201802679


介绍其中几种 , 但不同编译器对其指令的支持不同


5.3.1 #pragma pack(n)

作用 : 告诉编译器 , 结构体或类 内部的成员变量相对于 第一个变量 的地址的偏移量的对齐方式

注意 :

  • 在参数n缺省的情况下 , 编译器按自然边界对齐
  • 当变量所需的自然对齐边界比n大时 , 按照n对齐
  • 否则按自然边界对齐

[👇 关于结构体对齐, 前文已经介绍过了 ]

(结构体对齐)

使用示例:

1
2
3
4
5
6
#pragma pack(1)		// 使结构体按一字节对齐
struct data{ // 结构体size为7 (1 + 4 + 2 = 7)
char a; // 偏移为0; 占用1字节; 填充0字节
int b ; // 偏移为1; 占用4字节; 填充0字节
short c ; // 偏移为5; 占用2字节; 填充0字节
}

如上所示:

  • 结构体成员的大小取其内部长度最大的数据成员作为其大小;
  • 因为pack 参数默认为 8,所以对齐参数为 4 ;
  • 一般的 pack 对齐格式分别是 1,2,4,8,16;
  • 在默认的对齐格式,也就是#pragmapack() 的情况下,会在结构体中挑选占用字节最多的类型


5.3.1.1 #pragma pack__attribute__(packed)__attribute__(aligned)

  • #pragma pack设置结构体和联合体的字节对齐方式 , 一般参数是 1,2,4,8,16;


  • __attribute__ ((__packed__))关键字用于指定一个变量或类型以最小的方式对齐

    • 用于告诉编译器 , 不对结构体进行对齐处理 , 即按照它原有的类型大小分配空间
    • __attribute__ ((__packed__))是个类型属性可用于单独的结构体成员,也可用于变量

  • __attribute__((aligned(n))) 作用与#pragma pack类似, 也是指定变量或类型对齐方式

    • __attribute__((aligned(n)))同样是一个类型属性 , 不仅可以显性指定结构体某个成员的地址对齐 ; 也可以显式指定整个结构体的对齐方式
    • __attribute__((aligned(n)))的参数必须的2的幂次方

[👇 关于和 , 可查看前文 ]

(attribute(packed) ; attribute(aligned))


5.3.2 #pragma message

作用 : 在编译到该处代码时会在 编译输出窗口 中将制定 消息文本 打印出来。

格式 :

1
#pragma message("消息文本")

用途 : 可用于在版本更替的时候 , 输出版本信息等

1
2
3
4
5
6
7
8
9
#if defined(version V1.0)
#pragma message("version V1.0...")

#elif defined(version V1.3)
#pragma message("version V1.3...")

#elif defined(version V2.0)
#pragma message("version V2.0...")



5.3.3 #pragma once

作用 : #pragma once 用于保证头文件只被编译一次

注意 : #pragma once 不一定会被编译器支持


使用示例:

1
2
3
4
5
6
7
8
#ifndef _GLOBAL_H_				// 条件编译
#define _GLOBAL_H_

#pragma once

int g_value = 1;

#endif

如上所示 :

  • 条件编译
    • 被 C 语言所支持的,并不是只包含一次头文件 , 而是包含多次
    • 通过宏控制 , 可以保证头文件里面的内容只被嵌入一次
    • 由于在编译之前 , 预处理器还是处理了多次 , 所以效率上比较低
  • #pragma once
    • 是告诉预处理器当前文件只编译一次,所以说效率较高
    • 通过与条件编译指令一起使用 , 即保证了移植性 , 又保证了效率


5.4 assert断言

作用 : 计算表达式 expression ,如果其值为假(即为0),那么它先向stderr打印一条出错信息,然后通过调用 abort 来终止程序运行


宏原型 :

1
void assert (int expression);

参数expression可以是一个条件表达式或逻辑表达式:

1
assert(n != 0 );

说明 :

  • assert宏的原型定义在<assert.h>
  • assert是宏,而不是函数。
  • assert 的调用会增加额外的开销 ;
  • 一般可以用于在函数开始出 检验传入参数 的合法性
  • 在调试结束后,可以通过插入 #define NDEBUG 来禁用 assert 调用
1
2
3
4
5
6
7
/* NDEBUG宏和assert宏原型 */
#ifdef NDEBUG
#define assert(e) ((void)0)
#else
#define assert(e) \
((void) ((e) ? ((void)0) : __assert (#e, __FILE__, __LINE__)))
#endif

注意 :

  • 每个assert只检验一个条件 ( 存在多个条件时 , 无法直观的判断是哪个条件失败)
  • 因为assert只在DEBUG个生效 , 所以不能使用改变环境的语句 (如 : assert(i++ < 100))

优点 :

  • 使用assert()能自动标识出文件和出问题的行号
  • 而且提供了一种无需更改代码就能开启或关闭的断言机制


5.5 共用体 union

一般形式:

1
2
3
4
union 联合名
{
成员表
};

结构体和共用体的区别在于 :

  • 结构体的各个成员会 占用不同的内存 ,互相之间没有影响;
  • 而共用体的所有成员 占用同一段内存 ,修改一个成员会影响其余所有成员。

使用实例:


5.5.1 数据传输

用于传输浮点数据

1
2
3
4
5
6
union f_data {
float f;
struct {
uint8_t byte[4];
};
};

这样在进行数据传输的时候会方便很多,比如串口传输只需要把这个数组 byte[4] 进行传输就可以了。



5.5.2 管理状态变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
union sys_status {
uint32_t all_status;

struct {
bool status1 : 1; // FALSE / TRUE
bool status2 : 1; //
bool status3 : 1; //
bool status4 : 1; //
bool status5 : 1; //
bool status6 : 1; //
bool status7 : 1; //
bool status8 : 1; //
bool status9 : 1; //
bool status10 : 1; //
//...
} bit;
};

如上所示 :

  • 将各个状态封装成联合体类型;

  • 联合体里边的成员是一个32bit整数 及一个 结构体 ,该结构体以 位域 的形式体现。

  • 由于每个字段恰好为 1位 ,所以只能为其赋值 1 或 0 。

  • 这样就可以方便的操作 每个 状态标志位了

    1
    sys_status.bit.status1 = 1
  • 也可以直接操作 全部 标志位

    1
    sys_status.all_status = 0

补充说明:

位域:

  • C语言允许在一个结构体中 以位为单位来指定其成员所占内存长度 ,这种以位为单位的成员称为“位段”或称“位域

    1
    2
    3
    4
    5
    6
    7
    struct data {
    uint8_t a : 2;
    uint8_t b : 6;
    uint8_t c : 4;
    uint8_t d : 4;
    uint32_t i;
    };
  • 利用位段能够用较少的位数存储数据, : 后面的数字用来限定成员变量占用的位数。


其他应用:

  • 寄存器的封装 , 也是这样通过联合体封装的

    image-20220923222927253



5.5.3 数据的拆分组合

比如想要获取一个整数的 各个字节


  • 可以采用 移位 的方式 , 例如:
1
2
3
4
#define	GET_LOW_BYTE0(x)	((x >>  0) & 0x000000ff)	/* 获取第0个字节 */
#define GET_LOW_BYTE1(x) ((x >> 8) & 0x000000ff) /* 获取第1个字节 */
#define GET_LOW_BYTE2(x) ((x >> 16) & 0x000000ff) /* 获取第2个字节 */
#define GET_LOW_BYTE3(x) ((x >> 24) & 0x000000ff) /* 获取第3个字节 */
  • 也可以使用 联合体 进行拆分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
union bit32_data
{
uint32_t data;
struct
{
uint8_t byte0;
uint8_t byte1;
uint8_t byte2;
uint8_t byte3;
}byte;
};

int main(void)
{
union bit32_data num;

num.data = 0x12345678;

printf("byte0 = 0x%x\n", num.byte.byte0); // 第一位
printf("byte1 = 0x%x\n", num.byte.byte1); // 第二位
printf("byte2 = 0x%x\n", num.byte.byte2);
printf("byte3 = 0x%x\n", num.byte.byte3);

return 0;
}

补充说明:

  • 在使用联合体进行数据操作时 , 要明确当前平台的 大小端模式

    • 大小端 用于表示数据在存储器中的 存放顺序
    • 大端模式 ,是指数据的低位保存在内存的 高地址 中,而数据的高位,保存在内存的 低地址 中
    • 小端模式 ,是指数据的低位保存在内存的 低地址 中,而数据的高位,保存在内存的 高地址 中
  • 同样的,可以使用联合体来进行 数据组合 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    int main(void)
    {
    union bit32_data num;

    num.byte.byte0 = 0x78;
    num.byte.byte1 = 0x56;
    num.byte.byte2 = 0x34;
    num.byte.byte3 = 0x12;

    printf("num.data = 0x%x\n", num.data); // num.data即为组合后数据

    return 0;
    }


5.6 const关键字

摘抄自嵌入式 C语言 补充

关键字const用来定义常量,如果一个变量被const修饰,那么它的值就不能再被改变,我想一定有人有这样的疑问,C语言中不是有#define吗,干嘛还要用const呢,我想事物的存在一定有它自己的道理,所以说const的存在一定有它的合理性,与预编译指令相比,const修饰符有以下的优点:

预编译指令只是对值进行简单的替换,不能进行类型检查
可以保护被修饰的东西,防止意外修改,增强程序的健壮性
编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。
下面我们从几个方面来说一下const的用法:



5.6.1修饰局部变量

1
const int n=5; int const n=5;
  • 这两种写法是一样的,都是表示变量n的值不能被改变了;
  • 需要注意的是,用const修饰变量时,一定要给变量初始化,否则之后就不能再进行赋值了。

const 用于修饰常量 静态字符串 ,例如:

1
const char* str="fdsafdsa";
  • 如果没有 const 的修饰,我们可能会在后面有意无意的写 str[4]=’x’ 这样的语句
  • 这样会导致对只读内存区域的赋值,然后程序会立刻异常终止。
  • 有了 const ,这个错误就能在编译期被发现。


5.6.2常量指针与指针常量

常量指针 是指针指向的内容是常量,可以有一下两种定义方式。

1
2
const int * n; 
int const * n;

说明:

  • 常量指针 说的是不能通过这个指针改变变量的值,但是还是 可以通过其他的引用来改变变量的值的.

    1
    2
    int a=5; 
    const int* n=&a; a=6;
  • 常量指针 指向的值不能改变,但是这并不是意味着指针本身不能改变, 常量指针可以指向其他的地址。

1
2
int a=5; int b=6; 
const int* n=&a; n=&b;
  • 指针常量 是指指针本身是个常量,不能在指向其他的地址 ,写法如下:
1
int *const n;
  • 指针常量指向的地址不能改变,但是地址中保存的数值是可以改变的,可以通过其他指向改地址的指针来修改。
1
2
3
4
int a=5; 
int *p=&a;
int* const n=&a;
*p=8;

注意:

  • 区分常量指针指针常量的关键就在于星号的位置,以星号为分界线;
  • 如果const在星号的左边,则为常量指针; int const *n;是常量指针
  • 如果const在星号的右边则为指针常量int *const n是指针常量

指向常量的常指针

  • 是以上两种的结合,指针 指向的位置不能改变 并且也 不能通过这个指针改变变量的值
  • 但是依然可以通过其他的 普通指针改变变量的值
1
const int* const p;


5.6.3 修饰函数的参数

根据常量指针指针常量const修饰函数的参数也是分为三种情况:

  • 防止修改指针指向的内容

    1
    void StringCopy(char *strDestination, const char *strSource);
    • 其中 strSource输入参数strDestination输出参数
    • strSource 加上 const 修饰后,如果函数体内的语句试图改动 strSource 的内容,编译器将指出错误

  • 防止修改指针指向的地址

    1
    2
    /*指针p1和指针p2指向的地址都不能修改。*/
    void swap ( int *const p1 , int *const p2 )

  • 以上两种的结合 , 指针和内容都不能改


5.6.4 修饰函数的返回值

如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const 修饰的同类型指针。


例如:

函数原型:

1
const char *GetString(void);

如下语句将出现编译错误

1
char *str = GetString();

正确的用法是

1
const char *str = GetString();


5.6.5 修饰全局变量

  • 全局变量的 作用域 是整个文件;
  • 我们应该尽量 避免使用全局变量 , 因为一旦有一个函数改变了全局变量的值,它也会影响到其他引用这个变量的函数;
  • 如果一定要用全局变量,我们应该尽量的使用const修饰符进行修饰,这样防止不必要的人为修改,使用的方法与局部变量是相同的。

5.7 staic关键字

static关键字在编写程序时一般有三大类用法:

  • 定义局部变量
  • 定义全局变量
  • 定义函数


5.7.1 static定义局部变量

作用域 : 作用域是为代码块内

储存属性 :static定义的局部变量的生命周期 , 会变成静态储存 , 即全局都存在 ;

储存位置 : 储存位置由 转移到了 数据段 (.data)中

值 :

  • 静态局部变量如果没有被用户初始化,则会被编译器自动赋值为0
  • 以后每次调用静态局部变量的时候都用上次调用后的值。
  • 存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化

使用示例:

1
2
3
4
5
6
7
8
9
10
int main(void)
{
int i = 2;
{
int i = 2;
static int k = 4; // 在离开这个函数后,k = 4 不会变 ;在下次进入这个函数时 ,k都是4
}
printf("i = %d", i);
printf("k = %d", i ); // 打印k的值 编译器会报错; 因为k的作用域还是局限在{}中
}


5.7.1.1 补充 —— 内存空间分布

可执行文件(.o)在被加载到内存中时 , 内存空间分布图:

image-20220924001131747


  • 函数 翻译成二进制指令放在 代码段
  • 初始化的全局变量静态局部变量 放在 数据段 中(.data)
  • 未初始化的全局变量静态变量 放在 BSS段 中(.bss)
  • 函数的 局部变量 保存在
  • 使用malloc申请的 动态内存 保存在 堆空间


5.7.2 static定义全局变量

作用域: 作用域仅为当前文件 ; 其他文件不可访问 ; 其他文件可以定义与其同名的变量

储存属性 : 储存属性不变 , 还是 静态储存 , 生命周期为整个程序运行期间

储存位置: 储存位置 不变 , 都是在在.data段(已初始化)或者.bss段(未初始化)内

说明:

  • 用static定义的全局变量称为静态全局变量
  • 在定义不需要与其他文件共享的全局变量时,加上static关键字能够有效地降低程序模块之间的耦合,避免不同文件同名变量的冲突,且不会误使用。


5.7.3 static定义函数

static定义的函数与 static定义的全局变量特性相似

作用域 : static函数的作用域是本源文件 ; 其他文件不能引用该函数 ; 其他文件可以定义 同名函数

储存属性 : 静态储存, 生命周期为整个程序运行期间

储存位置 : 储存在代码段(.test)中



-------------已经到底啦! -------------