找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3721

积分

0

好友

519

主题
发表于 5 天前 | 查看: 13| 回复: 0

导读:如何入门嵌入式开发?相信很多人的回答都绕不开“学好C语言”。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语言标准只规定了每种基本数据类型的最小取值范围,因此在不同芯片平台上,相同类型可能占用不同长度的存储空间。这就要求我们在编写代码时必须考虑后续移植的兼容性问题。而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位宽的方法。

3 内存管理和存储架构

C语言允许程序变量在定义时就确定内存地址,并通过作用域以及externstatic等关键字实现了精细的内存管理。根据变量在硬件中的存储区域不同,内存分配主要有三种方式:

  1. 从静态存储区域分配:内存在程序编译时就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量和static变量。
  2. 在栈上创建:在执行函数时,函数内的局部变量在栈上创建,函数执行结束时这些存储单元自动释放。栈内存分配运算内置于处理器的指令集中,效率很高,但分配容量有限。
  3. 从堆上分配(动态内存分配):程序在运行时用mallocnew申请任意大小的内存,程序员自己负责在何时用freedelete释放。动态内存的生存期由程序员决定,使用灵活,但也最容易出现问题。

来看一个简单的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关键字主要限定变量能否被其他文件访问。

代码块作用域中的变量aptrlocal_st_val则根据类型不同,分配到不同区域:a是局部变量,在栈中;ptr指向由malloc在堆中分配的空间;local_st_valstatic关键字限定,分配到静态存储区。这里涉及一个关键点:static在文件作用域和代码块作用域的意义不同。在文件作用域,它用于限定函数和变量的外部链接性(能否被其他文件访问);在代码块作用域,则用于将变量分配到静态存储区

对于标准的C语言,理解上述知识基本足够。但在嵌入式C中,定义一个变量不一定在内存(SRAM)中,它也可能存储在FLASH空间,甚至直接由寄存器存储。例如,定义为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)都采用这种机制。

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", ival, 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类型。而unsigned char的范围是0~255,如果数组较大,循环可能无法正常终止,从而陷入死循环。这类问题在最初编码时容易避免,但后期若需求变更导致数组扩容,之前使用该数组的其他地方都可能埋下隐患,需要特别留意。

如前所述,指针占用的空间与芯片寻址宽度有关。指针的加减运算步长则与其指向的数据类型相关,例如char*步长为1,int*步长为4。仔细观察上述代码会发现par的值增加了8,这是因为par是指向指针的指针,其步长是指针类型的长度,在64位平台下为8,32位平台下则为4。这些特性理解起来不难,但在工程应用中稍有不慎,就会埋下难以察觉的Bug。

此外,指针支持强制转换,这在某些情况下非常有用:

#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

基于指针的强制转换,在协议解析、数据存储管理等场景中能高效、快捷地处理数据。但其中涉及的数据对齐和大小端问题,既常见又极易出错。例如上面的字符数组arr,通过__align(4)强制定义为4字节对齐是必要的,这能保证后续转换成int指针访问时不会触发非对齐访问异常。如果没有强制对齐(char默认1字节对齐),是否会触发异常取决于实际的内存布局和硬件是否支持非对齐访问。这可能导致增减其他无关变量就会触发异常,而出错的地方与新增的变量毫无关联,甚至在某个平台运行正常,换一个平台就出问题。这种隐蔽的现象是嵌入式开发中极难排查的。

另外,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)一个中断服务子程序中会访问到的非自动变量
3)多线程应用中被几个任务共享的变量
volatile可以解决用户模式代码和异常中断访问同一变量时的同步问题。此外,在访问硬件地址时,volatile也能阻止编译器对地址访问的优化,确保每次都是对实际地址的访问。精通volatile的运用,是嵌入式底层开发的基本要求之一。

函数指针在一般嵌入式软件开发中并不十分常见,但在实现异步回调、驱动模块化等场景时,它能以简洁的方式实现复杂功能。这里只是抛砖引玉,许多细节值得深入学习和掌握。

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
*/

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
*/

通过联合体和位域,我们可以直接对数据的特定位进行操作,这在寄存器操作以及内存受限的平台中,提供了一种简便且直观的处理方式。

结构体的另一个重要知识点是内存对齐。通过对齐访问,可以大幅提高运行效率。但由对齐引入的存储空间占用问题,也容易导致错误。对齐规则可以总结如下:

  • 基础数据类型:以其自身长度对齐(如char按1字节,short按2字节)。
  • 数组:按照其元素的基本数据类型对齐,第一个元素对齐了,后面的自然对齐。
  • 联合体:按其包含的长度最大的数据类型对齐。
  • 结构体:结构体中每个成员都要按其自身类型对齐;结构体本身则以其所有成员中最大的对齐要求来对齐。

看一个具体例子:

union DATA
{
    int a;
    char b;
};
struct BUFFER0
{
    union DATA data; //4字节
    char a;          //1字节, 编译器通常插入3字节填充(reserved[3])以使后面的int对齐
    int b;           //4字节
    short s;         //2字节, 编译器通常插入2字节填充(reserved[2])以使结构体总大小对齐
}; //总大小: 4 + (1+3) + 4 + (2+2) = 16字节

struct BUFFER1
{
    char a;          //1字节
    short s;         //2字节, 编译器通常在a后插入1字节填充以使s对齐
    union DATA data; //4字节
    int b;           //4字节, 已自然对齐
}; //总大小: (1+1) + 2 + 4 + 4 = 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 DATA的大小与其内部最大的变量int一致,为4字节。根据打印出的地址,可以清楚地看到实际的内存布局和编译器自动填充的位置。理解通过“填充”来分析C语言的对齐机制,是一种有效且快捷的方法。

6 预处理机制

C语言提供了丰富的预处理机制,极大方便了跨平台代码的编写。通过宏实现的数据和代码块替换、字符串格式化、代码段切换等,在工程实践中具有重要意义。下面按照功能分类,简述常用的预处理机制。

#include 文件包含命令。它的作用是将指定文件中的所有内容插入到当前指令的位置。这不仅可以包含头文件,参数文件、配置文件等也可以使用这种方式插入。<>""分别表示从标准库路径还是用户自定义路径开始搜索。

#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 g_v = 10;
int global(add)(int a, int b) // 展开为 int g_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)用于设置字节对齐。但需要注意,使用它通常会影响后续所有结构体的对齐方式。配合pushpop可以解决这个问题:

#pragma pack(push) // 保存当前对齐设置
#pragma pack(1)    // 设置为1字节对齐
struct TestA
{
    char i;
    int b;
}A;
#pragma pack(pop); // 恢复之前保存的对齐设置
// 如果不使用push/pop,后续所有结构体都将以1字节对齐,这可能不符合预期。

// 上述代码的功能等价于使用GCC风格的属性:
struct _TestB
{
    char i;
    int b;
} __attribute__((packed)) A;

7 总结

如果你读到了这里,应该对嵌入式C语言的核心概念有了更清晰的认识。嵌入式C语言在处理硬件物理地址、位操作、内存访问等方面,赋予了开发者极大的自由度。通过数组、指针以及强制类型转换等技巧,可以有效减少数据处理过程中的冗余拷贝,这对底层开发是必要的,也方便了整个系统架构的设计。

然而,这种自由也带来了非法访问、溢出、越界等风险,以及跨平台时的对齐、数据宽度、大小端等问题。对于经验丰富的设计者,这些问题或许尚在掌控之中;但对于后续的维护者而言,如果前期设计考虑不周,往往就意味着无尽的调试和麻烦。因此,对于任何嵌入式C开发者来说,清晰地掌握这些基础知识是必不可少的。

关于嵌入式C语言的初步总结就到此为止。但C语言在嵌入式应用中的重点和难点远不止这些,例如内联汇编、通信可靠性实现、存储数据的校验与完整性保证等工程实践技巧,都难以用简短的篇幅说清。此外,异常触发后的查找与解决技巧也值得详细探讨。限于篇幅以及个人知识体系的整理进度,本文暂且告一段落。希望这篇文章能为你构建坚实的知识框架,更多深入的内容,欢迎在云栈社区C/C++计算机基础等板块中继续探索与交流。

单片机嵌入式资料合集电子书封面拼贴图

资料合集部分电子书籍截图

单片机嵌入式资料合集课件PPT内容截图

资料合集部分课件PPT截图




上一篇:嵌入式系统设计:19种5V与3.3V电平转换电路方案全解析
下一篇:Java岗多但难进?我来聊聊真实招聘市场的供需错配与.NET的机遇
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 10:26 , Processed in 0.687447 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表