文章目录
前言
笔者在学习时发觉自己的C语言很久没有系统性重温一遍了,本期主要是对于嵌入式中常用的C语言语法进行一个汇总。本次内容对于着急刷嵌入式八股文的同学也有一定帮助,详细可以去看:
https://www.bilibili.com/video/BV1VM4y137Pm?p=3&vd_source=937f3264dce76f586bc0a69ee24dfafa
本系列会不定期持续更新(随笔者的学习进度),大家可以收藏一下时不时看一下。
一、指针和变量
变量和指针是编程语言中的两个核心概念,它们在程序设计和执行中各自扮演着重要的角色:
- 变量是编程语言中用于存储数据的标识符,它代表了一个可以存储数据值或引用(即内存地址)的内存位置。变量具有一个唯一的名称(即变量名),它允许程序在需要时访问和操作存储在该内存位置的数据。变量的值可以在程序执行过程中改变,这使得变量成为跟踪和操作程序数据的关键工具。
- 指针(若为32位机,默认在内存占4字节)则是编程语言中的一个特殊类型的变量,它存储的是另一个变量的内存地址,而不是直接的数据值。指针提供了一种间接访问和操作内存的方式,通过指针,程序可以获取和操作指定内存地址的数据。指针在编程中有很多应用,例如动态内存分配、数据结构操作以及函数参数传递等。
变量和指针在编程中密切相关,但又有明显的区别。变量直接存储数据值,而指针存储的是指向数据值的内存地址。在使用上,变量可以直接通过其名称来访问和操作存储的数据,而指针则需要通过解引用操作(如使用星号*操作符)来获取指向的数据。
通过指针赋值
在进行一个赋值操作时,其流程为“CPU从flash上读取i=123指令→CPU执行指令(从指令中解析得到i的地址和要输入的数据,再输入)”,这一个过程中实际上隐藏的对地址的操作,也可以通过指针复现这个过程,这二者是等价的。例如:
//1.直接写入
int i;
i=123;
//2.通过地址操作写入
int i;
int *p;
p = & i;
*p = 123;
指针函数和函数指针的区别—指针函数是返回指针的函数,而函数指针是指向函数的指针
- 指针函数:实际上是一个返回指针的函数,这个指针可以是指向任何数据类型的指针,包括整型、浮点型、结构体等,指针函数的声明形式通常如下:
type* function_name(parameters);
//这里的 type* 表示函数返回的是一个指向 type 类型数据的指针。
- 函数指针:是指向函数的指针,其函数名本身就是一个指向该函数的指针(其实这里不能称为函数名,应该叫做指针的变量名),可以通过它来调用函数。函数指针的声明形式通常如下:
return_type (*function_pointer_name)(parameters);
//这里的 return_type 是函数返回的类型,function_pointer_name 是函数指针的名字,parameters 是函数的参数列表。
补充1:指针和引用的区别
- 引用是已存在变量的别名,没有自己的存储空间;
- 引用不能为空,需要初始化时就指定一个有效的对象;
- 指针可以随意更改指向,而引用不可以,其始终指向一个对象
补充2:指针和数组的区别
数组是一块连续的内存空间,其大小在编译时确定,可以用下标访问其元素;指针大小固定,仅保存地址值并且可以指向不同的数据类型。
补充3:野指针
野指针是指指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。野指针产生的原因主要有以下几种:
指针变量未初始化、指针释放后未置空、指针操作超越变量作用域、指针的越界、指针指向的空间释放。
二、关键字
1. volatile
volatile关键字可以用于告诉编译器不要对变量进行优化,即不要将变量缓存在寄存器中,应该直接从内存中读取或写入变量。这在多线程访问共享变量和中断处理函数中的变量时特别有用,能确保数据的正确性和实时性。
代码如下(示例):
int i;
i=0;
i=1;
此时i=0可以省略,为了进一步优化速度,通常这部分内容会放到CPU中,从而使得读取不通过内存,此时如果我们想使其每次都必须从内存读取防止数据被篡改就可以加该关键字。通常情况下,在访问硬件寄存器时需要使用该关键字。
2. const
简单来说,const关键字就是表明所有人都不能改变此变量,常用于设置常量,例如圆周率等。
补充:const和define的区别
- define是预处理指令,在预处理阶段执行,const在编译阶段执行;
- define没有类型检查,仅文本替换,不分配内存,而const是存于数据段中;
- define用于创建符号常量,const常创建具有常量值的变值(例如:重力加速度g,声速v等)
3. static
在大型项目中,我们常常会出现变量名命名重复的问题,此时可以增加static关键字表示该定义只能在该文件下使用,不可传递到其他目录下。
// file1.c
static int globalVar = 10;
// file2.c
extern int globalVar; // 这会导致链接错误,因为 globalVar 在 file1.c 中是静态的
拓展用法
- 静态局部变量:
当 static 用于函数内部的局部变量时,该变量的生命周期会延长至整个程序的执行期间,而不是仅仅在函数被调用时存在。此外,该变量只会被初始化一次,即使函数被多次调用。
void func() {
static int count = 0;
count++;
printf("%d\n", count);
}
- 静态函数:
静态函数只能在定义它的文件中被调用。这有助于隐藏实现细节,并减少与其他文件的命名冲突。
// file1.c
static void staticFunction() {
// ... some code ...
}
// file2.c
extern void staticFunction(); // 这会导致链接错误,因为 staticFunction 在 file1.c 中是静态的
4. extern
在对同一变量名进行引用时,常常会出现重复定义的情况,此时便可以在一个文件中正常定义,而在其他文件加入extern关键字进行定义,告诉cpu在处理这部分内容时,变量被定义在别处。例如:
//子文件
int temp;
i=1;
//主函数
extern int temp;
三、数据结构
1. 结构体
1.1 结构体基本内容
结构体(struct)是编程语言中用于组合不同类型的数据到一个单独的数据类型的一种构造。结构体允许你创建复合数据类型,这些数据类型由多个基本数据类型(如整数、浮点数、字符等)或其他结构体组合而成。通过使用结构体,你可以将相关的数据元素组合在一起,形成一个有意义的整体,从而更方便地管理和操作这些数据。结构体通常具有如下特性:
- 组成元素:结构体可以包含多个不同类型的成员(或称为字段),这些成员可以是基本数据类型、其他结构体类型、指针类型等。
- 命名:每个结构体都有一个唯一的名称,用于在代码中引用该结构体类型。
- 定义与初始化:在使用结构体之前,需要先进行定义,即声明结构体的名称以及它所包含的成员类型和名称。之后,可以创建结构体的实例(即变量),并为其成员赋值。
- 访问成员:通过结构体变量和点运算符(在C/C++中)或箭头运算符(在通过指针访问结构体成员时),可以访问和修改结构体的成员。
#include <stdio.h>
// 定义一个结构体类型
struct Student {
char name[50];
int age;
float score;
};
int main() {
// 创建一个结构体的实例(变量)
struct Student student1 = {"张三",20,90.5f};
// 访问结构体的成员并打印
printf("姓名: %s\n", student1.name);
printf("年龄: %d\n", student1.age);
printf("分数: %.1f\n", student1.score);
return 0;
}
struct只有在实例化后才会分配空间,这里在32的硬件配置时经常使用(例如GPIOX),感兴趣可以打开了解一下。
补充:类(class)和结构体的主要区别
- 结构体的访问权限默认为public,而类为private;
- 结构体继承方式为公有继承,类为私有;
- 一般情况下, 结构体用于表示数据结构,而类更多表示为具有行为和数据的对象。
1.2 通过指针对结构体赋值
在项目中,我们同样可以利用指针实现结构体赋值,这种方式的好处在于具有更高的灵活性和安全性(大项目中,如果使用全局变量进行数据传输可能导致数据跑飞)。具体实现如下所示:
#include <stdio.h>
#include <string.h>
// 定义结构体类型
struct Person {
char name[50];
int age;
};
int main() {
// 创建两个结构体的实例
struct Person person1 = {"Alice", 30};
struct Person person2;
// 创建指向结构体类型的指针,并让它指向person1
struct Person *ptr = &person1;
// 通过指针访问结构体的成员
printf("Name: %s, Age: %d\n", ptr->name, ptr->age);
// 通过指针对结构体进行赋值
// 将person1的值赋给person2
person2 = *ptr; // 解引用指针,获取其指向的结构体的值,并赋给person2
// 直接访问person2的成员,验证赋值是否成功
printf("person2 Name: %s, Age: %d\n", person2.name, person2.age);
// 也可以通过指针直接修改结构体的成员
strcpy(ptr->name, "Bob");
ptr->age = 25;
// 再次输出修改后的值
printf("Modified Name: %s, Age: %d\n", ptr->name, ptr->age);
return 0;
}
- struct Person *ptr = &person1; 创建了一个指向 person1 的结构体指针 ptr。
- printf(“Name through pointer: %s\n”, ptr->name); 通过指针 ptr 访问 person1的name 成员。
- person2 = *ptr; 通过解引用指针 ptr(即 *ptr),获取 person1 的值,并将其赋给person2。
通过指针给结构体赋值则是使用这个地址来获取结构体的值,并将这个值赋给另一个结构体变量。
1.3 结构体指针
在C语言中,结构体指针是一个特殊的指针变量,它指向一个结构体类型的变量或内存区域。通过使用结构体指针,你可以间接地访问和操作结构体的成员。这里我们对上例进行修改:
#include <stdio.h>
#include <string.h>
// 定义结构体类型
struct Person {
char name[50];
int age;
};
int main() {
// 创建两个结构体的实例
struct Person person1 = {"Alice", 30};
struct Person person2;
// 创建指向结构体类型的指针,并让它指向person1
struct Person *ptr = &person1;
// 通过指针访问person1的成员
printf("Name through pointer: %s\n", ptr->name);
printf("Age through pointer: %d\n", ptr->age);
// 通过结构体指针给结构体赋值
// 将ptr指向的结构体(即person1)的值赋给person2
memcpy(&person2, ptr, sizeof(struct Person)); // 使用memcpy通过指针复制结构体内容
// 直接访问person2的成员,验证赋值是否成功
printf("person2 Name: %s\n", person2.name);
printf("person2 Age: %d\n", person2.age);
return 0;
}
在这个修改后的例子中,我使用了 memcpy 函数来通过结构体指针 ptr 复制 person1 的内容到 person2。memcpy 函数从 ptr 指向的地址开始,复制 sizeof(struct Person) 字节到 &person2 的地址。这样就实现了通过结构体指针给另一个结构体赋值的效果。此外,结构体指针最主要的功能在于你可以通过“ptr->XXX = ?”的形式来实现对某设定好的结构体的成员的改动,而普通的结构体变量则做不到这一点。
注:这时候你可能会感觉到很懵,这不是一样的吗,实际上结构体指针和通过指针给结构体赋值不是同一个意思,但它们之间有关联。结构体指针是一个变量,它存储了一个结构体的内存地址。通过这个地址,你可以间接地访问和修改结构体的成员。而通过指针给结构体赋值,是指使用结构体指针来将一个结构体的值赋给另一个结构体。这通常涉及到解引用指针来获取其指向的结构体的值,然后将这个值赋给另一个结构体变量。
补充:结构体中的成员也可以是函数指针,在使用时的功能和正常函数一样,赋值时和其他成员一样,例如:
#include <stdio.h>
// 定义两个简单的函数
void print_hello() {
printf("Hello, World!\n");
}
void print_goodbye() {
printf("Goodbye, World!\n");
}
// 定义结构体,其中包含一个函数指针成员
typedef struct {
void (*print_func)(); // 函数指针成员,指向无参数无返回值的函数
} FunctionStruct;
int main() {
// 创建两个结构体实例,并分别初始化它们的函数指针成员
FunctionStruct fs_hello = {print_hello};
FunctionStruct fs_goodbye = {print_goodbye};
// 通过结构体中的函数指针调用函数
fs_hello.print_func(); // 输出: Hello, World!
fs_goodbye.print_func(); // 输出: Goodbye, World!
return 0;
}
2. 联合体
联合体,或称共用体,是一种特殊的数据类型,允许在相同的内存位置存储不同的数据类型。但需要注意的是,联合体在同一时刻只能存储一个成员的值,因为所有成员都共享相同的内存空间。这意味着当改变联合体中一个成员的值时,其他成员的值可能会被覆盖或改变。联合体常用于节省内存空间,或者在某些需要灵活处理不同数据类型的情况下使用。
#include <stdio.h>
union Data {
int i;
float f;
char str[20];
};
int main() {
union Data data;
data.i = 10;
printf("data.i : %d\n", data.i);
data.f = 220.5;
printf("data.f : %f\n", data.f);
strcpy(data.str, "Hello");
printf("data.str : %s\n", data.str);
return 0;
}
补充:联合体和结构体的主要区别
联合体允许在同一内存位置存储不同的数据类型,但同一时刻只能存储一个成员的值;而结构体则将不同类型的数据组合在一起,每个成员都有自己的内存空间,并且可以同时存储所有成员的值。这两种数据结构各有其适用的场景,具体使用哪种取决于特定的需求和目标。
3. 链表
3.1 基本概念
链表(Linked List)是一种常见的数据结构,它由一系列节点(Node)组成,每个节点包含两个部分:数据域和指针域。数据域用于存储数据元素,指针域则用于指向链表中的下一个节点。通过指针的连接,链表可以动态地存储数据,并且可以根据需要进行扩展或缩减。链表有多种类型,最常见的有单向链表、双向链表和循环链表。
-
单向链表:每个节点包含一个数据元素和一个指向下一个节点的指针。第一个节点(头节点)的指针指向链表中的第一个数据节点,最后一个节点的指针通常指向 NULL 表示链表的结束。只能从头节点开始顺序访问链表中的元素。
-
双向链表:每个节点除了包含一个数据元素外,还包含两个指针:一个指向前一个节点,一个指向下一个节点。双向链表可以从任意节点向前或向后遍历,第一个节点的前指针通常指向 NULL,最后一个节点的后指针也指向 NULL。
-
循环链表:循环链表与单向链表类似,但最后一个节点的指针指向头节点,形成一个环。循环链表可以循环遍历整个链表。
接下来是一个简单的单向链表节点定义:
typedef struct Node {
int data; // 数据域
struct Node* next; // 指针域,指向下一个节点
} Node;
3.2 链表的插入和删除
一般在插入时,我们选择传递指针(节省内存资源),示例如下:
#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));
if (!newNode) {
printf("Memory allocation failed.\n");
exit(1);
}
newNode->data = data;
newNode->next = NULL;
return newNode;
}
// 在链表末尾插入节点
void insertNode(Node** head, int data) {
Node* newNode = createNode(data);
if (*head == NULL) {
*head = newNode;
} else {
Node* current = *head;
while (current->next != NULL) {
current = current->next;
}
current->next = 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; // 初始化空链表
// 插入节点
insertNode(&head, 1);
insertNode(&head, 2);
insertNode(&head, 3);
// 打印链表
printList(head); // 输出: 1 2 3
// 释放链表内存(这里省略了释放内存的代码)
return 0;
}
#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));
if (!newNode) {
printf("Memory allocation failed.\n");
exit(1);
}
newNode->data = data;
newNode->next = NULL;
return newNode;
}
// 删除指定值的节点
void deleteNode(Node** head, int value) {
// 处理头节点的情况
if (*head != NULL && (*head)->data == value) {
Node* temp = *head;
*head = (*head)->next;
free(temp);
return;
}
// 处理链表其他节点的情况
Node* current = *head;
while (current->next != NULL) {
if (current->next->data == value) {
Node* temp = current->next;
current->next = current->next->next;
free(temp);
return;
}
current = current->next;
}
// 如果没有找到要删除的节点
printf("Node with value %d not found in the list.\n", value);
}
// 遍历链表并打印节点数据
void printList(Node* head) {
Node* current = head;
while (current != NULL) {
printf("%d ", current->data);
current = current->next;
}
printf("\n");
}
// 主函数
int main() {
Node* head = NULL; // 初始化空链表
// 插入节点
head = createNode(1);
insertNode(&head, 2);
insertNode(&head, 3);
insertNode(&head, 4);
insertNode(&head, 5);
// 打印原始链表
printf("Original list: ");
printList(head);
// 删除节点
int valueToDelete = 3;
deleteNode(&head, valueToDelete);
// 打印删除节点后的链表
printf("List after deleting node with value %d: ", valueToDelete);
printList(head);
// 释放链表内存
Node* current = head;
while (current != NULL) {
Node* temp = current;
current = current->next;
free(temp);
}
return 0;
}
这部分代码的核心在于判断当前是否是最后一项(或者是要删除项的前一项),可以简单理解为排火车,插入时需要考虑是否插入的地方后面没有人,如果没有就可以插进去了。同理,在删除时也需要知道被删除的人在“谁后边”,确定之后才能进行操作。
补充:如何判断链表是否有环
利用快慢指针,同时指向链表头,一个设置每次移动两个节点,另一个为单节点移动,观察是否会产生重合。
4. 栈(Stack)
栈是一种后进先出(LIFO)的数据结构,用于存储局部变量和函数调用的信息。在程序执行时,栈会自动分配和释放内存空间。每次函数调用时,都会在栈上创建一个新的栈帧(stack frame),用于存储该函数的局部变量和返回地址等信息。函数执行完毕后,对应的栈帧会被销毁,其占用的内存空间会被自动释放。栈的大小通常有限,并且栈上的数据访问速度非常快。
5. 堆(Heap)
堆是用于动态内存分配的区域,程序员可以在运行时显式地请求分配和释放内存。在C和C++等语言中,通常使用malloc、calloc、realloc和free等函数来在堆上分配和释放内存。堆的大小通常比栈大得多,且分配和释放内存的操作相对较慢(因为需要查找可用的内存块并进行内存管理)。堆上的数据可以在程序的整个生命周期内存在,直到显式地释放它们。
注 :栈是自动管理内存的,主要用于存储局部变量和函数调用信息,其大小有限且访问速度快,向低地址拓展;堆是手动管理内存的,用于动态内存分配,其大小较大且分配和释放操作相对较慢,向高地址拓展。
补充:C语言中内存分配的方法:静态分配、堆上分配、栈上分配。
6.队列
队列(Queue) 是一种先进先出(FIFO,First In First Out)的数据结构。它就像日常生活中的排队一样,新加入的元素会被放到队尾,而访问元素时总是从队头开始取,队列不允许在中间位置进行插入或删除操作。
队列的基本操作通常包括:
- 入队(Enqueue):在队列的尾部添加一个新元素。
- 出队(Dequeue):移除队列的头部元素,并返回该元素的值。
- 查看队头(Peek):返回队列头部元素的值,但不移除它。
- 判断队列是否为空:检查队列中是否还有元素。
注:队列与栈和堆在内存管理上有本质的区别,栈和堆是内存管理的两种方式,而队列则是一种数据结构,用于组织和管理数据。
四、 内存
内存是一个非常重要的概念,因为它决定了程序在运行时可以存储哪些数据以及这些数据是如何被访问的。内存是计算机中的一个重要组成部分,用于存储数据和程序指令。它分为多种类型,如RAM(随机存取存储器)、ROM(只读存储器)等,但在编程时,我们通常指的是RAM。RAM中的每一个字节都有一个唯一的地址,通过这个地址,我们可以读取或写入该字节的数据。
1. 内存分配的方法
常用的内存分配方法为:静态分配、堆上分配(也称为动态分配内存)、栈上分配,下表是详细的对比。
项目 | 静态分配 | 堆上分配 | 栈上分配 |
---|---|---|---|
分配时机 | 编译阶段 | 执行时 | 执行时 |
移植性 | 较差 | 较好 | 较好 |
生命周期 | 整个程序 | 由程序员控制 | 生命周期与其所在的函数相对应 |
内存释放 | - | 手动(free) | 自动 |
2. malloc和free
malloc 和 free 是C语言(以及C++中兼容C的部分)中用于动态内存管理的库函数。它们允许程序员在运行时分配和释放任意大小的内存块。
1) malloc 函数
malloc的原理是调用mmap系统调用向操作系统按4K整数倍批量申请一或者多页,通过优化手段提高分配效率并零发给用户。
//用于动态地分配内存块。它的原型在 stdlib.h 头文件中定义。
void *malloc(size_t size);
- 参数:size 指定了要分配的字节数
- 返回值
- 如果内存成功分配,malloc 返回一个指向分配的内存块的指针。
- 如果内存分配失败(例如,由于内存不足),malloc 返回 NULL。
使用 malloc 时,需要注意以下几点:
- 返回的是 void 指针,因此需要将其转换为适当的类型才能使用。
- 分配的内存块是未初始化的,包含随机数据。
- 分配的内存块在堆上,因此其生命周期不受作用域的限制。
函数名 | 功能 |
---|---|
malloc | 分配用户内存,保证在虚拟地址空间上连续 |
kmalloc | 分配内核内存,保证在物理地址空间上连续 |
vmalloc | 分配内核内存,保证在虚拟地址空间上连续 |
2)free 函数
//用于释放先前由 malloc、calloc、realloc 分配的内存块。它的原型也在 stdlib.h 头文件中定义。
void free(void *ptr);
- 参数:ptr是之前由 malloc、calloc 或 realloc 返回的指针。
注: 调用 free 后,指针本身的值不会被自动设置为 NULL,因此为了避免野指针(dangling pointer),通常建议将指针设置为 NULL。释放内存后,不应再访问该内存块,因为其内容可能已被覆盖或不可访问。
3. 内存泄漏
内存泄漏(Memory Leak)是指在计算机程序中,由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存从物理内存中消失,而是指程序在运行过程中动态分配的内存由于某种原因在逻辑上无法被释放或回收,导致系统内存的浪费,严重时甚至会导致程序崩溃。内存泄漏的常见原因包括:
- 错误地使用动态内存分配:例如,在C或C++中,使用malloc、calloc、realloc或new分配的内存没有被free或delete释放。
- 循环引用:在对象间存在循环引用时,如果没有合适的机制来打破这种循环,内存可能无法被释放。
- 全局变量或静态变量使用不当:虽然它们本身不是动态分配的,但如果它们引用了动态分配的内存,并且这些引用在程序结束时没有被清除,也可能导致内存泄漏。
- 资源(非内存资源)未释放:虽然这里讨论的是内存泄漏,但也要注意到其他类型的资源(如文件句柄、数据库连接、网络套接字等)如果没有被正确关闭或释放,也可能导致类似的问题。
解决内存泄漏的方法通常包括:
- 使用内存分析工具来检测和定位内存泄漏。
- 确保动态分配的内存在使用完毕后被正确释放。
- 使用智能指针(如C++中的std::unique_ptr和std::shared_ptr)来自动管理内存。
- 仔细设计对象的生命周期和引用关系,避免循环引用。
4. 内存溢出
内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行所需的内存大于系统能提供的最大内存。内存溢出的原因可以有很多,以下是一些常见的原因:数组越界访问、指针错误、尝试未分配或者已经释放的地址空间。