从语法上来说C语言并不复杂, 但编写优质可靠的嵌入式C程序并非易事,不仅需要熟知硬件特性和缺陷,还需要对编译原理和计算机技术知识有着一定的了解。
本文以嵌入式实践为基础,再结合相关资料, 阐述嵌入式需要了解的C语言知识和重点,希望每个读到这篇文章的人都能有所收获。
1 关键字
关键字是C语言中具有特殊功能的保留标示符,按照功能可分为
1). 数据类型(常用char, short, int, long, unsigned, float, double)
2). 运算和表达式( =, +, -, *, while, do-while, if, goto, switch-case)
3). 数据存储(auto, static, extern,const, register,volatile,restricted),
4). 结构(struct, enum, union,typedef),
5). 位操作和逻辑运算(<<, >>, &, |, ~,^, &&),
6). 预处理(#define, #include, #error,#if...#elif...#else...#endif等),
7). 平台扩展关键字(__asm, __inline,__syscall)
这些关键字共同构成了嵌入式平台的C语法。
嵌入式的应用从逻辑上可以抽象为三个部分:
1). 数据的输入(如传感器,信号,接口输入),
2). 数据的处理(如协议的解码和封包,AD采样值的转换等)
3). 数据的输出(GUI的显示,输出的引脚状态,DA的输出控制电压,PWM波的占空比等),
对于数据的管理就贯穿着整个嵌入式应用的开发,它包含数据类型,存储空间管理,位和逻辑操作,以及数据结构,C语言从语法上支撑上述功能的实现,并提供相应的优化机制,以应对嵌入式下更受限的资源环境。
2 数据类型
C语言支持常用的字符型,整型,浮点型变量,有些编译器如keil还扩展支持bit(位)和sfr(寄存器)等数据类型来满足特殊的地址操作。C语言只规定了每种基本数据类型的最小取值范围,因此在不同芯片平台上相同类型可能占用不同长度的存储空间,这就需要在代码实现时考虑后续移植的兼容性,而C语言提供的typedef就是用于处理这种情况的关键字,在大部分支持跨平台的软件项目中被采用,典型的如下:
typedef unsigned char uint8_t;typedef unsigned short uint16_t; typedef unsigned int uint32_t; ...... typedef signed int int32_t;
复制代码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));
复制代码3 内存管理和存储架构
C语言允许程序变量在定义时就确定内存地址,通过作用域,以及关键字extern,static,实现了精细的处理机制,按照在硬件的区域不同,内存分配有三种方式(节选自C++高质量编程):
1). 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
2). 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中 ,效率很高,但是分配的内存容量有限。
3). 从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活,但同时遇到问题也最多。
这里先看个简单的C语言实例。
//main.c#include <stdio.h>#include <stdlib.h>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语言,如果理解上述知识对于内存管理基本就足够,但对于嵌入式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 *.o (RESET, +First) *(InRoot$Sections) .ANY (+RO) } EX_RAM 0x20000000 0xC000 { ;rw Data .ANY (+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>中关于存储相关的说明。
4 指针和数组
数组和指针往往是引起程序bug的主要原因,如数组越界,指针越界,非法地址访问,非对齐访问,这些问题背后往往都有指针和数组的影子,因此理解和掌握指针和数组,是成为合格C语言开发者的必经之路。
数组是由相同类型元素构成,当它被声明时,编译器就根据内部元素的特性在内存中分配一段空间,另外C语言也提供多维数组,以应对特殊场景的需求,而指针则是提供使用地址的符号方法,只有指向具体的地址才有意义,C语言的指针具有最大的灵活性,在被访问前,可以指向任何地址,这大大方便了对硬件的操作,但同时也对开发者有了更高的要求。参考如下代码。
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 */
复制代码在前面提到过,指针占有的空间与芯片的寻址宽度有关,32位平台为4字节,64位为8字节,而指针的加减运算中的长度又与它的类型相关,如char类型为1,int类型为4,如果你仔细观察上面的代码就会发现par的值增加了8,这是因为指向指针的指针,对应的变量是指针,也就是长度就是指针类型的长度,在64位平台下为8,如果在32位平台则为4,这些知识理解起来并不困难,但是这些特性在工程运用中稍有不慎,就会埋下不易察觉的问题。另外指针还支持强制转换,这在某些情况下相当有用,参考如下代码:
#include <stdio.h>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
复制代码#include <stdio.h>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)); }
复制代码1)并行设备的硬件寄存器(如:状态寄存器)
2)一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)
3)多线程应用中被几个任务共享的变量
volatile可以解决用户模式和异常中断访问同一个变量时,出现的不同步问题,另外在访问硬件地址时,volatile也阻止对地址访问的优化,从而确保访问的实际的地址,精通volatile的运用,在嵌入式底层中十分重要,也是嵌入式C从业者的基本要求之一。函数指针在一般嵌入式软件的开发中并不常见,但对许多重要的实现如异步回调,驱动模块,使用函数指针就可以利用简单的方式实现很多应用,当然我这里只能说是抛砖引玉,许多细节知识是值得详细去了解掌握的。
5 结构类型和对齐
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 */
复制代码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 */
复制代码基础数据类型:以默认的的长度对齐,如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 */
复制代码6 预处理机制
C语言提供了丰富的预处理机制,方便了跨平台的代码的实现,此外C语言通过宏机制实现的数据和代码块替换,字符串格式化,代码段切换,对于工程应用具有重要意义,下面按照功能需求,描述在C语言运用中的常用预处理机制。
#include 包含文件命令,在C语言中,它执行的效果是将包含文件中的所有内容插入到当前位置,这不只包含头文件,一些参数文件,配置文件,也可以使用该文件插入到当前代码的指定位置。其中<>和""分别表示从标准库路径还是用户自定义路径开始检索。
#define宏定义,常见的用法包含定义常量或者代码段别名,当然某些情况下配合##格式化字符串,可以实现接口的统一化处理,实例如下:
#define MAX_SIZE 10#define MODULE_ON 1 #define ERROR_LOOP() do{\ printf("error loop\n");\ }while(0); #define global(val) g_##val int global(v) = 10; int global(add)(int a, int b) { return a+b; }
复制代码#undef 取消定义的参数,避免重定义问题。
#error,#warning用于用户自定义的告警信息,配合#if,#ifdef使用,可以限制错误的预定义配置。
#pragma 带参数的预定义处理,常见的#pragma pack(1), 不过使用后会导致后续的整个文件都以设置的字节对齐,配合push和pop可以解决这种问题,代码如下:
#pragma pack(push)#pragma pack(1) struct TestA { char i; int b; }A; #pragma pack(pop); //注意要调用pop,否则会导致后续文件都以pack定义值对齐,执行不符合预期 等同于 struct _TestB{ char i; int b; }__attribute__((packed))A;
复制代码如果你看到了这里,那么应该对C语言有了比较清晰的认识,嵌入式C语言在处理硬件物理地址,位操作,内存访问,都给予开发者了充分的自由,通过数组,指针以及强制转换的技巧,可以有效减少数据处理中的复制过程,这对于底层是必要的,也方便了整个架构的开发。但是由这种自由带来的非法访问,溢出,越界,以及不同硬件平台对齐,数据宽度,大小端问题,在功能设计人员手里一般还能够处理,对于后续接手项目的人来说,如果本身的设计没有考虑清楚这些问题,往往代表着问题和麻烦,所以对于任何嵌入式C的从业者,清晰的掌握这些基础的知识和必要的。
讲到这里,关于嵌入式C语言的初步总结就到此为止,但C语言在嵌入式运用的中的重点和难点并不仅仅只有这些,如嵌入式C语言支持的内联汇编,通讯间的可靠性实现,存储数据校验和完整性保证,这些工程上的运用和技巧,都很难用简单的言语说清楚,另外有关异常触发后的查找和解决的技巧,也值得详细的说明,这里因为篇幅以及自己还未整理清晰,就先到此为止。
转自网络