嵌入式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 | int b[100] = {[10] = 1,[30] = 2}; // 给指定几个元素初始化 |
GNU C
支持使用…
表示范围拓展 , 不仅可以使用在数组初始化中 , 也可以使用在switch-case
中
1 | int main(void) |
初始化结构体成员示例:
1 | typedef struct |
4.2 语句表达式
-
表达式: 表达式就是由一系列操作符和操作数构成的式子。
1
i = i++ + 3
-
语句 : 在表达式后加一个
;
就构成了一条基本语句 ,;
表示一条语句的结束 -
语句表达式 :
- GNU C对C语言标准作了扩展,允许在一个表达式里内嵌语句;
- 允许在表达式内部使用局部变量、for循环和goto跳转语句;
语句表达式格式:
1
({表达式1 ; 表示2 ; 表达式3 ;})
- 语句表达式 最外面使用小括号
( )
括起来,里面一对 大括号{ }
包起来的是代码块, - 代码块里允许内嵌各种语句。语句的格式可以是一般表达式,也可以是循环、跳转语句。
- 语句表达式的值为内嵌语句中最后一个表达式的值。
示例:
使用语句表达式求值
1 | int main(void) |
- 在上面的程序中,通过语句表达式实现了从1到10的累加求和
- 因为语句表达式的值等于最后一个表达式的值,所以在for循环的后面,我们要添加一个
s
;s
语句表示整个语句表达式的值 , 如果不加这一句,结果是sum=0
。- 如果将这一行语句改为
100
,最后sum
的值就变成了100,这是因为语句表达式的值总等于最后一个表达式的值.
4.2.1 使用实例
- 语句表达式的主要用途在于定义功能复杂的宏。
- 使用语句表达式来定义宏,不仅可以实现复杂的功能,还能避免宏定义带来的歧义和漏洞。
示例:
定义一个宏 , 求两个数的最大值
1 | /* ANSIC */ |
备注: 为什么存在 (void) (&_x == &_y);
这个看似很多余的语句
它的作用有两个 :
- 一是用来给用户提示一个警告,对于不同类型的指针比较,编译器会发出一个警告,提示两种数据的类型不同。
- 二是两个数进行比较运算 , 运算的结果却没有用到,有些编译器可能会给出一个
warning
,加一个(void)
后,就可以消除这个警告。
4.3 typeof
关键字
typeof
是GNU C
扩展的一个关键字 , 用来获取一个变量或表达式的类型。- 使用
typeof
可以获取一个变量或表达式的类型。typeof
的参数有两种形式 : 表达式或类型。
示例:
1 | int i; |
其他用法:
1 | typeof (int *) y; //把y定义为指向int类型的指针,相当于int *y; |
4.4 container_of宏
作用 : 根据结构体某一成员的地址,获取这个结构体的首地址。
实现原理 :
- 结构体中各个成员的地址在数值上等于结构体各成员相对于结构体首地址的偏移。
- 直接用结构体成员的地址 , 减去该成员在结构体内的偏移,就可以得到该结构体的首地址了。
宏定义原型:
1 |
可以看到这个宏有三个参数:
type
为结构体类型;member
为结构体内的成员;ptr
为结构体内成员member
的地址。
4.4.1 结构体的储存空间
编译器在给一个结构体变量分配储存空间时,有以下特点:
- 根据每个成员的数据类型和字节对齐方式,编译器会按照结构体中各个成员的顺序,在内存中分配一片连续的空间来存储它们
- 结构体中的每个成员变量,从结构体首地址开始依次存放,每个成员变量相对于结构体首地址,都有一个固定偏移。
- 在同一个编译环境下 , 各个成员相对于结构体首地址的偏移是固定不变的 . (当然 使用
#pragma pack(x)
或typedef __packed struct
改变字节对齐方式 ,同时也会改变地址编译 )
示例:
1 | struct student |
struct student
的地址偏移
示例2:
1 | struct A // A 对齐为 4 ,大小为 16 |
struct B
的地址偏移
可以看到 :
- 结构体中成员变量在内存中存储的其实是偏移地址
- 也就是说结构体A的地址+成员变量的偏移地址 = 结构体成员变量的起始地址
- 因此,可以根据结构体变量的起始地址和成员变量的偏移地址来反推出结构体A的地址。
4.4.2 container_of
宏实现
4.4.2.1 (((type *)0)->member)
解析
测试代码:
1 | struct student{ |
运行结果:
1 | &age = 00000000 |
如上所示:
((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
和,而不是int
,unsigned
。
4.4…2.4 (type *)((char *)_mptr - offsetof(type, member) );
这句代码的意思是:
- 取结构体某个成员
member
的地址,减去这个成员在结构体type中的偏移,得到结构体type
的首地址。
注意:
- 把
__mptr
转换成char *
类型, 因为offsetof
宏 得到的偏移量是以字节为单位。 - 在语句表达式的最后 因为返回的是结构体的首地址 , 所以整个地址还必须强制转换一下,转换为
TYPE *
- 即返回一个指向
TYPE
结构体类型的指针,所以最后一个表达式中会出现一个强制类型转换(TYPE *)
4.4.5 使用实例
1 | typedef struct |
4.6 零长度数组
零长度数组定义:
1 | int a[0]; |
它的特点是 不占用内存存储空间
示例:
1 | struct buffer{ |
int a[0]
仅仅意味着程序中通过buffer
结构体实例的a[index]
成员可以访问len
之后的第index
个地址- 它并 没有为
a[]
数组分配内存,因此sizeof(struct buffer)=sizeof(int)
。
用途: 零长度数组一般很少单独使用,它常常作为结构体的一个成员,构成一个变长结构体。
示例:
1 | struct buffer{ |
- 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
中格式:
说明:
一段源程序代码在编译生成可执行文件的过程中,函数和变量是放在不同段中的 , 如图所示:
- 一个可执行文件主要由代码段、数据段、BSS段构成。
- 代码段主要存放编译生成的可执行指令代码;
- 数据段和BSS段用来存放全局变量、未初始化的全局变量
- 代码段、数据段和BSS段构成了一个可执行文件的主要部分。
- 此外还有其他的段 ,如只读数据段、符号表 、还包含其他一些
section
说明:
从C程序到可执行文件
示例:
1 | int global_val = 8; |
- 通过
__atttribute__
的section
属性,显式指定一个函数或变量,在编译时放到指定的section
里面 - 未初始化的全局变量是放在
.bss section
中的,即放在 BSS 段中 - 现在通过
section
属性,把这个未初始化的全局变量放到数据段.data
中。
4.7.1.2 属性: aligned
作用 : 指定一个变量或类型的对齐方式(使被设置的对象占用更多的空间)
注意 : 对齐的字节数必须是2的幂次方
示例: 定义一个变量在内存中以指定字节数对齐
1 | int x __attribute__ ((aligned (16))) = 0; // 以16字节地址对齐 |
结构体对齐:
由前文 结构体储存方式 可知道 :
- 结构体作为一种复合数据类型,编译器在给一个结构体变量分配存储空间时,会进行地址对齐;
- 结构体整体对齐要按结构体所有成员中最大成员字节数的整数倍进行对齐.
- 结构体成员按不同的顺序排放,可能会导致结构体整体长度不一致
示例:
1 | struct data{ // 结构体size为12 ((1 + (3)) + 4 + 2 = 10 ,补齐后为12) |
用
__attribute__(aligned())
属性不仅可以显性指定结构体某个成员的地址对齐 ; 也可以显式指定整个结构体的对齐方式
示例:
1 | /* 让 short 型的变量 b 按4字节对齐 */ |
特别注意:
-
修改字节对齐方式 ,最大不能超过编译器允许的最大值;
-
如果连接器最大只支持16字节对齐,那么此时定义32字节对齐也是按16字节对齐
4.7.1.3 属性:packed
作用 : 指定一个变量或类型尽量使用最小的地址对齐方式来分配地址(一般用来设置减少地址对齐)
注意 : 对齐的字节数必须是2的幂次方
用途 : 如用于封装结构体 , 为防止因内存空洞 导致与实际连续的寄存器地址不符合 ,则可以使用该属性.
示例:
1 | struct data{ // 结构体size为7 ((1+ 2 + 4 = 7) |
实际应用:
- 在实际应用中 , 经常可以看到
aligned
和packed
一起使用,即对一个变量或类型同时使用aligned
和packed
属性声明。- 这样做的好处是,既避免了结构体内因地址对齐产生的内存空洞,又指定了整个结构体的对齐方式。
1 | struct data{ // 结构体size为8 ((1+ 2 + 4 = 7 ; 末尾填充1个字节后 ,长度变为8) |
如上所示 :
- 结构体
data
虽然使用packed
属性声明,整个长度变为7; - 同时又使用了
aligned(8)
指定其按8字节地址对齐,所以编译器要在结构体后面填充1个字节 - 这样整个结构体的大小就变为8字节,按8字节地址对齐。
4.7.1.4 属性: deprecated
作用 : 弃用,如果在源文件在任何地方地方使用
__attribute__((deprecated))
函数,编译器将会发出警告.注意 : 该
deprecated
属性也可用于函数和类型
示例:
1 | __attribute__((deprecated)) void test(int a) |
注意:
警告仅在使用时出现,并且仅在类型应用于本身未被声明为已弃用的标识符、变量或函数时才会出现。
1 | /* 变量 */ |
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 | /* 如下所示: |
4.7.2.1.1 拓展 ——变参函数的实现
简单介绍怎么设计一个 变参函数 , 打印传入的实参
基本思路:
- 变参函数的参数存储由一个连续的参数列表组成,列表里存放的是每个参数的地址。
- 有一个固定参数
count
, 该参数后面连续储存着后面一系列参数的地址 - 通过获取
count
的地址 , 再通过&count + 1
就可以依次访问下一个地址
1 | void my_print(int count, ...) |
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 |
|
4.7.2.1.3 简单日志打印函数的实现
通过
vsnprintf
函数 , 解决打印功能实现的问题
printf系列函数说明:
1 |
|
代码实现:
1 | void log_printf(char *fmt, ...) |
运行结果:
4.7.2.1.4 日志打印函数优化
优化方向:
- 添加打印等级 , 根据设置的打印等级,模块打印的 log 信息也会不一样
- 添加
format
属性声明 , 用于让编译器在编译的时候 , 检查log_printf
函数的参数格式
代码实现一:
1 | /*********************************** log.c ************************************/ |
注意: 为什么要使用do { … } while(0)
- 为了防止宏在条件、选择等分支中展开后 ,产生歧义
- 使用do { … } while(0)这种结构 , 在宏展开后,是一个代码块,避免发生歧义
缺陷:
- 因为
__attribute__
关键字不能作用于宏 , 所以没办法让编译器进行可变参数的检查- 也无法使用内联函数 , 因为使用了变参数列表 , 所以内联函数无法展开
代码实现二:
1 |
|
4.7.2.2 属性: weak
作用 : 将一个强符号转换为弱符号 , 可用于函数和变量
用途 : 主要用于定义可以在用户代码中覆盖的库函数
格式:
1
2 void __attribute__((weak)) func(void); // 函数声明
int num __attribte__((weak); // 变量声明
说明:
- 强符号: 函数名、初始化的全局变量名;
- 弱符号: 未初始化的全局变量名。
不同场景下的同名符号:
- (强符号 + 强符号): 在一个工程中同时定义两个同名的函数或全局变量 , 在链接器链接目标文件的时候会报重定义错误
- (强符号 + 弱符号) : 在一个工程中同时定义两个同名的初始化全局变量或未初始化全局变量 , 编译器一般会选用强符号,丢掉弱符号。
- (弱符号 + 弱符号) : 谁的体积大,即谁在内存中存储空间大,使用谁。
使用示例:
1 | //func.c |
输出结果:
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 | //f.c |
4.7.2.4 属性: noinline
& always_inline
作用:
- noinline: 通过声明
noinline
使指定的内联函数不展开- always_inline: 通过声明
always_inline
使指定的内联函数展开用途:
- 因为通过inline关键字修饰的函数 , 并不一定在编译时会展开
- 如函数体太大、存在循环、存在指针、函数调用频繁等情况 ,编译器一般不会做内联展开
- 通过使用
noinline
和always_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 | int main(void) |
说明:
- C 语言函数在调用过程中,会将当前函数的返回地址、寄存器等现场信息保存在堆栈中,然后才会跳到被调用函数中去执行。
- 当被调用函数执行结束后,根据保存在堆栈中的返回地址,就可以直接返回到原来的函数中继续执行。
4.8.2 __builtin_constant_p(EXP)
作用 : 用于判断参数
EXP
在编译时 是否为常量说明 : 如果
EXP
是常量返回 1 ; 否则返回 0用途 ; 主要用于宏定义中 , 根据宏的参数是常量还是变量 , 实现的方法也不同
1 | /* 根据参数是否为常数 , 实现不同的版本 */ |
4.8.3 __builtin_expect(exp,c)
作用 : 用于为编译器提供分支预测信息 ,
说明 : 无论参数
C
的值是什么 , 该内建函数的返回值都是exp
,c
必须是编译时的常数用途 :
Linux
内核编程时常用的likely()
和unlikely()
就是通过__builtin_expect(exp,c)
实现的
1 | /* 将参数x转换为布尔类型 , 然后与1和0直接做比较 , 告诉编译器x为真或假的可能性很高 */ |
注意:
为什么要进行两次取非操作
- 为了把传入参数转换为布尔类型
- 如果参数x是0, 两次取非后还是0
- 如果参数是非0 , 两次取非后会变成1
4.9 可变参数宏
说明 :
在标准
C
语言中支持可变参数函数( 具体见变参函数的实现 ) ;而在
GNU C
中,宏也可以接受可变数目的参数
注意:变参宏的实现形式其实跟变参函数差不多 :
- 用
...
表示变参列表,变参列表由不确定的参数组成,各个参数之间用逗号隔开。 - 可变参数宏使用
__VA_ARGS__
预定义标识符来表示前面的变参列表 - 预处理器在将宏展开时,会用变参列表替换掉宏定义中的所有
__VA_ARGS__
标识符。 - 也可以用
args...
表示变参列表 , 在后面的宏定义中直接使用arg
表示变参
1 | /* 使用__VA_ARGS__ */ |
4.9.1 拓展 —— 宏定义中的特殊符号
宏定义语句中存在一些特殊的符号 :
语句连接符
\
: 用于在复杂宏定义中 , 将上下行连接起来 , 表示上下行同属于一行符号
#
:将其后面的宏参数进行字符串化操作,简单说就是在对它所引用的宏变量 通过替换后在其左右各加上一个双引号。符号
#@
: 将标记转换为相应的单个字符 , 注意:仅对单一标记转换有效 .
1
- 义参数连接符
##
:主要用于将宏定义中的两个token
链接起来,这里的token
可以是宏的变量,也可以是任意参数或者标记。(宏展开时会将##
两边的字符合并 , 并删除##
这个连接符)
1
4.9.2 通过##
连接符对宏进行优化
1 |
如上所示 :
-
如上的定义方式存在漏洞 :
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 |
|
注意 : 上面两个运行结果分别是 17 ;42
原因如下:
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
声明的函数称为内联函数 , 内联函数一般前面会使用static
和extern
修饰
如:
1 | inline double square(double x){return x*x;} |
内联函数可以定义在头文件中,因为内联函数具有内部链接,所以在多个文件中定义同一个内联函数不会产生什么问题
inline
是指嵌入代码,就是在调用函数的地方不是跳转,而是把代码直接写到那里去。- 对于短小的代码来说,inline可以带来一定的效率提升,而且和函数宏相比,
inline
更安全可靠。 - 可是这个是以增加空间消耗为代价的 , 因为内联函数相当于代码复制,在运行时,将内联的代码复制上去。
内联函数一般使用情况:
- 一个函数不断被重复调用。
- 函数体积小
- 且函数不包含指针赋值、递归、循环等语句。
注意:
- 一般来说,我们写小程序没有必要定义成
inline
- 但是如果要完成一个工程项目,当一个简单函数被调用多次时,则应该考虑用
inline
。 - 使用static和inline关键字修饰它 , 并可以视情况使用
noinline
或always_inline
进行声明
5.1.3 宏与内联函数
相比于函数宏 , 内联函数具有以下优势:
- 参数类型检查。内联函数虽然具有宏的展开特性,但其本质仍是函数,编译过程中,编译器仍可以对其进行参数检查,而宏就不具备这个功能。
- 便于调试。函数支持的调试功能有断点、单步……,内联函数也同样可以。
- 返回值。内联函数有返回值,返回一个结果给调用者。这个优势是相对于 ANSI C 而言的。不过现在宏也可以有返回值和类型了,比如使用语句表达式定义的宏。
- 接口封装。有些内联函数可以用来封装一个接口,而宏不具备这个特性。
5.1.3.1 总结:
- 宏是在代码处不加任何验证的简单替代,而内联函数是将代码直接插入调用处,而减少了普通函数调用时的资源消耗。
- 宏不是函数,只是在编译前(编译预处理阶段)将程序中有关字符串替换成宏体。
inline
函数是函数,但在编译中不单独产生代码,而是将有关代码嵌入到调用处。- 编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
5.1.4内联函数为什么用static修饰
内联函数为什么定义在头文件中
- 内联函数的使用方法和宏类似
- 通过定义在头文件中 , 任何想要使用该内联函数的源文件 ,只需要包含该头文件 ,而不需要重新定义一遍
内联函数为什么要用static修饰
- 因为我们使用 inline 定义的内联函数,编译器不一定会内联展开
- 那么当多个文件都包含这个内联函数的定义时,编译时就有可能报重定义错误。
- 而使用 static 修饰,可以将这个函数的作用域局限在各自本地文件内,避免了重定义错误。
5.2 预定义宏
C标准规定了一些预定义宏( 前文关于日志函数优化 部分就使用了部分预定义宏)
预定义宏:
注意: 使用时注意这些宏的作用域
5.3 #pragma
作用 :
#pragma
是一个C语言中的预处理指令,用于设定编译器的状态或者指示编译器完成一些特定的动作注意:
#pragma
所定义的很多指示字是编译器特有的#pragma
在不同的编译器间是不可移植的- 预处理器将忽略它不认识的
#pragma
指令
示例:
ARM-MDK
下支持的#pragma
指令:
介绍其中几种 , 但不同编译器对其指令的支持不同
5.3.1 #pragma pack(n)
作用 : 告诉编译器 , 结构体或类 内部的成员变量相对于 第一个变量 的地址的偏移量的对齐方式
注意 :
- 在参数n缺省的情况下 , 编译器按自然边界对齐
- 当变量所需的自然对齐边界比n大时 , 按照n对齐
- 否则按自然边界对齐
[👇 关于结构体对齐, 前文已经介绍过了 ]
(结构体对齐)
使用示例:
1 |
|
如上所示:
- 结构体成员的大小取其内部长度最大的数据成员作为其大小;
- 因为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用途 : 可用于在版本更替的时候 , 输出版本信息等
1
2
3
4
5
6
7
8
9
5.3.3 #pragma once
作用 :
#pragma once
用于保证头文件只被编译一次注意 :
#pragma once
不一定会被编译器支持
使用示例:
1 |
|
如上所示 :
- 条件编译
- 被 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宏原型 */
注意 :
- 每个assert只检验一个条件 ( 存在多个条件时 , 无法直观的判断是哪个条件失败)
- 因为
assert
只在DEBUG
个生效 , 所以不能使用改变环境的语句 (如 :assert(i++ < 100)
)
优点 :
- 使用
assert()
能自动标识出文件和出问题的行号- 而且提供了一种无需更改代码就能开启或关闭的断言机制
5.5 共用体 union
一般形式:
1
2
3
4 union 联合名
{
成员表
};
结构体和共用体的区别在于 :
- 结构体的各个成员会 占用不同的内存 ,互相之间没有影响;
- 而共用体的所有成员 占用同一段内存 ,修改一个成员会影响其余所有成员。
使用实例:
5.5.1 数据传输
用于传输浮点数据
1 | union f_data { |
这样在进行数据传输的时候会方便很多,比如串口传输只需要把这个数组 byte[4]
进行传输就可以了。
5.5.2 管理状态变量
1 | union sys_status { |
如上所示 :
将各个状态封装成联合体类型;
联合体里边的成员是一个
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;
};利用位段能够用较少的位数存储数据,
:
后面的数字用来限定成员变量占用的位数。
其他应用:
寄存器的封装 , 也是这样通过联合体封装的
5.5.3 数据的拆分组合
比如想要获取一个整数的 各个字节
- 可以采用 移位 的方式 , 例如:
1 | #define GET_LOW_BYTE0(x) ((x >> 0) & 0x000000ff) /* 获取第0个字节 */ |
- 也可以使用 联合体 进行拆分:
1 | union bit32_data |
补充说明:
-
在使用联合体进行数据操作时 , 要明确当前平台的 大小端模式
- 大小端 用于表示数据在存储器中的 存放顺序 :
- 大端模式 ,是指数据的低位保存在内存的 高地址 中,而数据的高位,保存在内存的 低地址 中
- 小端模式 ,是指数据的低位保存在内存的 低地址 中,而数据的高位,保存在内存的 高地址 中
-
同样的,可以使用联合体来进行 数据组合 :
1
2
3
4
5
6
7
8
9
10
11
12
13int 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 | const int * n; |
说明:
-
常量指针 说的是不能通过这个指针改变变量的值,但是还是 可以通过其他的引用来改变变量的值的.
1
2int a=5;
const int* n=&a; a=6; -
常量指针 指向的值不能改变,但是这并不是意味着指针本身不能改变, 常量指针可以指向其他的地址。
1 | int a=5; int b=6; |
- 指针常量 是指指针本身是个常量,不能在指向其他的地址 ,写法如下:
1 | int *const n; |
- 指针常量指向的地址不能改变,但是地址中保存的数值是可以改变的,可以通过其他指向改地址的指针来修改。
1 | int a=5; |
注意:
- 区分常量指针和指针常量的关键就在于星号的位置,以星号为分界线;
- 如果
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 | int main(void) |
5.7.1.1 补充 —— 内存空间分布
可执行文件(.o)在被加载到内存中时 , 内存空间分布图:
- 函数 翻译成二进制指令放在 代码段 中
- 初始化的全局变量 和 静态局部变量 放在 数据段 中(.data)
- 未初始化的全局变量 和 静态变量 放在 BSS段 中(.bss)
- 函数的 局部变量 保存在 栈 中
- 使用
malloc
申请的 动态内存 保存在 堆空间 中
5.7.2 static定义全局变量
作用域: 作用域仅为当前文件 ; 其他文件不可访问 ; 其他文件可以定义与其同名的变量
储存属性 : 储存属性不变 , 还是 静态储存 , 生命周期为整个程序运行期间
储存位置: 储存位置 不变 , 都是在在
.data
段(已初始化)或者.bss
段(未初始化)内说明:
- 用static定义的全局变量称为静态全局变量
- 在定义不需要与其他文件共享的全局变量时,加上static关键字能够有效地降低程序模块之间的耦合,避免不同文件同名变量的冲突,且不会误使用。
5.7.3 static定义函数
static
定义的函数与 static定义的全局变量特性相似作用域 : static函数的作用域是本源文件 ; 其他文件不能引用该函数 ; 其他文件可以定义 同名函数
储存属性 : 静态储存, 生命周期为整个程序运行期间
储存位置 : 储存在代码段(.test)中