C语言指针在嵌入式系统中的常用操作(6个)
在嵌入式系统开发过程中,C语言指针常用的操作有以下 6 种。
如需要访问每个 GPIO 对应的 GPIO_CTL0 寄存器,只需要调用宏 GPIO_CTL0 并填入 GPIOx 定义的地址即可:
如此便封装了硬件寄存器的地址,对于用户而言,读写见名知意的寄存器名,比直接与硬件寄存器地址打交道更为直观和方便,那么上述对硬件地址的封装究竟是如何实现的呢?这需要找到宏 REG32 的定义:
可以看到这个宏首先是将 addr 强转为了 32bit 的无符号数,再转化为 32bit 地址,最后访问地址内容。
上述使用结构体模拟了类定义了一个圆类,类中定义了成员变量和成员函数,则使用此结构体可以实例化一个对象。
如此便模拟了 C++ 中的创建对象的操作,但是还是和 C++ 中实例化对象有很大的区别,结构体中没有构造函数,因此在定义了变量之后,需要手动初始化,为结构体中的变量和函数指针赋初值。
在上述的结构体中定义了函数指针
这是不是就有点类似于 C++ 中多态的表现形式。
上述使用指针来转化反而还不如使用 memcpy 函数来的简单实在,说实话在嵌入式系统中我也不建议新手这样操作,这种指针操作会存在很多未知的风险,大家就当涨涨见识,请勿用于实际工程代码中。
为什么这个指针操作存在很多未知风险?则对它分析便可知:
以下是一个简单的单链表示例代码:
一、 访问硬件寄存器
嵌入式系统中,硬件寄存器通常被映射到特定的内存地址。通过指针可以直接访问这些寄存器。如下所示为某 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_CTL0(GPIOA) = 0xE800CF57;
- #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))
转化过程:数字 -> 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;
- // 计算圆的面积
- 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));
- }
三、通讯过程快速组包与解包
在通讯数据发送组包与接收解包的过程中,由于通讯 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]);
为什么这个指针操作存在很多未知风险?则对它分析便可知:
- 现在嵌入式系统中使用较多的是 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;
- }