嵌入式开发中3个常见的c语言技巧,嵌入式c语言例程
墨初 知识笔记 146阅读
目录
一、C语言标准和编译器

二、指定初始化
三、宏构造“利器”语句表达式

四、typeof与container_of宏
五、零长度数组
六、属性声明section
七、属性声明aligned
一、C语言标准和编译器
C语言标准的发展过程
● K&R C.
● ANSI C.
● C99.
● C11.
指定初始化结构体成员
和数组类似在C语言标准中初始化结构体变量也要按照固定的顺序但在GNU C中我们可以通过结构域来指定初始化某个成员。
在程序中我们定义一个结构体类型student然后分别定义两个结构体变量stu1和stu2。初始化stu1时我们采用C语言标准的初始化方式即按照固定顺序直接初始化。初始化stu2时我们采用GNU C的初始化方式通过结构域名.name和.age就可以给结构体变量的某一个指定成员直接赋值。当结构体的成员很多时使用第二种初始化方式会更加方便。
Linux内核驱动注册
在Linux内核驱动中大量使用GNU C的这种指定初始化方式通过结构体成员来初始化结构体变量。如在字符驱动程序中我们经常见到下面这样的初始化。
指定初始化的好处
如果采用C标准按照固定顺序赋值当file_operations结构体类型发生变化时如添加了一个成员、删除了一个成员、调整了成员顺序那么使用该结构体类型定义变量的大量C文件都需要重新调整初始化顺序牵一发而动全身。
通过指定初始化方式就可以避免这个问题。无论file_operations结构体类型如何变化添加成员也好、删除成员也好、调整成员顺序也好都不会影响其他文件的使用。
三、宏构造“利器”语句表达式什么是表达式、操作符、操作数
表达式就是由一系列操作符和操作数构成的式子。操作符可以是C语言标准规定的各种算术运算符、逻辑运算符、赋值运算符、比较运算符。操作数可以是一个常量也可以是一个变量。
语句表达式
GNU C对C语言标准作了扩展允许在一个表达式里内嵌语句允许在表达式内部使用局部变量、for循环和goto跳转语句。这种类型的表达式我们称为语句表达式。语句表达式的格式如下。
和一般表达式一样语句表达式也有自己的值。语句表达式的值为内嵌语句中最后一个表达式的值。
在宏定义中使用语句表达式请定义一个宏求两个数的最大值。
合格
#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 _x x; \ type _y y; \ _x > _y ? _x : _y; \})
更优秀
#define MAX(x,y)({ \ typeof(x) _x (x); \ typeof(y) _y (y); \ (void) (&_x &_y); \ _x > _y ? _x : _y; \})
在这个宏定义中我们使用了typeof关键字来自动获取宏的两个参数类型。比较难理解的是void&x&y这句话看起来很多余仔细分析一下你会发现这条语句很有意思。它的作用有两个一是用来给用户提示一个警告对于不同类型的指针比较编译器会发出一个警告提示两种数据的类型不同。
二是两个数进行比较运算运算的结果却没有用到有些编译器可能会给出一个warning加一个void后就可以消除这个警告。
四、typeof与container_of宏typeof关键字
ANSI C定义了sizeof关键字用来获取一个变量或数据类型在内存中所占的字节数。GNU C扩展了一个关键字typeof用来获取一个变量或表达式的类型。
使用typeof可以获取一个变量或表达式的类型。typeof的参数有两种形式表达式或类型。
在上面的代码中因为变量i的类型为int所以typeof(i)就等于inttypeof(i) j20就相当于int j20typeof(int*) a相当于int*af()函数的返回值类型是int所以typeof(f()) k就相当于int k
Linux内核中的container_of宏
它的主要作用就是根据结构体某一成员的地址获取这个结构体的首地址。根据宏定义我们可以看到这个宏有三个参数type为结构体类型member为结构体内的成员ptr为结构体内成员member的地址。也就是说如果我们知道了一个结构体的类型和结构体内某一成员的地址就可以获得这个结构体的首地址。container_of宏返回的就是这个结构体的首地址。
结构体作为一个复合类型数据它里面可以有多个成员。当我们定义一个结构体变量时编译器要给这个变量在内存中分配存储空间。根据每个成员的数据类型和字节对齐方式编译器会按照结构体中各个成员的顺序在内存中分配一片连续的空间来存储它们。
在这个程序中我们定义一个结构体里面有3个int型数据成员。我们定义一个变量stu分别打印这个变量stu的地址、各个成员变量的地址程序运行结果如下。
从运行结果可以看到结构体中的每个成员变量从结构体首地址开始依次存放每个成员变量相对于结构体首地址都有一个固定偏移。如num相对于结构体首地址偏移了4字节。math的存储地址相对于结构体首地址偏移了8字节。
一个结构体数据类型在同一个编译环境下各个成员相对于结构体首地址的偏移是固定不变的。我们可以修改一下上面的程序当结构体的首地址为0时结构体中各个成员的地址在数值上等于结构体各成员相对于结构体首地址的偏移。
在上面的程序中我们没有直接定义结构体变量而是将数字0通过强制类型转换转换为一个指向结构体类型为student的常量指针然后分别打印这个常量指针指向的各成员地址。运行结果如下。
从语法角度来看container_of宏的实现由一个语句表达式构成。语句表达式的值即最后一个表达式的值。
最后一句的意义就是取结构体某个成员member的地址减去这个成员在结构体type中的偏移运算结果就是结构体type的首地址。因为语句表达式的值等于最后一个表达式的值所以这个结果也是整个语句表达式的值container_of最后会返回这个地址值给宏的调用者。
计算结构体某个成员在结构体内的偏移,内核中定义了offset宏来实现这个功能.
这个宏有两个参数一个是结构体类型TYPE一个是结构体TYPE的成员MEMBER它使用的技巧和我们上面计算零地址常量指针的偏移是一样的。将0强制转换为一个指向TYPE类型的结构体常量指针然后通过这个常量指针访问成员获取成员MEMBER的地址其大小在数值上等于MEMBER成员在结构体TYPE中的偏移。
结构体的成员数据类型可以是任意数据类型为了让这个宏兼容各种数据类型我们定义了一个临时指针变量__mptr该变量用来存储结构体成员MEMBER的地址即存储宏中的参数ptr的值。如何获取ptr指针类型呢可以通过下面的方式。
宏的参数ptr代表的是一个结构体成员变量MEMBER的地址所以ptr的类型是一个指向MEMBER数据类型的指针当我们使用临时指针变量__mptr来存储ptr的值时必须确保__mptr的指针类型和ptr一样是一个指向MEMBER类型的指针变量。typeof(((type*)0)->member表达式使用typeof关键字用来获取结构体成员MEMBER的数据类型然后使用该类型通过typeof(((type*)0)->member*__mptr这条程序语句就可以定义一个指向该类型的指针变量了。
在语句表达式的最后因为返回的是结构体的首地址所以整个地址还必须强制转换一下转换为TYPE*即返回一个指向TYPE结构体类型的指针所以你会在最后一个表达式中看到一个强制类型转换TYPE*。
五、零长度数组顾名思义零长度数组就是长度为0的数组。ANSI C标准规定定义一个数组时数组的长度必须是一个常数即数组的长度在编译的时候是确定的。在ANSI C中定义一个数组的方法如下。
数组的长度在编译时是未确定的在程序运行的时候才确定甚至可以由用户指定大小。
指针与零长度数组
数组名在作为参数传递时传递的确实是一个地址但数组名绝不是指针两者不是同一个东西。数组名用来表征一块连续内存空间的地址而指针是一个变量编译器要给它单独分配一个内存空间用来存放它指向的变量的地址。我们看下面的程序。
运行结果如下。
对于一个指针变量编译器要为这个指针变量单独分配一个存储空间然后在这个存储空间上存放另一个变量的地址我们就说这个指针指向这个变量。而对于数组名编译器不会再给它分配一个单独的存储空间它仅仅是一个符号和函数名一样用来表示一个地址。如下代码
#include <stdio.h>int array1[10] {1, 2, 3, 4, 5, 6, 7, 8, 9};int array2[0];int *p &array1[5];int main(void){ return 0;}
在这个程序中我们分别定义一个普通数组、一个零长度数组和一个指针变量。其中这个指针变量p的值为array1[5]这个数组元素的地址也就是说指针p指向arraay1[5]。我们接着对这个程序使用ARM交叉编译器进行编译并进行反汇编。
从反汇编生成的汇编代码中我们找到array1和指针变量p的汇编代码。
从汇编代码中可以看到对于长度为10的数组array1[10]编译器给它分配了从0x205240x20548共40字节的存储空间但并没有给数组名array1单独分配存储空间数组名array1仅仅表示这40个连续存储空间的首地址即数组元素array1[0]的地址。对于指针变量p编译器给它分配了0x20538这个存储空间在这个存储空间上存储的是数组元素array1[5]的地址0x20538。
而对于array2[0]这个零长度数组编译器并没有为它分配存储空间此时的array2仅仅是一个符号用来表示内存中的某个地址我们可以通过查看可执行文件a.out的符号表来找到这个地址值。
readelf -s a.out
从符号表可以看到array2的地址为0x21054在BSS段的后面。array2符号表示的默认地址是一片未使用的内存空间仅此而已编译器绝不会单独再给其分配一个存储空间来存储数组名。
数组名和指针并不是一回事数组名虽然在作为函数参数时可以当作一个地址使用但是两者不能画等号。
六、属性声明sectionGNU C编译器扩展关键字__attribute__
__attribute__的使用非常简单当我们定义一个函数、变量或类型时直接在它们名字旁边添加下面的属性声明即可。
使用__atttribute__这个属性声明就相当于告诉编译器按照我们指定的边界对齐方式去给这个变量分配存储空间。
有些属性可能还有自己的参数。如aligned(8)表示这个变量按8字节地址对齐属性的参数也要使用小括号括起来如果属性的参数是一个字符串则小括号里的参数还要用双引号引起来。
我们可以使用__attribute__来声明一个section属性section属性的主要作用是在程序编译时将一个函数或变量放到指定的段即放到指定的section中。
一个可执行文件主要由代码段、数据段、BSS段构成。代码段主要存放编译生成的可执行指令代码数据段和BSS段用来存放全局变量、未初始化的全局变量。代码段、数据段和BSS段构成了一个可执行文件的主要部分。
除了这三个段可执行文件中还包含其他一些段。用编译器的专业术语讲还包含其他一些section如只读数据段、符号表等。我们可以使用下面的readelf命令去查看一个可执行文件中各个section的信息。
例如下面的程序我们分别定义一个函数、一个全局变量和一个未初始化的全局变量。
#include <stdio.h>int global_val 8;int global_val;void print_star(void){ printf(****\n);}int main(void){ print_star(); return 0;}
readelf
是一个用于查看和分析可执行文件、共享库和目标文件的工具。它提供了多种选项来显示不同类型的信息。其中-s
选项和-S
选项用于显示不同的符号表和节表信息。
-s
选项-s
选项用于显示符号表Symbol Table的信息。符号表是一个记录了程序中各种符号如函数、变量、常量等的表格它包含了符号的名称、类型、大小、地址等信息。使用-s
选项可以查看符号表中的符号列表以及相关的属性。
示例命令readelf -s <file>
-S
选项-S
选项用于显示节表Section Table的信息。节表是一个记录了程序各个节Section的表格它包含了每个节的名称、类型、大小、偏移量等信息。节表描述了程序的不同部分如代码段、数据段、BSS段、符号表等。
示例命令readelf -S <file>
总结
-s
选项用于显示符号表的信息包括符号的名称、类型、大小、地址等。-S
选项用于显示节表的信息包括节的名称、类型、大小、偏移量等。
查看可执行文件的符号表信息
对应的section header表信息如下。
通过符号表和section header表信息我们可以看到函数print_star400526被放在可执行文件中的.text section400430即代码段初始化的全局变量global_val601038被放在了a.out的.data section601028即数据段而未初始化的全局变量uninit_val601040则被放在了.bss section60103c即BSS段。
编译器在编译程序时以源文件为单位将一个个源文件编译生成一个个目标文件。在编译过程中编译器都会按照这个默认规则将函数、变量分别放在不同的section中最后将各个section组成一个目标文件。编译过程结束后链接器会将各个目标文件组装合并、重定位生成一个可执行文件。
在GNU C中我们可以通过__attribute__的section属性显式指定一个函数或变量在编译时放到指定的section里面。通过上面的程序我们知道未初始化的全局变量默认是放在.bss section中的即默认放在BSS段中。现在我们就可以通过section属性声明把这个未初始化的全局变量放到数据段.data中。
通过readelf命令查看符号表我们可以看到uninit_val601034这个未初始化的全局变量通过__attribute__((section(.data)))属性声明就和初始化的全局变量一样被编译器放在了数据段.data601020中。
U-boot镜像自复制分析
有了section这个属性声明我们就可以试着分析U-boot在启动过程中是如何将自身代码加载的RAM中的。U-boot的用途主要是加载Linux内核镜像到内存给内核传递启动参数然后引导Linux操作系统启动。U-boot一般存储在NOR Flash或NAND Flash上。无论从NOR Flash还是从NAND Flash启动U-boot其本身在启动过程中都会从Flash存储介质上加载自身代码到内存然后进行重定位跳到内存RAM中去执行。
char __image_copy_start[0] __attribute__((section(.__image_copy_start)));char __image_copy_end[0] __attribute__((section(.__image_copy_end)));
这两行代码定义在U-boot-2016.09中的arch/arm/lib/section.c文件中。在其他版本的U-boot中可能路径不同这两行代码的作用是分别定义一个零长度数组并指示编译器要分别放在.__image_copy_start和.__image_copy_end这两个section中。
链接器在链接各个目标文件时会按照链接脚本里各个section的排列顺序将各个section组装成一个可执行文件。U-boot的链接脚本Uboot.lds在U-boot源码的根目录下面。
OUTPUT_FORMAT(elf32-littlearm, elf32-littlearm, elf32-littlearm)OUTPUT_ARCH(arm)ENTRY(_start)SECTIONS{ . 0x00000000; . ALIGN(4); .text : { *(.__image_copy_start) *(.vectors) arch/arm/cpu/armv7/start.o (.text*) *(.text*) } . ALIGN(4); .rodata : { *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) } . ALIGN(4); .data : { *(.data*) } . ALIGN(4); . .; . ALIGN(4); .u_boot_list : { KEEP(*(SORT(.u_boot_list*))); } . ALIGN(4); .image_copy_end : { *(.__image_copy_end) } .rel_dyn_start : { *(.__rel_dyn_start) } .rel.dyn : { *(.rel*) } .rel_dyn_end : { *(.__rel_dyn_end) } .end : { *(.__end) } _image_binary_end .; . ALIGN(4096); .mmutable : { *(.mmutable) } .bss_start __rel_dyn_start (OVERLAY) : { KEEP(*(.__bss_start)); __bss_base .; } .bss __bss_base (OVERLAY) : { *(.bss*) . ALIGN(4); __bss_limit .; } .bss_end __bss_limit (OVERLAY) : { KEEP(*(.__bss_end)); } .dynsym _image_binary_end : { *(.dynsym) } .dynbss : { *(.dynbss) } .dynstr : { *(.dynstr*) } .dynamic : { *(.dynamic*) } .plt : { *(.plt*) } .interp : { *(.interp*) } .gnu.hash : { *(.gnu.hash) } .gnu : { *(.gnu*) } .ARM.exidx : { *(.ARM.exidx*) } .gnu.linkonce.armexidx : { *(.gnu.linkonce.armexidx.*) }}
通过链接脚本我们可以看到__image_copy_start和__image_copy_end这两个section在链接的时候分别放在了代码段.text的前面、数据段.data的后面作为U-boot复制自身代码的起始地址和结束地址。而在这两个section中我们除了放两个零长度数组并没有放其他变量。
在arch/arm/lib/relocate.S中ENTRYrelocate_code汇编代码主要完成代码复制的功能。
ENTRY(relocate_code)ldrr1, __image_copy_start/* r1 <- SRC &__image_copy_start */subsr4, r0, r1/* r4 <- relocation offset */beqrelocate_done/* skip relocation */ldrr2, __image_copy_end/* r2 <- SRC &__image_copy_end */copy_loop:ldmiar1!, {r10-r11}/* copy from source address [r1] */stmiar0!, {r10-r11}/* copy to target address [r0] */cmpr1, r2/* until source end address [r2] */blocopy_loop
在这段汇编代码中寄存器R1、R2分别表示要复制镜像的起始地址和结束地址R0表示要复制到RAM中的地址R4存放的是源地址和目的地址之间的偏移在后面重定位过程中会用到这个偏移值。在汇编代码中
ldrr1, __image_copy_start
通过ARM的LDR伪指令直接获取要复制镜像的首地址并保存在R1寄存器中。数组名本身其实就代表一个地址通过这种方式Uboot在嵌入式启动的初始阶段就完成了自身代码的复制工作从Flash复制自身镜像到内存中然后进行重定位最后跳到内存中执行。
七、属性声明aligned地址对齐aligned
GNU C通过__attribute__来声明aligned和packed属性指定一个变量或类型的对齐方式。这两个属性用来告诉编译器在给变量分配存储空间时要按指定的地址对齐方式给变量分配地址。如果你想定义一个变量在内存中以8字节地址对齐就可以这样定义。
通过aligned属性我们可以显式地指定变量a在内存中的地址对齐方式。aligned有一个参数表示要按几字节对齐使用时要注意地址对齐的字节数必须是2的幂次方否则编译就会出错。
编译器一定会按照aligned指定的方式对齐吗
通过aligned属性我们可以显式指定一个变量的对齐方式编译器就一定会按照我们指定的大小对齐吗非也我们通过这个属性声明其实只是建议编译器按照这种大小地址对齐但不能超过编译器允许的最大值。一个编译器对每个基本数据类型都有默认的最大边界对齐字节数。如果超过了则编译器只能按照它规定的最大对齐字节数来给变量分配地址。
在这个程序中我们指定char型的变量c2以16字节对齐编译运行结果如下。
我们可以看到编译器给c2分配的地址是按16字节地址对齐的如果我们继续修改c2变量按32字节对齐你会发现程序的运行结果不再有变化编译器仍然分配一个16字节对齐的地址这是因为32字节的对齐方式已经超过编译器允许的最大值了。
属性声明packed
aligned属性一般用来增大变量的地址对齐元素之间因为地址对齐会造成一定的内存空洞。而packed属性则与之相反一般用来减少地址对齐指定变量或类型使用最可能小的地址对齐方式。
在上面的程序中我们将结构体的成员b和c使用packed属性声明就是告诉编译器尽量使用最可能小的地址对齐给它们分配地址尽可能地减少内存空洞。程序的运行结果如下。
通过结果我们看到结构体内各个成员地址的分配使用最小1字节的对齐方式没有任何内存空间的浪费导致整个结构体的大小只有7字节。
这个特性在底层驱动开发中还是非常有用的。例如你想定义一个结构体封装一个IP控制器的各种寄存器在ARM芯片中每一个控制器的寄存器地址空间一般都是连续存在的。如果考虑数据对齐则结构体内就可能有空洞就和实际连续的寄存器地址不一致。使用packed可以避免这个问题结构体的每个成员都紧挨着依次分配存储地址这样就避免了各个成员因地址对齐而造成的内存空洞。
我们也可以对整个结构体添加packed属性这和分别对每个成员添加packed属性效果是一样的。修改结构体后重新编译程序运行结果和上面程序的运行结果相同结构体的大小为7结构体内各成员地址相同。