C语言指针在嵌入式系统中的常用操作(6个)
在嵌入式系统开发过程中,C语言指针常用的操作有以下 6 种。
为什么这个指针操作存在很多未知风险?则对它分析便可知:
以下是一个简单的单链表示例代码:
一、 访问硬件寄存器
嵌入式系统中,硬件寄存器通常被映射到特定的内存地址。通过指针可以直接访问这些寄存器。如下所示为某 32 芯片定义的寄存器:/* GPIOx(x=A,B,C,D,E,F,G) definitions */ #define GPIOA (GPIO_BASE + 0x00000000U) #define GPIOB (GPIO_BASE + 0x00000400U) #define GPIOC (GPIO_BASE + 0x00000800U) #define GPIOD (GPIO_BASE + 0x00000C00U) #define GPIOE (GPIO_BASE + 0x00001000U) #define GPIOF (GPIO_BASE + 0x00001400U) #define GPIOG (GPIO_BASE + 0x00001800U) /* AFIO definitions */ #define AFIO AFIO_BASE /* registers definitions */ /* GPIO registers definitions */ #define GPIO_CTL0(gpiox) REG32((gpiox) + 0x00U) /*!< GPIO port control register 0 */ #define GPIO_CTL1(gpiox) REG32((gpiox) + 0x04U) /*!< GPIO port control register 1 */ #define GPIO_ISTAT(gpiox) REG32((gpiox) + 0x08U) /*!< GPIO port input status register */ #define GPIO_OCTL(gpiox) REG32((gpiox) + 0x0CU) /*!< GPIO port output control register */ #define GPIO_BOP(gpiox) REG32((gpiox) + 0x10U) /*!< GPIO port bit operation register */ #define GPIO_BC(gpiox) REG32((gpiox) + 0x14U) /*!< GPIO bit clear register */ #define GPIO_LOCK(gpiox) REG32((gpiox) + 0x18U) /*!< GPIO port configuration lock register */ #define GPIOx_SPD(gpiox) REG32((gpiox) + 0x3CU) /*!< GPIO port bit speed register */如需要访问每个 GPIO 对应的 GPIO_CTL0 寄存器,只需要调用宏 GPIO_CTL0 并填入 GPIOx 定义的地址即可:
GPIO_CTL0(GPIOA) = 0xE800CF57;如此便封装了硬件寄存器的地址,对于用户而言,读写见名知意的寄存器名,比直接与硬件寄存器地址打交道更为直观和方便,那么上述对硬件地址的封装究竟是如何实现的呢?这需要找到宏 REG32 的定义:
#define REG32(addr) (*(volatile uint32_t *)(uint32_t)(addr)) #define REG16(addr) (*(volatile uint16_t *)(uint32_t)(addr)) #define REG8(addr) (*(volatile uint8_t *)(uint32_t)(addr))可以看到这个宏首先是将 addr 强转为了 32bit 的无符号数,再转化为 32bit 地址,最后访问地址内容。
转化过程:数字 -> 32bit数字 -> 32bit地址 -> 访问地址内容
转化操作:addr -> 32bit无符号数:(uint32_t)(addr) -> 32bit地址:(uint32_t*)(uint32_t)(addr) -> 访问地址内容:*((uint32_t*)(uint32_t)(addr))
- 在嵌入式系统中,经常需要直接访问硬件寄存器,但硬件寄存器的值可能会被硬件电路随时修改,但编译器无法感知这种变化。使用 volatile 关键字可以确保每次访问该寄存器时,都会从实际的硬件地址读取数据,而不是使用之前缓存的值。
二、模拟面向对象操作
在 C++ 或者 python 中面向对象是通过定义类(class)来实现的,然后创建对象来实现的,一个类中包含了最基本的成员变量和成员函数,那么C语言中要定义一个类则是通过定义结构体来模拟实现的,结构体中的变量则是模拟的类中的成员变量,结构体中的函数指针则是模拟类中的成员函数,如下所示:typedef struct { int point_x; //圆心坐标x int point_y; //圆心坐标y double radius; // 圆的半径 double (*func)(sCircle_t*); // 函数指针,用于对圆进行相关的计算操作 }sCircle_t上述使用结构体模拟了类定义了一个圆类,类中定义了成员变量和成员函数,则使用此结构体可以实例化一个对象。
sCircle_t sCircle;如此便模拟了 C++ 中的创建对象的操作,但是还是和 C++ 中实例化对象有很大的区别,结构体中没有构造函数,因此在定义了变量之后,需要手动初始化,为结构体中的变量和函数指针赋初值。
// 计算圆的面积 double circle_area(sCircle_t* c) { return 3.14159 * c->radius * c->radius; } // 创建对象时初始化 sCircle_t sCircle = { .point_x = 0, .point_y = 0, .radius = 5.5, .func = circle_area, }; //或者在系统跑起来的时候调用一次初始化 void Circle_init(sCircle_t* c) { c->point_x = 0, c->point_y = 0, c->radius = 5.5, c->func = circle_area, }在上述的结构体中定义了函数指针
double (func)(sCircle_t);
,但是并没有指明这个函数指针指向的函数的功能作用是什么,那么就意味着,只要在使用之前改变了函数指针指向的函数,那这个函数指针就可以实现不同的函数功能:
// 计算圆的面积 double circle_area(sCircle_t* c) { return 3.14159 * c->radius * c->radius; } // 计算圆的面积 double circle_circum(sCircle_t* c) { return 2 * 3.14159 * c->radius; } //或者在系统跑起来的时候调用一次初始化 int main(void) { sCircle_t sCircle; sCircle.radius = 5; //函数指针指向的计算圆面积的函数 则实现圆面积计算 sCircle.func = circle_area; printf("圆面积:%lf\n",sCircle.func(&sCircle)); //函数指针指向的计算圆周长的函数 则实现圆周长计算 sCircle.func = circle_circum; printf("圆周长:%lf\n",sCircle.func(&sCircle)); }这是不是就有点类似于 C++ 中多态的表现形式。
三、通讯过程快速组包与解包
在通讯数据发送组包与接收解包的过程中,由于通讯 UART、SPI、I2C、CAN 等驱动底层收发数据一般是 8bit 或者 16bit 宽度(以 8bit 宽度为例),也就意味着不管你发送的是什么数据(结构体,uint32_t 类型的数组或者变量),最终到通讯驱动层都是 8bit 宽度的数组。一般的组包或者解包操作都是通过 memcpy 函数来转化的://定义一个需要发送的数据结构体 typedef struct { uint32_t a; uint16_t b; uint8_t c[2]; }sObj_t; //定义一个需要发送的数据对象 sObj_t sSendObj; //定义一个通讯发送数据的8bit宽度数组 uint8_t ui8buf[100] = {0}; //常规转化操作 memcpy(ui8buf,sSendObj,sizeof(sSendObj)); 进阶指针转化: //组包时直接对地址进行赋值 *((uint32_t*)&ui8buf[0]) = sSendObj.a; *((uint16_t*)&ui8buf[4]) = sSendObj.b; //解包时直接解析地址对应的位宽数据 sSendObj.a = *((uint32_t*)&ui8buf[0]); sSendObj.b = *((uint16_t*)&ui8buf[4]);上述使用指针来转化反而还不如使用 memcpy 函数来的简单实在,说实话在嵌入式系统中我也不建议新手这样操作,这种指针操作会存在很多未知的风险,大家就当涨涨见识,请勿用于实际工程代码中。
为什么这个指针操作存在很多未知风险?则对它分析便可知:
- 现在嵌入式系统中使用较多的是 32bit 芯片,但是也不乏还有 16bit 和 8bit 的芯片,将地址强转为 32bit 地址并对地址内容进行赋值时会出错,16bit 宽度最大寻址位宽为 16bit,若寻址 32bit 则会出错。
-
有数据越界风险,如上数组 ui8buf 大小为 100 字节,若进行操作
((uint32_t)&ui8buf[98])=20;
则访问必定越界。 - 这样的操作在不同的芯片之间不好移植。
- 需要知道转化的数据位宽是多少和在数组中的偏移起始地址。
四、注册中断、回调函数
在很多库或者操作系统甚至上位机中,都会有事先准备好一个函数,待事件响应时由系统调用这个函数来进行事件响应的机制,这个函数就是回调函数。- FreeRTOS 中的任务便是事先注册好的,待调度器查询到该此任务运行时,调用指向此任务的指针执行此任务。
- QT 中控件的槽函数也是事先绑定好的,待控件动作时,调用指向此槽函数的函数指针来响应。
- 嵌入式系统中中断函数也是如此,每个芯片都会有一个中断向量表,里面存放着指向每个中断函数的函数指针,当中断发生时,通过中断向量表中的函数指针调用对应的中断函数。
- 系统初始化时 -> 在中断向量表中注册中断函数
- 事件响应时 -> 中断向量表(存放着指向中断函数的函数指针) ->调用中断函数
五、 管理申请的空间
在一些资源相对丰富的嵌入式系统中,会涉及到动态内存的分配和释放,这部分空间则是通过指针来进行管理的。#include <stdlib.h> //申请空间 char* str = (char*)malloc(10 * sizeof(char)); //释放空间 free(str);
六、数据结构操作等
嵌入式系统中使用各种数据结构(如链表、栈、队列等)来组织和管理数据,指针是实现这些数据结构的基础。以下是一个简单的单链表示例代码:
#include <stdio.h> #include <stdlib.h> // 定义链表节点结构体 typedef struct Node { int data; struct Node* next; } Node; // 创建新节点 Node* createNode(int data) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; newNode->next = NULL; return newNode; } // 在链表头部插入节点 void insertAtHead(Node** head, int data) { Node* newNode = createNode(data); newNode->next = *head; *head = newNode; } // 打印链表 void printList(Node* head) { Node* current = head; while (current != NULL) { printf("%d ", current->data); current = current->next; } printf("\n"); } int main() { Node* head = NULL; insertAtHead(&head, 3); insertAtHead(&head, 2); insertAtHead(&head, 1); printList(head); return 0; }