嵌入式C语音基础夯实备用实战

C语言虽然是基础,但实际上是要求学者已经具备入门的能力,然后通过本章节将C语言里面几个比较重要的板块进行加深;如果学者对C语言并不了解,建议先通过网上搜索入门教程学习后再进入本章节。

1.数据类型

1.1  类型定义

C语言标准中,常用的类型如下表格:

类型

存储大小

取值范围

signed char

1 字节(Byte),8bits

-128 到 127

unsigned char

1 字节,8bits

0 到 255

short

2 字节,16bits

-32,768 到 32,767

unsigned short

2 字节,16bits

0 到 65,535

int

2/4 字节,16/32bits

-32,768 到 32,767 或

-2,147,483,648 到 2,147,483,647

unsigned int

2/4 字节,16/32bits

0 到 65,535 或 0 到 4,294,967,295

long

4 字节,32bits

-2,147,483,648 到 2,147,483,647

unsigned long

4 字节,32bits

0 到 4,294,967,295

float

4 字节,32bits

1.2E-38 到 3.4E+38

double

8 字节,64bits

2.3E-308 到 1.7E+308

bool

忽略

true/false

然而在正常开发过程中,由于平台(主控芯片类型)不同,会导致我们使用的变量有位数的差异,比如表中的int在不同位(16位/32位)机的表现不同,因此我们需要一个抽象定义来解决这个跨平台问题:

实际使用类型

存储大小

打印符号

int8_t

1 字节(Byte),8bits

%d

uint8_t

1 字节,8bits

%u%c%x(16进制小写,%X大写)

int16_t

2 字节,16bits

%d

uint16_t

2 字节,16bits

%u%x

int32_t

4 字节,32bits

32位机器%ld,64位机器%d

uint32_t

4 字节,32bits

32位机器%lu,64位机器%u%x

int64_t

8 字节,64bits

32位机器%lld,64位机器%ld

uint64_t

8 字节,64bits

32位机器%llu,64位机器%lu

float

4 字节,32bits

%f

double

8 字节,64bits

%lf

bool

忽略

%d或%s

这个抽象定义在Linux标准头文件已经实现,我们只需要在开发时包含一下头文件即可:#include <stdint.h>

bool类型需要包含另外一个头文件:#include <stdbool.h>

1.2  类型格式化

在上一章节中我们理解了类型占用的空间,还有输出格式,但还是存在一些困惑的地方,这里做一些解释,首先是格式输出(比如使用printf打印),我们必须要解决一个不同位数机器的兼容问题,在我们的Ubuntu Server中可以找到一个目录:~/workspace/basics/c/3_1_typedef,该目录里面的源码对应本章内容,如下:

1.2.1包含标准头文件:

说明:定义__cplusplus说明是c++的编译器进行编译,c++原生支持bool类型,因此可以忽略该头文件,不然会有编译告警。

1.2.2 定义变量:

1.2.3  printf格式化输出:

说明:

#if defined(__x86_64__) || defined(__aarch64__) || defined(__arm64__)

这段宏定义是在区分代码是在64位机器上还是32位机器,不同位数的打印输出是不一样的,我们的Ubuntu Server是64位机器的;如果没有这段宏,代码会出莫名其妙的运行时问题,而且是令人百思不得其解的现象。

1.2.4 编译及运行:

注意,嵌入式的开发过程不要使用微软的工具(比如VS)来编译或者运行,不然你的代码会有一堆冗余的代码去将就WIN32和微软编译内核版本的问题,比如上述例子中的bool类型,如果使用旧的微软编译器(低于Visual Studio 2013的版本),一定会出现编译不通过的问题,而我们需要做一个修改来将就这个编译内核的问题:

这种兼容在嵌入式开发是绝对的败笔,除非你的软件是开源软件,需要考虑各方的使用,如果是企业产品的开发,不建议使用微软的编译工具。

目前我们暂不考虑编译到开发板上运行,先通过gcc来编译测试:

这段代码可以在任何基于Linux平台(包括安卓)中被无缝移植,而且编译无任何警告和错误,运行也不会出现奇怪的问题。

这种格式化也经常被用来通过sprintf把数值格式化为字符串,比如:

1.3 变量

1.3.1 可执行程序存储区

当一个C/C++原码文件被编译链(比如gcc/g++)编译及链接成为可执行程序后,由4个段组成,分别是:代码段,数据段,栈,堆

代码段(.text)包含代码逻辑(函数),以及宏定义(#define)常量。

数据段包含3部分:.bss,.rodata,.data

.bss: Block Started by Symbol,存放程序中未初始化的全局变量。

.rodata:read only data,用于存放不可变修改的常量数据。

.data:静态变量和已初始化的全局变量存储区。

栈(.stack)主要用来存放局部变量, 传递的参数, 存放函数的返回地址;程序运行过程中动态生成及回收,不需要用户回收存储空间。

堆(.heap)由malloc等API动态分配的内存区域,其生命周期由free决定;程序运行过程中动态生成,需要由使用者自行回收。

了解程序的组成存储区有利于开发过程中对程序的精简,比如我们可以选择变量内容及大小是直接编译进可执行程序(ROM)中,还是程序运行过程中才被实例化(RAM);如果代码量10W+行基本能很明显的出现差异,同样功能有的代码编译出来占用空间非常大,有的很精简,其中一个原因就是对底层存储分区的理解不同。

在我们Ubuntu Server目录:~/workspace/basics/c/3_2_variables,存放着本章节我们会用到的源代码文件;其中main_1.c的内容是针对变量/函数的分区存储结构做了描述:

我们尝试保留及注释掉.data里面的一个存储空间,对比两者编译后程序的大小。

差别巨大:

1.4 动态类型

本节内容源码在点击~/workspace/basics/c/3_2_variables/main_2.c中,主要讲解C语言中的动态类型变量定义的方法,需要使用到的关键字是:typeof()该关键字是GNU C提供的一种特性,可以用来取得变量/函数的类型,或者表达式的类型。常用的方式如下:

a. 取得变量类型。

定义一个变量,可以是普通变量也可以是指针变量,然后typeof取得该变量类型并用于定义另外同类型的变量;比如图中所示的value。

b. 取得函数类型做函数指针。

主要用来取得函数的类型,并定义函数指针使用,图中所示的指针func就是取着函数add类型定义的。

c. 取得表达式类型做处理。

取得表达式相对较为复杂,图中所示,我们将函数add的运算结果导出来用于判断;该技巧同样可以用于函数调用失败后的多次重试。

编译运行如下:

1.5  类型转换

在C语言中,进行类型之间的转换有两种转换方式:隐式类型转换 和 强制类型转换。其中强制类型转换是由开发人员完成的,比如float val = (float)u8;

一般不会出现问题,所以我们重点关心隐式类型转换

隐式类型转换是由编译器主动完成的,如果由低类型到高类型的隐式类型转换是安全的,不会发生截断;相反由高类型到低类型的隐式类型转换是不安全的,会发生截断产生不正确的结果:

四种情况下会发生隐式类型转换:赋值,算术运算,函数传参,函数返回值。

在源码文件:main_3.c中,我们列出了四种情况的例子:

a. 赋值。

图中我们定义的类型uint8_t u8,并赋值为250;同时定义int8_t i8,然后把u8赋值给i8,显然这个过程出现类型不匹配的转换,由于250已经超过i8的最大范围,因此i8不在是数值250了。

b. 算术运算。

两个uint8_t类型相加,赋值给uint16_t,实际上编译器在执行该条指令时,会把两个uint8_t先转换为uint16_t,所以图中:

uint16_t both = cal_1 + cal_2; 等价于:

uint16_t both = (uint16_t)cal_1 + (uint16_t)cal_2;

隐式类型转换后数据正确

c. 函数传参。

函数add的参数类型都是int8_t,而我们传入的200已经超过最大范围,因此传入的数据发生大类型到小类型的转换;同时函数返回值是int8_t,两个超过范围的int8_t相加得不到200+200=400的数值,如果相加也出现溢出,那么返回值更加不可测了。

d. 函数返回值。

函数add2的参数和返回值都是uint16_t,我们传入的两个uint8_t被转换为uint16_t,运算结果数值也是uint16_t,因此返回数值正确。

编译运行:

在编写程序的过程中,我们需要留意可能存在隐式类型转换的地方,避免由于数据类型转换导致的结果不可预测。

 

1.6 指针

本章内容的源码在目录点击~/workspace/basics/c/3_3_pointer 中。

1.6.1 指针与堆

堆常见的操作函数有以下几个:

void *malloc(size_t size) : 从堆中申请内存空间。

void *calloc(size_t nmemb, size_t size) : 从堆中申请内存空间并清零。

void *realloc(void *ptr, size_t size) : 调整已从堆中申请到的内存大小。

void free(void *ptr) : 释放从堆中申请到的内存空间。

以上的API需要包含头文件: #include <stdlib.h>

void *memset(void *s, int c, size_t n) : 给内存空间格式化为指定值。

void bzero(void *s, size_t n) : 给内存空间清零。

以上的API需要包含头文件: #include <string.h>

a. malloc以及内存清零。

malloc返回值非NULL时表示成功,程序中没有做这个判断,出于严谨考虑大家在调用时最好做下判断;同时除非申请的空间极其巨大或者有内存泄漏,不然基本不会出现失败的情况。清零的方式有两种,可以使用memset,也可以使用bzero,建议使用bzero直观一点。

b. realloc。

该函数使用时需要注意,失败时会返回NULL,且先前分配的堆内存空间是不会被回收的,因此我们在调用该函数之前需要备份一下已申请的内存空间地址,这样申请失败的话还可以找回之前的数据;同时需要注意的是,重新申请成功后多出来的那部分内存空间有可能是没有被清零的,需要我们手动调用bzero进行清零。

c. 释放内存。

所有从堆中成功申请到内存空间,都需要通过free函数进行手动回收,不然会造成内存泄漏。

d. 内存拷贝。

从一片内存拷贝数据到另外一片内存可以使用函数:memcpy,比如:

把content栈空间内容(hello world!)拷贝到堆中(pa)。

1.7 多阶指针

指向指针的指针是一种多级间接寻址的形式,或者说是一个指针链;有两个概念需要区分,一个是指针变量本身,一个是指针变量指向(存储)的地址。比如:

int a = 100;

int *pa = &a;

  1. 定义一个整型(int)变量a,值是100。
  2. 定义一个整型指针(int *)变量pa,值是a的地址。

pa是一个整型指针变量,指向的地址是整型变量a的地址,而*pa是地址里面的内容,也就是变量a的值100。

由此看一个程序:

char *类型指针p1,指向常量区内容为"1234567890"的地址;

char **指向指针的指针p2,指向p1地址,因此*p2就是p1,依次类推

定义char ***p3存放p2地址,*p3就是p2,**p3相当于*p2也就是p1;

定义char ****p4存放p3地址,*p4就是p3,**p4相当于*p3也就是p2,

***p4相当于**p3也就是*p2即p1。

上述程序中printf的输出应该都是一样的,编译测试:

1.8 通用类型指针

a. 类型转换。

所谓通用类型指针,其实就是void *;该指针可以用来转换不同类型参数指针变量,如下例子(main_3.c),我们定义一个用于输出任意类型变量内容的函数,函数要求传入参数时指定该参数的类型(比如uint16_t,string等)来指示输出:

函数将void *类型格式化为想要类型的指针,然后取值输出,调用方式:

编译输出:

b. 代理传参调用。

下面这个例子会比较抽象(涉及到指针和地址比较抽象的转换关系,了解即可,不要求能理解里面的转换逻辑),源码放在main_4.c中,需要很好理解地址和指针变量的关系,我们借助void *的列表来存储一段空间,并用来做回调函数的调用,从而达到代理调用的过程。

思考一下:int *pa = (int *)malloc(sizeof(int)); 这个代码中,指针变量本身的地址在哪指针变量指向的地址在哪指针变量指向的地址里面的内容是什么,先清这几个概念再继续往下看,首先指针变量本身的地址是&pa指针指向的地址是pa(准确来说是(uint64_t)pa指针指向的地址里面的内容是*pa

声明几个函数如下:

然后有一定测试代码如下:

首先我们申请了2个参数(args),然后给两个参数进行赋值,第一个值是一个函数的地址,第二个值是一个uint16_t类型的数值(12345);然后调用tester进行测试,函数tester里面把args的内容(函数和数值)取出来,进行调用,也就是把第一值强制转换成函数指针(第21行),然后传入数值完成调用。

再看args这几个声明的函数实现:

在函数args_add_func里面,首先我们把指针变量func所指向的地址保存了起来(loader),然后loader被放在args中,由于index是0,因此是放在第一个位置;在函数args_add_uint16里面,我们则是把变量value的数值保存起来(pvalue),然后放在args第二个位置(index是1)。再回过头看tester里面的代码大概就能看懂了。

编译运行,结果等价于直接调用show函数:show(12345):

1.9 主函数(main)参数列表

默认情况下,main函数是一个程序的入口函数,而我们可以给程序传入一系列的参数列表,这个列表可以在main函数中被解析;这就需要main函数是带参的方式使用了,基本写法:int main(int argc, char *argv[])。

main函数参数列表的解析,需要包含一个头文件:#include <getopt.h>,利用该头文件提供的接口,我们需要理解几个地方即可,

a. 结构体:
struct option {  

const char *name;  // 选项(参数)

int has_arg;  // 是否选项后有参数,常用取值如下:

// required_argumentno_argument

int *flag;    // NULL时返回val使用时保持NULL就可以了。

int val;      // 返回值,需和flag配合。

};

b. 外部变量:

extern char *optarg;  // 当前选项对应的参数值。

c. 函数(结合例子看):

int getopt_long(int argc,

char * const argv[],

const char *optstring,

const struct option *longopts,

int *longindex);

d. 还有就是选项字符串:

字符加冒号,比如:"p:n:v::th",说明:

  1. 字符加一个冒号表示选项后面必须加一个参数,选项和参数用空格隔开。
  2. 单个字符后跟两个冒号,表示该选项后可以跟一个参数,也可以不跟,如果后边跟一个参数,参数必须紧跟在选项后不能以空格隔开。
  3. 单个字符,表示选项,后面不能带参数。

例子(模板),选项必须再opts中找得到:

编译运行如下:

1.10 可变参数

基本用法

C语言的可变参数解析需要包含头文件:#include <stdarg.h>,直接上例子,我们实现一个加法函数add,用来求和:

add第一个参数n,表示可变参数的长度,有几个地方说明下:

va_list 为可变参数类型。

va_start 是一个宏,计算起始地址。

va_end 是一个宏,计算结束地址。

va_arg 是一个宏,它返回当前取值指针指向的参数值并将取值指针指向形参列表的下一个参数的首地址。

结果如上图,基本是固定的,我们通过va_arg拿出可变参数的每个数据,然后进行加法运算。

main函数中调用的不是函数add,而是宏ADD,这里主要是说明宏如何进行可变参数的处理,可以用__VA_ARGS__来表示可变参数。

编译运行:

1.10.1 扩展用法之日志输出

利用可变参数的特性,我们可以实现一个日志功能,输出带有文件名,函数名,行号的一个接口来代替printf的功能;再linux c中,可以用三个宏来分别表示文件名:__FILE__;函数名:__func__;行号:__LINE__;有了这三个宏,我们再把用户输出的内容拼接进入,就可以完成一个带格式的日志功能了,首先声明并实现一个函数logd如下:

这个函数的参数列表包括:文件名file,函数名func,行号line,剩下的format和一个可变参数;可变参数的解析结构和上节课一样,多出一个新的接口函数是vsprintf,这个接口是把可变参数的内容格式化为一个字符串。

为了简化调用,我们定义一个宏LOG,这个宏主要的作用是自己填充文件名,函数名,行号,简化调用过程:

最后是使用的方法:

编译运行:

1.11 头文件

兼容C++

定义.h文件时,可以按照下面的声明方式,从而完成对c++的兼容:

头文件应符合几个基本要求:

  1. 尽可能少的包含其他头文件。
  2. 不带具体的代码实现,纯粹的做API声明。
  3. 简洁明了。
  4. 除了需要extern声明的外部变量,不能出现变量的定义。

1.11.2 日志功能实例

我们把3.5.2中的日志接口单独抽取出来,放在log.h中去声明接口函数,然后在log.c中去实现,最后在main.c中去包括log.h并通过宏来调用日志输出功能。

接口实现源码log.c如下:

然后main.c中的调用如下:

编译运行(gcc log.c main.c -o tester):

1.11.3 C++源文件测试

上一节C语言测试了log.h,现在我们创建一个main.cpp,在C++源码中来测试,测试内容如下(嵌入式C++基础后续在基础篇中加入,这里了解即可):

logger::instance()是为了取得一个类logger的单例,类logger中有一个函数debug对log.h中的接口进行了一次封装;调用如下:

编译运行(g++ log.c main.cpp -o tester):

 

==================================================================================================================================

这样整个开发环境及交叉编译链就搭建好啦!!如果觉得对您有帮助并想进一步深入学习交流可以扫描以下微信二维码或加入QQ群:928840648

欢迎共同学习成长,有一群爱学习的小伙伴一起勉励!!加油!!也可点击