总结嵌入式开发中的C语言知识点
分享本文,阐述嵌入式开发中需要了解的C语言知识和重点,希望每个读到这篇文章的人都能有所收获。
有人说C语言是非常简单的,也有人说学了十年还是没有学明白。事实上,编写优质嵌入式C程序并非易事,需要了解相关硬件特性和缺陷,还需要了解相应地编译原理。
关键字
几乎每一门语言中都有关键字,具有特殊功能,C语言也不例外,按照功能可分为:
数据类型(常用char, short, int, long, unsigned, float, double)
运算和表达式( =, +, -, *, while, do-while, if, goto, switch-case)
数据存储(auto, static, extern,const, register,volatile,restricted)
结构(struct, enum, union,typedef)
位操作和逻辑运算(<<, >>, &, |, ~,^, &&)
预处理(#define, #include, #error,#if...#elif...#else...#endif等)
平台扩展关键字(__asm, __inline,__syscall)
这些关键字共同构成了嵌入式平台的C语言语法,嵌入式的应用从逻辑上可以抽象为以下三个部分:
数据的输入(如传感器,信号,接口输入)
数据的处理(如协议的解码和封包,AD采样值的转换等)
数据的输出(GUI的显示,输出的引脚状态,DA的输出控制电压,PWM波的占空比等)
贯穿在整个嵌入式应用开发的过程中,对数据的管理包含以下几部分:
数据类型
存储空间
位和逻辑操作
数据结构
为了应对嵌入式开发中受限的资源环境,C语言从语法上支撑上述功能的实现,并提供相应的优化机制。
数据类型
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
......
typedef signed int int32_t;
既然不同平台的基本数据宽度不同,那么如何确定当前平台的基础数据类型如int的宽度,这就需要C语言提供的接口sizeof,实现如下。
printf("int size:%d, short size:%d, char size:%d\n", sizeof(int), sizeof(char), sizeof(short));
这里还有重要的知识点,就是指针的宽度,如
char *p;
printf("point p size:%d\n", sizeof(p));
其实这就和芯片的可寻址宽度有关,如32位MCU的宽度就是4,64位MCU的宽度就是8,在有些时候这也是查看MCU位宽比较简单的方式。
内存管理和存储架构
从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中 ,效率很高,但是分配的内存容量有限。
从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活,但同时遇到问题也最多。
这里先看个简单的C语言实例。
//main.c
static int st_val; //静态全局变量 -- 静态存储区
int ex_val; //全局变量 -- 静态存储区
int main(void)
{
int a = 0; //局部变量 -- 栈上申请
int *ptr = NULL; //指针变量
static int local_st_val = 0; //静态变量
local_st_val += 1;
a = local_st_val;
ptr = (int *)malloc(sizeof(int)); //从堆上申请空间
if(ptr != NULL)
{
printf("*p value:%d", *ptr);
free(ptr);
ptr = NULL;
//free后需要将ptr置空,否则会导致后续ptr的校验失效,出现野指针
}
}
C语言的作用域不仅描述了标识符的可访问的区域,其实也规定了变量的存储区域,在文件作用域的变量st_val和ex_val被分配到静态存储区,其中static关键字主要限定变量能否被其它文件访问,而代码块作用域中的变量a, ptr和local_st_val则要根据类型的不同,分配到不同的区域,其中a是局部变量,被分配到栈中,ptr作为指针,由malloc分配空间,因此定义在堆中,而local_st_val则被关键字限定,表示分配到静态存储区,这里就涉及到重要知识点,static在文件作用域和代码块作用域的意义是不同的:在文件作用域用于限定函数和变量的外部链接性(能否被其它文件访问), 在代码块作用域则用于将变量分配到静态存储区。
对于C语言,如果理解上述知识对于内存管理基本就足够。
但对于嵌入式C编程来说,定义一个变量,它不一定在内存,也就是SRAM中,也有可能在FLASH空间,或直接由寄存器存储(register定义变量或者高优化等级下的部分局部变量),如定义为const的全局变量定义在FLASH中,定义为register的局部变量会被优化到直接放在通用寄存器中,在优化运行速度,或者存储受限时,理解这部分知识对于代码的维护就很有意义。
此外,嵌入式C语言的编译器中会扩展内存管理机制,如支持分散加载机制和__attribute__((section("用户定义区域"))),允许指定变量存储在特殊的区域如(SDRAM, SQI FLASH), 这强化了对内存的管理,以适应复杂的应用环境场景和需求。
LD_ROM 0x00800000 0x10000 { ;load region size_region
EX_ROM 0x00800000 0x10000 { ;load address = execution address
(RESET, +First)
*(InRoot$$Sections)
(+RO)
}
EX_RAM 0x20000000 0xC000 { ;rw Data
(+RW +ZI)
}
EX_RAM1 0x2000C000 0x2000 {
.ANY(MySection)
}
EX_RAM2 0x40000000 0x20000{
.ANY(Sdram)
}
}
int a[10] __attribute__((section("Mysection")));
int b[100] __attribute__((section("Sdram")));
采用这种方式,我们就可以将变量指定到需要的区域,这在某些情况下是必须的,如做GUI或者网页时因为要存储大量图片和文档,内部FLASH空间可能不足,这时就可以将变量声明到外部区域,另外内存中某些部分的数据比较重要,为了避免被其它内容覆盖,可能需要单独划分SRAM区域,避免被误修改导致致命性的错误,这些经验在实际的产品开发中是常用且重要,不过因为篇幅原因,这里只简略的提供例子,如果工作中遇到这种需求,建议详细去了解下。
至于堆的使用,对于嵌入式Linux来说,使用起来和标准C语言一致,注意malloc后的检查,释放后记得置空,避免"野指针“,不过对于资源受限的单片机来说,使用malloc的场景一般较少,如果需要频繁申请内存块的场景,都会构建基于静态存储区和内存块分割的一套内存管理机制,一方面效率会更高(用固定大小的块提前分割,在使用时直接查找编号处理),另一方面对于内存块的使用可控,可以有效避免内存碎片的问题,常见的如RTOS和网络LWIP都是采用这种机制,我个人习惯也采用这种方式,所以关于堆的细节不在描述,如果希望了解,可以参考<C Primer Plus>中关于存储相关的说明。
指针和数组
参考如下代码:
int main(void)
{
char cval[] = "hello";
int i;
int ival[] = {1, 2, 3, 4};
int arr_val[][2] = {{1, 2}, {3, 4}};
const char *pconst = "hello";
char *p;
int *pi;
int *pa;
int **par;
p = cval;
p++; //addr增加1
pi = ival;
pi+=1; //addr增加4
pa = arr_val[0];
pa+=1; //addr增加4
par = arr_val;
par++; //addr增加8
for(i=0; i<sizeof(cval); i++)
{
printf("%d ", cval[i]);
}
printf("\n");
printf("pconst:%s\n", pconst);
printf("addr:%d, %d\n", cval, p);
printf("addr:%d, %d\n", icval, pi);
printf("addr:%d, %d\n", arr_val, pa);
printf("addr:%d, %d\n", arr_val, par);
}
/* PC端64位系统下运行结果
0x68 0x65 0x6c 0x6c 0x6f 0x0
pconst:hello
addr:6421994, 6421995
addr:6421968, 6421972
addr:6421936, 6421940
addr:6421936, 6421944 */
对于数组来说,一般从0开始获取值,以length-1作为结束,通过[0, length)半开半闭区间访问,这一般不会出问题,但是某些时候,我们需要倒着读取数组时,有可能错误的将length作为起始点,从而导致访问越界,另外在操作数组时,有时为了节省空间,将访问的下标变量i定义为unsigned char类型,而C语言中unsigned char类型的范围是0~255,如果数组较大,会导致数组超过时无法截止,从而陷入死循环,这种在最初代码构建时很容易避免,但后期如果更改需求,在加大数组后,在使用数组的其它地方都会有隐患,需要特别注意。
指针占有的空间与芯片的寻址宽度有关,32位平台为4字节,64位为8字节,而指针的加减运算中的长度又与它的类型相关,如char类型为1,int类型为4,如果你仔细观察上面的代码就会发现par的值增加了8,这是因为指向指针的指针,对应的变量是指针,也就是长度就是指针类型的长度,在64位平台下为8,如果在32位平台则为4,这些知识理解起来并不困难,但是这些特性在工程运用中稍有不慎,就会埋下不易察觉的问题。另外指针还支持强制转换,这在某些情况下相当有用,参考如下代码:
typedef struct
{
int b;
int a;
}STRUCT_VAL;
static __align(4) char arr[8] = {0x12, 0x23, 0x34, 0x45, 0x56, 0x12, 0x24, 0x53};
int main(void)
{
STRUCT_VAL *pval;
int *ptr;
pval = (STRUCT_VAL *)arr;
ptr = (int *)&arr[4];
printf("val:%d, %d", pval->a, pval->b);
printf("val:%d,", *ptr);
}
//0x45342312 0x53241256
//0x53241256
typedef int (*pfunc)(int, int);
int func_add(int a, int b){
return a+b;
}
int main(void)
{
pfunc *func_ptr;
*(volatile uint32_t *)0x20001000 = 0x01a23131;
func_ptr = func_add;
printf("%d\n", func_ptr(1, 2));
}
这里说明下,volatile易变的,可变的,一般用于以下几种状况:
并行设备的硬件寄存器(如:状态寄存器)
一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)
多线程应用中被几个任务共享的变量
结构类型和对齐
C语言提供自定义数据类型来描述一类具有相同特征点的事务,主要支持的有结构体,枚举和联合体。其中枚举通过别名限制数据的访问,可以让数据更直观,易读,实现如下:
typedef enum {spring=1, summer, autumn, winter }season;
season s1 = summer;
联合体的是能在同一个存储空间里存储不同类型数据的数据类型,对于联合体的占用空间,则是以其中占用空间最大的变量为准,如下:
typedef union{
char c;
short s;
int i;
}UNION_VAL;
UNION_VAL val;
int main(void)
{
printf("addr:0x%x, 0x%x, 0x%x\n",
(int)(&(val.c)), (int)(&(val.s)), (int)(&(val.i)));
val.i = 0x12345678;
if(val.s == 0x5678)
printf("小端模式\n");
else
printf("大端模式\n");
}
/*
addr:0x407970, 0x407970, 0x407970
小端模式
*/
int data = 0x12345678;
short *pdata = (short *)&data;
if(*pdata = 0x5678)
printf("%s\n", "小端模式");
else
printf("%s\n", "大端模式");
可以看出使用联合体在某些情况下可以避免对指针的滥用。结构体则是将具有共通特征的变量组成的集合,比起C++的类来说,它没有安全访问的限制,不支持直接内部带函数,但通过自定义数据类型,函数指针,仍然能够实现很多类似于类的操作,对于大部分嵌入式项目来说,结构化处理数据对于优化整体架构以及后期维护大有便利,下面举例说明:
typedef int (*pfunc)(int, int);
typedef struct{
int num;
int profit;
pfunc get_total;
}STRUCT_VAL;
int GetTotalProfit(int a, int b)
{
return a*b;
}
int main(void){
STRUCT_VAL Val;
STRUCT_VAL *pVal;
Val.get_total = GetTotalProfit;
Val.num = 1;
Val.profit = 10;
printf("Total:%d\n", Val.get_total(Val.num, Val.profit)); //变量访问
pVal = &Val;
printf("Total:%d\n", pVal->get_total(pVal->num, pVal->profit)); //指针访问
}
/*
Total:10
Total:10
*/
C语言的结构体支持指针和变量的方式访问,通过转换可以解析任意内存的数据(如我们之前提到的通过指针强制转换解析协议),另外通过将数据和函数指针打包,在通过指针传递,是实现驱动层实接口切换的重要基础,有着重要的实践意义,另外基于位域,联合体,结构体,可以实现另一种位操作,这对于封装底层硬件寄存器具有重要意义,实践如下:
typedef unsigned char uint8_t;
union reg{
struct{
uint8_t bit0:1;
uint8_t bit1:1;
uint8_t bit2_6:5;
uint8_t bit7:1;
}bit;
uint8_t all;
};
int main(void)
{
union reg RegData;
RegData.all = 0;
RegData.bit.bit0 = 1;
RegData.bit.bit7 = 1;
printf("0x%x\n", RegData.all);
RegData.bit.bit2_6 = 0x3;
printf("0x%x\n", RegData.all);
}
/*
0x81
0x8d
*/
通过联合体和位域操作,可以实现对数据内bit的访问,这在寄存器以及内存受限的平台,提供了简便且直观的处理方式,另外对于结构体的另一个重要知识点就是对齐了,通过对齐访问,可以大幅度提高运行效率,但是因为对齐引入的存储长度问题,也是容易出错的问题,对于对齐的理解,可以分类为如下说明:
基础数据类型:以默认的的长度对齐,如char以1字节对齐,short以2字节对齐等
数组 :按照基本数据类型对齐,第一个对齐了后面的自然也就对齐了
联合体 :按其包含的长度最大的数据类型对齐
结构体:结构体中每个数据类型都要对齐,结构体本身以内部最大数据类型长度对齐
union DATA{
int a;
char b;
};
struct BUFFER0{
union DATA data;
char a;
//reserved[3]
int b;
short s;
//reserved[2]
}; //16字节
struct BUFFER1{
char a;
//reserved[0]
short s;
union DATA data;
int b;
};//12字节
int main(void)
{
struct BUFFER0 buf0;
struct BUFFER1 buf1;
printf("size:%d, %d\n", sizeof(buf0), sizeof(buf1));
printf("addr:0x%x, 0x%x, 0x%x, 0x%x\n",
(int)&(buf0.data), (int)&(buf0.a), (int)&(buf0.b), (int)&(buf0.s));
printf("addr:0x%x, 0x%x, 0x%x, 0x%x\n",
(int)&(buf1.a), (int)&(buf1.s), (int)&(buf1.data), (int)&(buf1.b));
}
/*
size:16, 12
addr:0x61fe10, 0x61fe14, 0x61fe18, 0x61fe1c
addr:0x61fe04, 0x61fe06, 0x61fe08, 0x61fe0c
*/
其中union联合体的大小与内部最大的变量int一致,为4字节,根据读取的值,就知道实际内存布局和填充的位置是一致,事实上学会通过填充来理解C语言的对齐机制,是有效且快捷的方式。
预处理机制
C语言提供了丰富的预处理机制,方便了跨平台的代码的实现,此外C语言通过宏机制实现的数据和代码块替换,字符串格式化,代码段切换,对于工程应用具有重要意义,下面按照功能需求,描述在C语言运用中的常用预处理机制。
#include 包含文件命令,在C语言中,它执行的效果是将包含文件中的所有内容插入到当前位置,这不只包含头文件,一些参数文件,配置文件,也可以使用该文件插入到当前代码的指定位置。其中<>和""分别表示从标准库路径还是用户自定义路径开始检索。
#define宏定义,常见的用法包含定义常量或者代码段别名,当然某些情况下配合##格式化字符串,可以实现接口的统一化处理,实例如下:
printf("error loop\n");\
}while(0);
int global(v) = 10;
int global(add)(int a, int b)
{
return a+b;
}
#if..#elif...#else...#endif, #ifdef..#endif, #ifndef...#endif条件选择判断,条件选择主要用于切换代码块,这种综合性项目和跨平台项目中为了满足多种情况下的需求往往会被使用。
#undef 取消定义的参数,避免重定义问题。
#error,#warning用于用户自定义的告警信息,配合#if,#ifdef使用,可以限制错误的预定义配置。
#pragma 带参数的预定义处理,常见的#pragma pack(1), 不过使用后会导致后续的整个文件都以设置的字节对齐,配合push和pop可以解决这种问题,代码如下:
struct TestA
{
char i;
int b;
}A;
等同于
struct _TestB{
char i;
int b;
}__attribute__((packed))A;
总结嵌入式C语言难点
对于任何嵌入式C编程从业者,清晰的掌握这些基础的知识和必要的。
在嵌入式C编程过程中,需要不断提升的能力与技巧:
C语言支持的内联汇编
通讯间的可靠性实现
存储数据校验和完整性保证
有关异常触发后的查找和解决
溢出,越界
不同硬件平台对齐,数据宽度,大小端问题