查看原文
其他

【硬核】优质 "嵌入式C编程" 必备指南


来源于公众号 : 嵌入式ARM    整理:bug

1、聊一聊

    本文是bug菌为大家整理的好文,C语言其实是非常简洁的语言,语法相比那些高级语言可以说非常小巧了,然而C语言在嵌入式中却有着其独特的魅力,本文为大家展示了C语言在嵌入式中的特色,大家参考学习一下。
   文章中也融入了bug菌的一些观点,仅供大家学习参考!
    同时也欢迎大家在文末留言、分享、讨论!

2、前言

    本文从语法上来说C语言并不复杂, 但编写优质可靠的嵌入式C程序并非易事,不仅需要熟知硬件特性和缺陷,还需要对编译原理和计算机技术知识有着一定的了解。
    本文以嵌入式实践为基础,再结合相关资料, 阐述嵌入式需要了解的C语言知识和重点,希望每个读到这篇文章的人都能有所收获。

3、关键字

关键字是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语法。
(bugPS:平台扩展关键字大家可以到各个平台手册中查找,往往这些扩展能够帮助大家更好的调试程序等,不过也会存在跨平台可移植性问题)

嵌入式的应用从逻辑上可以抽象为三个部分:
1). 数据的输入(如传感器,信号,接口输入),
2). 数据的处理(如协议的解码和封包,AD采样值的转换等)
3). 数据的输出(GUI的显示,输出的引脚状态,DA的输出控制电压,PWM波的占空比等),

    对于数据的管理就贯穿着整个嵌入式应用的开发,它包含数据类型,存储空间管理,位和逻辑操作,以及数据结构,C语言从语法上支撑上述功能的实现,并提供相应的优化机制,以应对嵌入式下更受限的资源环境。

4、数据类型

    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;
    既然不同平台的基本数据宽度不同,那么如何确定当前平台的基础数据类型如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位宽比较简单的方式。
(bugPS:在熟悉一款新的芯片时候,直接把这些参数打印出来,不然这些注意点比较零碎在文档上查找比较麻烦)

5、内存管理与存储架构

    C语言允许程序变量在定义时就确定内存地址,通过作用域,以及关键字extern,static,实现了精细的处理机制,按照在硬件的区域不同,内存分配有三种方式(节选自C++高质量编程):
1). 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
2). 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中 ,效率很高,但是分配的内存容量有限。(bugPS:不过还是存在入栈和出栈的时间消耗)
3). 从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活,但同时遇到问题也最多。(bugPS:这一块也是最容易出问题的,分配了不释放,释放了又访问都是不正确的,也是非常考验程序员的编程素质)
这里先看个简单的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语言的作用域不仅描述了标识符的可访问的区域,其实也规定了变量的存储区域,在文件作用域的变量st_valex_val被分配到静态存储区,其中static关键字主要限定变量能否被其它文件访问,而代码块作用域中的变量a, ptr和local_st_val则要根据类型的不同,分配到不同的区域,其中a是局部变量,被分配到栈中,ptr作为指针,由malloc分配空间,因此定义在堆中,而local_st_val则被关键字限定,表示分配到静态存储区
    这里就涉及到重要知识点,static在文件作用域和代码块作用域的意义是不同的:在文件作用域用于限定函数和变量的外部链接性(能否被其它文件访问), 在代码块作用域则用于将变量分配到静态存储区。(bugPS:这里的代码块一般指的是用大括号括起来的区域,当然文件中的static同样也是分配到静态全局存储区)
    对于C语言,如果理解上述知识对于内存管理基本就足够,但对于嵌入式C来说,定义一个变量,它不一定在内存(SRAM)中,也有可能在FLASH空间,或直接由寄存器存储(register定义变量或者高优化等级下的部分局部变量),如:
   定义为const的全局变量定义在Flash中(BugPS:对于MCU一般都会放在Flash,不过具体还是需要看对应的映射文件,参考:【MCU】一种单片机节省内存的方法(补充);
    定义为register的局部变量会被优化到直接放在通用寄存器中(bugPS:register修饰的仅仅是可能分配到:具体可以参考: 顿悟,神秘的register关键字(C语言篇)),在优化运行速度,或者存储受限时,理解这部分知识对于代码的维护就很有意义。
    此外,嵌入式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>中关于存储相关的说明。

6、指针与数组

    数组和指针往往是引起程序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 0x0pconst:helloaddr:6421994, 6421995addr:6421968, 6421972addr:6421936, 6421940addr: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,这些知识理解起来并不困难,但是这些特性在工程运用中稍有不慎,就会埋下不易察觉的问题。另外指针还支持强制转换,这在某些情况下相当有用,参考如下代码:
#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
    基于指针的强制转换,在协议解析,数据存储管理中高效快捷的解决了数据解析的问题(bugPS:bug菌之前的文章也有提到过,强转结构体直接索引解析,还是比较好用的),但是在处理过程中涉及的数据对齐,大小端,是常见且十分易错的问题,如上面arr字符数组,通过__align(4)强制定义为4字节对齐是必要的,这里可以保证后续转换成int指针访问时,不会触发非对齐访问异常,如果没有强制定义,char默认是1字节对齐的,当然这并不就是一定触发异常(由整个内存的布局决定arr的地址,也与实际使用的空间是否支持非对齐访问有关,如部分SDRAM使用非对齐访问时,会触发异常), 这就导致可能增减其它变量,就可能触发这种异常,而出异常的地方往往和添加的变量毫无关系,而且代码在某些平台运行正常,切换平台后触发异常,这种隐蔽的现象是嵌入式中很难查找解决的问题。
    另外,C语言指针还有特殊的用法就是通过强制转换给特定的物理地址访问,通过函数指针实现回调,如下:

#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));}

这里说明下,volatile易变的,可变的,一般用于以下几种状况:
1)并行设备的硬件寄存器(如:状态寄存器)
2)一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)
3)多线程应用中被几个任务共享的变量
    volatile可以解决用户模式和异常中断访问同一个变量时,出现的不同步问题,另外在访问硬件地址时,volatile也阻止对地址访问的优化,从而确保访问的实际的地址,精通volatile的运用,在嵌入式底层中十分重要,也是嵌入式C从业者的基本要求之一。函数指针在一般嵌入式软件的开发中并不常见,但对许多重要的实现如异步回调,驱动模块,使用函数指针就可以利用简单的方式实现很多应用,当然我这里只能说是抛砖引玉,许多细节知识是值得详细去了解掌握的。

7、结构体类型与对齐

   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的访问,这在寄存器以及内存受限的平台(bugPS:省内存的办法),提供了简便且直观的处理方式,另外对于结构体的另一个重要知识点就是对齐了,通过对齐访问,可以大幅度提高运行效率,但是因为对齐引入的存储长度问题,也是容易出错的问题,对于对齐的理解,可以分类为如下说明。
基础数据类型:以默认的的长度对齐,如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语言的对齐机制,是有效且快捷的方式。

8、预处理机制

   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_##valint 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可以解决这种问题,代码如下:
#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;

9、总结

    如果你看到了这里,那么应该对C语言有了比较清晰的认识,嵌入式C语言在处理硬件物理地址,位操作,内存访问,都给予开发者了充分的自由,通过数组,指针以及强制转换的技巧,可以有效减少数据处理中的复制过程,这对于底层是必要的,也方便了整个架构的开发。
    但是由这种自由带来的非法访问,溢出,越界,以及不同硬件平台对齐,数据宽度,大小端问题,在功能设计人员手里一般还能够处理,对于后续接手项目的人来说,如果本身的设计没有考虑清楚这些问题,往往代表着问题和麻烦,所以对于任何嵌入式C的从业者,清晰的掌握这些基础的知识和必要的。
    讲到这里,关于嵌入式C语言的初步总结就到此为止,但C语言在嵌入式运用的中的重点和难点并不仅仅只有这些,如嵌入式C语言支持的内联汇编,通讯间的可靠性实现,存储数据校验和完整性保证,这些工程上的运用和技巧,都很难用简单的言语说清楚,另外有关异常触发后的查找和解决的技巧,也值得详细的说明,这里因为篇幅以及自己还未整理清晰,就先到此为止。
-END-

免责声明:整理文章为传播相关技术,版权归原作者所有如有侵权,请联系删除,非常感谢!

10、结束语

     好了,这里是公众号:“最后一个bug”,一个为大家打造的技术知识提升基地。

推荐好文  点击蓝色字体即可跳转

【收藏】get这些技巧,HardFault_Handler排查只需要几分钟

【C进阶】这种地方别再强制类型转化了,来告诉你个小技巧!

【MCU】一种单片机节省内存的方法(补充)

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存