嵌入式八股文

目录

前言

一、指针函数与函数指针

二、野指针

三、malloc和calloc区别

四、内存泄漏

五、static作用

六、指针大小

七、C语言内存分配方式

八、数组和链表

九、宏函数

十、结构体和联合体

十一、内存对齐

十二、#define和typedef

十三、#include " " 和 #include < > 

十四、全局变量和局部变量

十五、数组名和指针

十六、数组指针和指针数组

十七、常量指针和指针常量

十八、堆栈的区别

十九、malloc和new

二十、struct和class

二十一、C++类的访问权限

1.概念

2.访问权限的继承规则

二十二、C语言实现string.h中常用的字符串处理函数

二十三、C语言内存分区

二十四、队列和栈

二十五、将.c源文件转换为可执行文件

二十六、UART、IIC(I2C)和SPI的区别

二十七、什么是交叉编译

二十八、SPI线制的问题

二十九、TCP和UDP的区别

三十、进程和线程

三十一、进程间通信

三十二、互斥锁和自旋锁

三十三、DMA

三十四、僵尸、孤儿、守护进程

总结


提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

简单记录下嵌入式求职可能用到的知识点


一、指针函数与函数指针

指针函数:指针函数本质上是一种函数,它的返回值是指针类型。一般定义形式如下:

int* func(int a, int b); // 返回整型指针的函数

简单示例

int* createArray(int size) {
    int* arr = (int*)malloc(size * sizeof(int));
    for(int i=0; i<size; i++) {
        arr[i] = i;
    }
    return arr; // 返回动态分配的数组指针
}

注意:

1. 当函数返回局部变量的指针时要格外小心,因为局部变量在函数执行结束后就会被销毁,此时返回的指针将指向无效内存(野指针)。

2. 对于使用malloc等函数动态分配内存的情况,在使用完指针后,要记得使用free()释放内存,防止内存泄漏。

函数指针:函数指针是一种指针,它指向的是函数。借助函数指针,我们可以将函数作为参数传递给其他函数,从而实现回调机制等功能(回调函数)->FreeRTOS创建任务,LVGL添加事件...。其定义形式如下:

int (*funcPtr)(int, int); // 指向返回整型且接受两个整型参数的函数,与指向函数的参数列表要一致

简单示例

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int calculate(int (*op)(int, int), int a, int b) {
    return op(a, b); // 通过函数指针调用函数
}

// 使用示例
int result = calculate(add, 3, 4); // 调用add函数

二、野指针

野指针指的是指向无效内存地址的指针。使用野指针可能导致程序崩溃、数据损坏或安全漏洞。

产生情况

(1) 对象被销毁后仍使用其指针

int* ptr;
{
    int x = 10;
    ptr = &x;  // ptr指向局部变量x
}  // x离开作用域被销毁,ptr变为野指针

*ptr = 20;  // 危险!访问已释放的内存

这种情况建议,返回静态变量或动态分配的内存,保证指向有效的内存。

(2)内存被释放后未置空指针

int* ptr = (int*)malloc(sizeof(int));
free(ptr);  // 释放内存
*ptr = 10;   // ptr成为野指针,访问已释放的内存

free(ptr); ptr = NULL: 搭配使用,这是一种良好的编程习惯。

小结:野指针的核心问题是指针的生命周期与所指对象的生命周期不匹配。通过合理管理内存、及时置空指针、使用智能指针等方法,可以有效避免野指针带来的风险。

三、malloc和calloc区别

malloc:分配指定大小(字节)的内存块,不初始化内容(内存中保留原有数据)。

void* malloc(size_t size);

calloc:分配num_elements个元素的数组,每个元素大小为element_size字节,初始化为 0

void* calloc(size_t num_elements, size_t element_size);

小结:选择malloc还是calloc取决于是否需要初始化内存。若需要初始化,直接用calloc更简洁;若性能优先且无需初始化,malloc更合适。

四、内存泄漏

内存泄漏:是指程序在运行过程中动态分配的内存由于某些原因未被释放,导致这部分内存无法被再次使用的现象。在需要长期运行的程序(如服务器)中,内存泄漏会逐渐耗尽系统资源,最终导致程序崩溃或系统变慢。

常见成因

(1)动态分配的内存未释放

void func() {
    int* ptr = (int*)malloc(sizeof(int));  // 分配内存
    // 忘记调用 free(ptr);
}  // 函数结束后,ptr 丢失,但内存未释放

(2)指针覆盖导致无法访问内存

int* ptr1 = (int*)malloc(sizeof(int));
int* ptr2 = (int*)malloc(sizeof(int));
ptr1 = ptr2;  // ptr1 原指向的内存无法被访问或释放
free(ptr1);   // 仅释放了 ptr2 指向的内存

小结:内存泄漏的核心问题是动态分配的内存(堆)失去引用但未被释放

五、static作用

static 关键字是一个多功能的修饰符,主要用于控制变量和函数的作用域生命周期链接属性。它的具体行为取决于使用场景:变量(全局变量、局部变量)或函数。

(1)静态局部变量

        静态局部变量的生命周期从函数调用期间扩展到整个程序运行期间,但作用域仍限于函数内部。由于生命周期的改变,静态局部变量不会重新初始化,而是保留上次调用结束时的值。

#include <stdio.h>

void counter() {
    static int count = 0;  // 仅初始化一次
    count++;
    printf("Count: %d\n", count);
}

int main() {
    counter();  // 输出: Count: 1
    counter();  // 输出: Count: 2
    counter();  // 输出: Count: 3
    return 0;
}

        若局部静态变量未显式初始化,静态变量默认初始化为 0(与全局变量相同);静态变量存储在静态区的数据段(已初始化)或 BSS 段(未初始化),而非栈上。

(2)静态全局变量

        静态全局变量的作用域仅限于定义它的源文件(.c),其他文件无法通过extern引用。

(3)静态函数

        静态函数的作用域仅限于定义它的源文件,其他文件无法调用;避免命名冲突:不同文件中可定义同名的静态函数。

        另外,未显式初始化的全局变量(或静态局部变量)的默认值为0,由由语言标准编译器 / 运行时环境共同保证的。首先,又c语言的标准规定;然后,编译时编译器将变量标记为 BSS 段;最后加载时操作系统或加载器在程序启动前将 BSS 段清零。

静态区又分数据段和BSS段:

        数据段(Data Segment):存放已显式初始化的全局变量和静态变量。

        BSS 段(Block Started by Symbol):存放未显式初始化的全局变量和静态变量。

六、指针大小

        指针大小由系统架构决定,与指针类型无关。32为系统对应4字节,64为系统对应8字节。

七、C语言内存分配方式

        在 C 语言中,内存分配方式主要有三种,分别是静态分配栈分配堆分配。

(1)静态分配

        静态分配主要包括全局变量和静态变量等,分配在静态存储区(BSS 段或数据段);程序编译时确定,在程序启动前完成分配;生命周期为整个程序运行期间,从程序启动到结束;

(2)栈分配

        栈分配主要包括局部变量、函数参数等,分配在栈内存;函数调用时,随着函数的执行上下文(栈帧)一起分配;生命周期从函数调用开始到函数返回结束,函数返回时自动释放;遵循后进先出(LIFO)原则。

(3)堆分配

        堆分配主要是需要动态调整大小的数据结构(如数组、链表)、程序运行时才能确定大小的对象;分配在堆内存;程序运行时,通过动态内存分配函数(如malloccallocrealloc)手动分配;生命周期从分配到程序员手动释放(使用free函数),若忘记释放会导致内存泄漏。

八、数组和链表

特性数组链表
内存存储连续内存空间离散内存空间(通过指针连接)
访问方式支持随机访问(时间复杂度 O (1))仅支持顺序访问(时间复杂度 O (n))
插入 / 删除效率低(需移动元素,O (n))效率高(修改指针,O (1))
内存占用较小(仅数据存储)较大(数据 + 指针域)
空间扩展静态数组大小固定,动态扩容需复制动态添加 / 删除节点,无需预分配
适用场景频繁随机访问,插入删除较少频繁插入删除,无需随机访问

补充一下,链表示例

#include <stdio.h>
#include <stdlib.h>

// 定义链表节点结构
struct Node {
    int data;//数据域,可以是多个不同类型的数据,信息
    struct Node* next;
};

// 创建新节点
struct Node* createNode(int data) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    if (newNode == NULL) {
        printf("内存分配失败!\n");
        exit(1);
    }
    newNode->data = data;
    newNode->next = NULL;
    return newNode;
}

// 在链表尾部添加节点
void append(struct Node** head_ref, int data) {
    struct Node* newNode = createNode(data);
    if (*head_ref == NULL) {
        *head_ref = newNode;
        return;
    }
    struct Node* last = *head_ref;
    while (last->next != NULL) {
        last = last->next;
    }
    last->next = newNode;
}

// 在链表头部插入节点
void prepend(struct Node** head_ref, int data) {
    struct Node* newNode = createNode(data);
    newNode->next = *head_ref;
    *head_ref = newNode;
}

// 删除指定值的第一个节点
void deleteNode(struct Node** head_ref, int key) {
    struct Node* temp = *head_ref;
    struct Node* prev;

    if (temp != NULL && temp->data == key) {
        *head_ref = temp->next;
        free(temp);
        return;
    }

    while (temp != NULL && temp->data != key) {
        prev = temp;
        temp = temp->next;
    }

    if (temp == NULL) return;

    prev->next = temp->next;
    free(temp);
}

// 遍历链表并打印元素
void printList(struct Node* node) {
    while (node != NULL) {
        printf("%d -> ", node->data);
        node = node->next;
    }
    printf("NULL\n");
}

// 释放链表内存
void freeList(struct Node* node) {
    struct Node* temp;
    while (node != NULL) {
        temp = node;
        node = node->next;
        free(temp);
    }
}

// 主函数演示链表操作
int main() {
    struct Node* head = NULL;

    append(&head, 1);
    append(&head, 2);
    append(&head, 3);
    prepend(&head, 0);

    printf("初始链表: ");
    printList(head);  // 输出: 0 -> 1 -> 2 -> 3 -> NULL

    deleteNode(&head, 2);
    printf("删除节点2后: ");
    printList(head);  // 输出: 0 -> 1 -> 3 -> NULL

    freeList(head);  // 释放内存
    return 0;
}    

九、宏函数

宏定义是一种预处理指令,用于在编译前将代码中的特定标识符直接替换为预定义的文本。

#include <stdio.h>

#define add(a,b) a+b

int main(){
    printf("%d\n",5*add(3,3));//打印输出18,
    //预处理时add(3,3)被直接替换为3+3,所以5*add(3,3)-->5*3+3-->18
    return 0;
}

十、结构体和联合体

特性结构体(struct)联合体(union)
内存分配每个成员拥有独立内存空间,总大小为所有成员大小之和(可能包含填充字节)。所有成员共享同一块内存空间,总大小为最大成员的大小。
成员访问各成员可同时存在并独立访问,通过 . 或 -> 操作符。同一时间只能使用一个成员,修改一个成员会覆盖其他成员的值。
数据存储所有成员的数据同时存储在内存中。不同成员的数据复用同一块内存,按最新赋值的成员解释内存内容。
初始化可同时初始化多个成员:
struct S { int a; char b; } s = {1, 'A'};
只能初始化第一个成员:
union U { int a; char b; } u = {1};
典型应用场景存储一组相关但类型不同的数据(如学生信息:姓名、年龄、成绩)。节省内存(如嵌入式系统)或处理不同类型的兼容性数据(如 JSON 值)。
内存占用通常较大(各成员累加)。通常较小(仅最大成员大小)。
填充字节可能存在(编译器为对齐内存插入的额外字节)。可能存在(最大成员对齐所需),但所有成员共享填充。

十一、内存对齐

C语言中的内存对齐主要针对结构体,下面通过代码验证内存对齐的规则

#include <stdio.h>

// 定义一个结构体类型
struct Example {
    char a;     // 1 字节
    //填充7字节
    double b;      // 8 字节
    short c;    // 2 字节
    //填充6字节
    //总共:1+7+8+2+6=24字节
};

struct STU {
    char a;     // 1 字节
    //填充3字节
    int b;      // 4 字节
    struct Example ex;  //24字节
    //总共:1+3+4+24=32  字节

};

int main() {
    struct STU stu;

    // 输出各成员的地址,观察内存对齐情况
    printf("Address of ex:   %#x\n", &stu);
    printf("Address of ex.a: %#x\n", &stu.a);
    printf("Address of ex.b: %#x\n", &stu.b);
    printf("Address of stu.ex: %#x\n", &stu.ex);
    printf("Address of stu.ex.a: %#x\n", &stu.ex.a);
    printf("Address of stu.ex.b: %#x\n", &stu.ex.b);
    printf("Address of stu.ex.c: %#x\n", &stu.ex.c);

    // 输出整个结构体的大小
    printf("Size of struct Example: %lu bytes\n", sizeof(struct STU));

    return 0;
}

小结:

1.对齐到最大成员的大小,结构体的对齐单位为其内部最大成员所占用的字节数。例如,由于struct STU的最大成员(结构体嵌套情况下拆出来算)大小为8字节(double),所以sizeof(struct STU)为8的整数倍;同理,sizeof(struct Example)为8的整数倍。

2.成员按自身大小对齐,每个成员变量必须放在其自身大小对齐的地址上。例如,int b占用 4 字节,必须从 4 的倍数地址开始。

十二、#define和typedef

特性#define(宏定义)typedef(类型别名)
本质预处理器指令,在编译前进行文本替换编译器指令,创建真正的类型别名
作用域全局有效,直到被 #undef 取消受限于声明的作用域(如函数、文件)
类型安全无类型检查,仅文本直接替换有类型检查,更安全
处理时机编译前(预处理阶段)编译时
使用场景定义常量、简单表达式、代码片段创建类型别名(如简化结构体类型,函数指针)
语法#define 标识符 替换文本typedef 原类型 新类型名;

十三、#include " " 和 #include < > 

特性#include "filename"#include <filename>
查找路径优先级1. 先在当前源文件所在目录查找
2. 若未找到,再按系统标准路径查找
直接在系统标准头文件目录查找(如编译器自带库目录)
适用场景引入自定义头文件(自己或项目团队编写的.h 文件)引入标准库头文件(如stdio.hiostream等系统提供的头文件)

此外,双引号支持相对路径绝对路径,可指定头文件在项目中的具体位置。

十四、全局变量和局部变量

特性全局变量局部变量
定义位置在所有函数和代码块外部定义(通常在文件顶部)在函数内部、代码块(如iffor)或函数参数中定义
作用域从定义位置到整个文件结束,可通过extern扩展到其他文件仅限于定义它的函数、代码块或参数范围
生命周期程序启动时创建,程序结束时销毁(全程存在)函数 / 代码块执行时创建,退出时销毁(动态存在)
存储位置全局数据区(静态存储区)栈区(动态分配)
默认初始值未显式初始化时自动初始化为 0(数值)或NULL(指针)未初始化时值为随机垃圾值(危险!)
可见性整个程序可见(需注意命名冲突)仅在定义的作用域内可见
使用场景多个函数需要共享数据时(如配置参数)函数内部临时存储数据(如循环计数器)

另外,如果全局变量和局部变量同名情况下,在局部变量作用域内,局部变量优先。

十五、数组名和指针

特性数组名指针
本质数组首元素的常量地址(不可修改)存储地址的变量(可重新赋值)
sizeof 结果整个数组的大小(元素数 × 元素大小)指针本身的大小(通常 4/8 字节)
自增 / 自减操作非法(常量地址不可修改)合法(移动指针位置)
存储位置数组元素直接存储在定义处指针变量存储在栈 / 堆,指向其他内存位置
初始化必须静态定义大小或初始化列表可指向任意合法地址
数组到指针的转换在大多数表达式中自动转换为指针(数组退化)无特殊转换规则

数组退化规则

数组名在以下情况会隐式转换为指针(退化):

  1. 作为函数参数时(如void func(int arr[])等价于void func(int* arr))。
  2. 参与指针运算时(如arr + 1)。
  3. 赋值给指针时(如int* ptr = arr)。

例外情况(数组名不退化):

  • 使用sizeof(arr)
  • 使用&arr取数组地址时(类型为int (*)[5](数组指针),指向整个数组的指针)

典型面试题:

#include <stdio.h>

int main() {
    int a[5]={1,2,3,4,5};
    printf("%d\n",*(*(&a+1)-1));
    /*
    1.&a:表示数组a的地址,类型是 int (*)[5]数组指针
    2.&a + 1:指针算术运算,跳过整个数组a,即指向数组a后面的下一个数组地址
    3.*(&a + 1) - 1:将&a + 1解引用得到的是一个数组int [5],
    然后减1得到该数组的最后一个元素的地址
    4.*(*(&a + 1) - 1):将*(&a + 1) - 1解引用得到该数组最后一个元素的值
    */
    return 0;
}

十六、数组指针和指针数组

对比项数组指针指针数组
本质一个指针,指向完整的数组一个数组,元素为指针
语法type (*ptr)[size];type *ptr_array[size];
内存布局存储数组的起始地址存储多个指针,每个指针指向不同对象
偏移量加 1 跳过整个数组(size * sizeof(type)加 1 指向下一个元素(sizeof(type*)
典型用途访问多维数组、传递数组参数存储多个字符串、动态内存管理

十七、常量指针和指针常量

常量指针是一个指针,它指向常量数据,即指针所指向的内容不能被修改,但指针本身可以指向其他地址。语法:const type *ptr;或 type const *ptr

说白了就是不能通过指针去修改所指向地址中的数据。

指针常量是一个常量指针即指针本身的值(存储的地址)不能被修改,但指针所指向的内容可以被修改。语法:type * const ptr;就是不能修改指针的指向。

十八、堆栈的区别

适合存储临时、小容量、生命周期短的数据(函数参数,局部变量,递归函数等),由编译器自动管理,高效但受限;

适合存储动态、大容量、生命周期长的数据(动态数组,链表,哈希表等),由程序员控制,灵活但需注意内存管理。

十九、malloc和new

对比项mallocnew
本质C 语言库函数,依赖标准库。C++ 关键字,依赖 C++ 语言机制。
类型转换需要手动强制类型转换。自动返回对应类型指针,无需转换。
构造 / 析构不调用构造函数和析构函数。自动调用构造函数(分配时)和析构函数(释放时)。
内存大小计算需手动计算字节数(sizeof)。编译器自动计算,直接指定类型 / 长度。
数组支持需手动计算总大小,释放用free语法支持new[],释放必须用delete[]
失败处理返回NULL,需手动检查。默认抛出bad_alloc异常,或返回NULLnothrow版本)。
适用场景C 语言或 C++ 中兼容 C 的代码。C++ 代码,尤其是涉及类和对象的场景。

malloc/free和new/delete必须配套使用,否则会导致内存泄漏或程序崩溃;

C++中优先使用new。

二十、struct和class

struct默认成员权限为 public,即外部代码可以直接访问结构体的成员变量和函数。默认继承权限为 public

class默认成员权限为 private,外部代码无法直接访问,必须通过public的成员函数间接访问。默认继承权限为 private

在 C++ 中,structclass的本质区别仅在于默认访问权限默认继承权限,其他功能(如构造函数、析构函数、成员函数等)完全相同。

C++ 中的struct保持了与 C 语言的兼容性,可直接用于 C 语言代码(只要不包含 C++ 特有的特性,如成员函数、构造函数等)。

二十一、C++类的访问权限

在 C++ 中,类的访问权限是面向对象编程中封装的核心机制,用于控制类的成员(变量和函数)如何被外部代码访问。(publicprivateprotected)

1.概念

public(公共成员):可以被类的外部代码直接访问,没有任何限制。通常用于定义类的接口(如成员函数),允许外部代码与对象交互。

private(私有成员):只能被类的内部成员(成员函数、友元)访问,外部代码无法直接访问。用于隐藏类的实现细节(如数据存储方式),防止外部代码意外修改。

protected(受保护成员):类似于private,但派生类(子类)可以访问基类的protected成员。在继承体系中,允许子类访问基类的某些成员,同时保持对外部代码的隐藏。

2.访问权限的继承规则

当一个类继承自另一个类时,基类成员的访问权限在派生类中可能(和继承方式有关)会发生变化,具体规则如下:

基类成员权限public 继承protected 继承private 继承
public仍为public变为protected变为private
protected仍为protected仍为protected变为private
private不可访问不可访问不可访问

二十二、C语言实现string.h中常用的字符串处理函数

1.char *strcpy( char *to, const char *from );

char* my_strcpy(char* to, const char* from) {
    char* orig = to;
    while ((*to++ = *from++) != '\0');  // 复制字符并判断是否为'\0'
    return orig;
}

2.char *strncpy( char *to, const char *from, size_t count );

char* my_strncpy(char* dest, const char* src, size_t n) {
    char* orig = dest;
    while (n-- > 0 && (*dest++ = *src++) != '\0');  // 复制字符
    while (n-- > 0) *dest++ = '\0';                  // 填充剩余空间
    return orig;
}

3.char *strcat(char *str1, const char *str2 );

char* my_strcat(char* dest, const char* src) {
    char* orig = dest;
    while (*dest) dest++;           // 找到dest的末尾
    while ((*dest++ = *src++) != 0);  // 复制src到dest末尾
    return orig;
}

4.char *strncat( char *str1, const char *str2, size_t count );

char* my_strncat(char* dest, const char* src, size_t n) {
    char* orig = dest;
    while (*dest) dest++;  // 找到dest的末尾
    while (n-- && (*dest++ = *src++));  // 复制最多n个字符
    *dest = '\0';  // 添加结束符
    return orig;
}

5.int strcmp( const char *str1, const char *str2 );

int my_strcmp(const char* s1, const char* s2) {
   while (*s1 != '\0' && *s2 != '\0' && *s1 == *s2) {
        s1++;
        s2++;
    }
    return (unsigned char)*s1 - (unsigned char)*s2;
}

6.int strncmp( const char *str1, const char *str2, size_t count );

int my_strncmp(const char* s1, const char* s2, size_t n) {
    if (n == 0) return 0;  // 若n为0,直接返回0
    
    while (--n > 0 && *s1 != '\0' && *s2 != '\0' && *s1 == *s2) {
        s1++;
        s2++;
    }
    
    return (unsigned char)*s1 - (unsigned char)*s2;
}

7.size_t strlen( char *str );

size_t my_strlen(const char* str) {
    const char* s;
    for (s = str; *s; ++s);
    return s - str;
}

8.char* strlwr( char *str );

char* my_strlwr(char* str) {
    char* ptr = str;
    while (*ptr != '\0') {
        if (*ptr >= 'A' && *ptr <= 'Z') {  // 手动检查大写字母
            *ptr += ('a' - 'A');  // 转换为小写(ASCII中相差32)
        }
        ptr++;
    }
    return str;
}

9.char* strupr( char *str );

char* my_strupr(char* str) {
    char* ptr = str;
    while (*ptr != '\0') {
        if (*ptr >= 'a' && *ptr <= 'z') {  // 手动检查小写字母
            *ptr -= ('a' - 'A');  // 转换为大写(ASCII中相差32)
        }
        ptr++;
    }
    return str;
}

10.char *strstr( const char *str1, const char *str2 );

#include <string.h>

char* my_strstr(const char* str1, const char* str2) {
    const char* a = str1;
    const char* b = str2;

    // 处理空字符串的特殊情况
    if (*b == '\0') {
        return (char*)str1;  // 空字符串是任何字符串的前缀
    }

    while (*a != '\0') {
        const char* p1 = a;
        const char* p2 = b;

        // 逐字符比较,直到不匹配或p2结束
        while (*p1 != '\0' && *p2 != '\0' && *p1 == *p2) {
            p1++;
            p2++;
        }

        // 如果p2到达末尾,说明找到匹配
        if (*p2 == '\0') {
            return (char*)a;  // 返回匹配的起始位置
        }

        // 否则继续在str1中查找
        a++;
    }

    return NULL;  // 未找到匹配
}

11.void *my_memset( void *buffer, int ch, size_t count );

void* my_memset(void* buffer, int ch, size_t count) {
    unsigned char* ptr = buffer;  // 转换为unsigned char*以逐字节操作
    
    while (count-- > 0) {
        *ptr++ = (unsigned char)ch;  // 将ch转换为unsigned char后赋值
    }
    
    return buffer;  // 返回原指针,允许链式调用
}

二十三、C语言内存分区

详细链接:

C语言:内存分配---栈区、堆区、全局区、常量区和代码区

二十四、队列和栈

特性栈 (Stack)队列 (Queue)
数据原则后进先出 (LIFO)先进先出 (FIFO)
主要操作入栈 (Push)、出栈 (Pop)入队 (Enqueue)、出队 (Dequeue)
操作端点仅栈顶操作入队在队尾,出队在队头
应用场景函数调用、递归、表达式计算任务调度、消息缓冲、广度优先搜索
实现方式        数组或链表

队列的实现

#include <stdio.h>
#define MAX_SIZE 100

int queue[MAX_SIZE];
int front = 0, rear = 0;

// 入队操作
void enqueue(int value) {
    if ((rear + 1) % MAX_SIZE == front) {
        printf("队列已满\n");
        return;
    }
    queue[rear] = value;
    rear = (rear + 1) % MAX_SIZE;
}

// 出队操作
int dequeue() {
    if (front == rear) {
        printf("队列为空\n");
        return -1;
    }
    int value = queue[front];
    front = (front + 1) % MAX_SIZE;
    return value;
}

栈的实现

#include <stdio.h>
#define MAX_SIZE 100

int stack[MAX_SIZE];
int top = -1;

// 入栈操作
void push(int value) {
    if (top >= MAX_SIZE - 1) {
        printf("栈已满\n");
        return;
    }
    stack[++top] = value;
}

// 出栈操作
int pop() {
    if (top < 0) {
        printf("栈为空\n");
        return -1;
    }
    return stack[top--];
}

小结:栈和队列的核心差异在于数据的存取顺序,栈是后进先出,队列是先进先出。

二十五、将.c源文件转换为可执行文件

.c源文件转换为可执行文件需要经过四个主要阶段:预处理、编译、汇编和链接。下面我将通过linux环境下详细演示这个过程:

1.预处理(生成.i文件)

先预处理,然后查看.i文件

gcc -E main.c -o main.i 
cat main.i

主要操作

  • 展开#include包含的文件内容。

  • 直接替换#define定义的宏。

  • 处理条件编译指令(如#ifdef)。

  • 删除注释。

2.编译(生成汇编文件.s)

gcc -S main.i -o main.s

主要操作

  • 词法分析、语法分析、语义分析。
  • 代码优化(如常量折叠、循环展开)。
  • 生成平台相关的汇编代码。

3.汇编(生成目标文件.o,二进制格式

as main.s -o main.o

主要操作

  • 将汇编指令翻译为二进制机器码。
  • 生成目标文件(包含代码段、数据段、符号表等)。

4.链接(链接库并生成可执行文件main)

gcc main.o -o main

主要操作

  • 解析符号引用(如函数调用、全局变量)。
  • 合并代码段、数据段等。
  • 处理库依赖(静态链接或动态链接)。

最后执行文件

二十六、UART、IIC(I2C)和SPI的区别

1. 物理层特性

特性UART(串口)I²CSPI
信号线数量2 线(TX+RX)2 线(SDA+SCL)4 线(MOSI+MISO+SCK+CS)
拓扑结构点对点(一对一)多主多从(通过器件地址区分设备)单主多从(通过片选信号区分设备)
通信方向全双工(两根独立数据线)半双工(同一时间单向传输)全双工(可同时双向传输)
时钟方式异步(需约定波特率)同步(主设备或从设备生成时钟)同步(主设备生成时钟)
电平类型多种(TTL、RS-232、RS-485 等)开漏输出(需上拉电阻)推挽输出

2. 协议层特性

特性UARTI²CSPI
数据格式起始位 + 数据位 + 校验位 + 停止位基于帧(起始位 + 地址 + 数据 + 应答)连续数据流,无固定帧格式
寻址方式无(点对点)设备地址(7 位或 10 位)片选信号(每个从设备独占一个 CS 引脚)
传输速率低到中(9600bps-115200bps)中速(标准 100kbps,快速 400kbps)高速(可达数十 Mbps)
应答机制可选校验位(奇偶校验)ACK/NACK 应答位无显式应答(通过 SS 信号控制)
错误检测校验位(奇偶、CRC)ACK 应答无内置机制,需上层协议实现

3. 应用场景

场景UART 更适合I²C 更适合SPI 更适合
设备数量一对一通信(如调试接口)多设备(如连接多个传感器)少设备(如 SD 卡、LCD 显示屏)
距离限制中长距离(RS-485 可达 1200 米)短距离(通常 < 1 米)短距离(通常 < 1 米)
引脚资源引脚紧张(仅需 2 根线)引脚较紧张(2 根线)引脚充足(至少 4 根线)
速度要求低速数据传输中速数据传输高速数据传输(如图像传感器)
典型应用调试信息输出、GPS 模块传感器(温度 / 湿度)、EEPROM高速外设(SD 卡

补充:

1.UART判断数据接收完成的编程技巧

如果没有串口空闲中端的情况或者传输大量的数据空闲中断异常触发等,都会导致数据接收不完整性。此时可以同过定时器增加超时判断,如果两个数据帧之间间隔时间超时了,可以认为两个数据帧不是同一包数据,由此判断串口数据接收完成

2.IIC为什么接上拉电阻?

  1. 提供高电平驱动:I2C 总线的 SDA 和 SCL 引脚采用开漏输出,器件本身只能将信号线拉低,无法主动输出高电平。因此,需要通过外部上拉电阻将信号线拉至高电平,以满足 I2C 总线规范中总线空闲时信号线为高电平的要求。
  2. 确保信号稳定:如果没有上拉电阻,当没有设备主动驱动 SDA 或 SCL 时,这些线将处于浮空状态,容易受到噪声干扰,导致数据传输错误。上拉电阻能确保信号线在无设备驱动时保持稳定的高电平状态,提高信号的可靠性。
  3. 实现线与功能及总线仲裁:I2C 支持多主多从结构,开漏输出加上拉电阻的设计具有线与功能。当多个设备同时抢占总线时,通过线与机制来实现总线仲裁。默认状态下 SDA 是高电平表示总线空闲,当总线上的一个设备将 SDA 拉低后,由于线与逻辑,SDA 整条线都为低,其余设备就无法抢占总线,从而达到仲裁的效果。
  4. 保护电路:上拉电阻可以限制电流,防止其他器件拉低信号线时,过大的电流灌入端口而损坏设备。例如,当 VDD=3V 时,选择不低于 1KΩ 的上拉电阻,可使灌入电流不超过 3mA,保证端口安全。
  5. 匹配电压级别:I2C 设备可以支持不同的逻辑电平,使用外部上拉电阻可以方便地将总线电平匹配到特定的系统电压,如 3.3V 或 5V 等,使得不同电压等级的设备可以共存于同一总线。

3.SPI四种模式选择原则

  1. 设备兼容性:根据从设备的规格书要求选择匹配的模式(如 SD 卡通常使用模式 0 或模式 3)。
  2. 抗干扰能力
    • 模式 0/3:上升沿采样,更适合长距离传输(上升沿比下降沿更抗噪声)。
    • 模式 1/2:下降沿采样,在短距离或噪声环境中使用。
  3. 初始状态
    • CPOL=0:SCK 初始为低,适合系统复位后保持稳定状态。
    • CPOL=1:SCK 初始为高,某些设备要求时钟在空闲时为高电平。

二十七、什么是交叉编译

交叉编译是指在一种平台(如 x86 架构的 PC)上编译生成另一种平台(如 ARM 架构的嵌入式设备)可执行代码的过程。这种技术在嵌入式开发、移动应用开发等场景中非常常见,因为目标设备(如开发板、手机)通常资源有限,无法直接进行复杂的编译工作。

为什么需要交叉编译?

  1. 资源限制:目标设备(如单片机、开发板)可能没有足够的内存、存储或 CPU 性能来运行编译器。
  2. 效率考量:在高性能主机上编译代码比在目标设备上编译快得多。
  3. 环境差异:目标设备的操作系统可能不支持编译工具链(如嵌入式 Linux 裁剪版)

二十八、SPI线制的问题

1.标准的四线制:全双工通信,支持同时双向数据传输

2.三线制

  • 去除MISO,仅需主设备向从设备发送数据(如控制指令),无需从设备返回数据,此时从设备无法向主设备反馈数据,变为单工通信。
  • 共用数据线,半双工通信(同一时间仅单向传输),需分时复用数据线,降低通信效率,此外需要在程序设计时对收发数据进行处理,增加编程难度。

二十九、TCP和UDP的区别

特性TCP(传输控制协议)UDP(用户数据报协议)
连接机制面向连接(三次握手建立,四次挥手断开)无连接(直接发送)
可靠性可靠(确认、重传、排序、流量控制)不可靠(不保证送达或顺序)
传输效率低(开销大)高(无连接和确认开销)
数据形式字节流(无边界)数据报(有明确边界)
头部大小最小 20 字节固定 8 字节
拥塞控制有(避免网络拥塞)无(可能导致拥塞)
传输速度慢(受可靠性机制影响)快(适合实时性要求高的场景)

三十、进程和线程

维度进程线程
资源分配独立资源(地址空间、文件描述符等)共享进程资源,仅独立栈、寄存器等
开销创建 / 销毁 / 切换开销大(复制资源)开销小(仅复制少量独立资源)
独立性高度独立,一个进程崩溃不影响其他进程依赖性强,一个线程崩溃可能导致进程崩溃
通信方式需通过 IPC 机制(管道、共享内存等)直接共享内存(需同步机制避免冲突)
调度单位间接调度(以进程为单位分配时间片)直接调度(CPU 调度的基本单位)
标识符唯一 PID唯一 TID,共享进程的 TGID
  • 线程是进程的一部分,一个进程至少包含一个线程(主线程);

  • 进程和线程均需 CPU 调度才能运行,状态(就绪、运行、阻塞等)逻辑一致;

  • 线程不能脱离进程独立存在,必须依赖进程的资源才能执行。

三十一、进程间通信

  • 管道:无名管道适用于存在亲缘关系的进程;有名管道以文件的形式存在适用于所有进程;
  • 共享内存:最快的 IPC 方式,多个进程映射同一块物理内存(需信号量同步);
  • 消息队列:内核维护的消息链表,进程通过发送 / 接收消息通信;
  • 信号量:用于进程间同步(如控制共享资源的访问次数);
  • 套接字(Socket):适用于跨主机或同一主机的进程通信(如网络编程)。

另外,需要借助内核的 IPC 方式包括:管道(无名管道、有名管道)、消息队列、共享内存、信号量、套接字。

三十二、互斥锁和自旋锁

1. 互斥锁(Mutex)

  • 原理
    当线程 A 获取互斥锁时,若锁已被其他线程占用,线程 A 会主动放弃 CPU 时间片,进入睡眠状态(内核态)。直到锁被释放后,内核会唤醒等待该锁的线程之一。

  • 关键特点

    • 线程等待锁时不消耗 CPU 资源;

    • 锁的释放会触发内核调度,唤醒等待线程;

    • 适用于锁持有时间较长(如涉及 I/O 操作)或竞争激烈的场景。

2. 自旋锁(Spinlock)

  • 原理
    当线程 A 获取自旋锁时,若锁已被占用,线程 A 会持续循环检查锁的状态(称为 “忙等待”),不会主动让出 CPU。直到锁被释放后,线程立即获取锁并继续执行。

  • 关键特点

    • 线程等待锁时持续占用 CPU(自旋);

    • 避免了内核态与用户态的切换开销;

    • 适用于锁持有时间极短(如几微秒)且 CPU 资源充足的场景。

三十三、DMA

DMA(Direct Memory Access,直接内存访问)是一种重要的外设,它允许数据在不占用 CPU 的情况下直接在内存和外设之间传输。这大大提高了系统效率,尤其适用于大数据量或实时性要求高的场景。

DMA工作流程:

  1. 初始化:CPU 向 DMAC(DMA控制器) 发送指令,指定传输方向(外设→内存、内存→外设、内存→内存)、数据源地址、目标地址、传输数据量等信息;
  2. 释放 CPU:CPU 完成初始化后,继续执行其他任务,不再参与数据传输;
  3. 独立传输:DMAC 直接控制设备与内存的数据传输,无需 CPU 干预;
  4. 传输完成:数据传输结束后,DMAC 通过中断通知 CPU,CPU 处理后续工作(如确认传输结果)。

通俗来说,

  • 单缓冲区:DMA 搬完一斗车数据后,必须等 CPU 重新装满才能继续,中间有 “等待装货” 的空闲时间。
  • 双缓冲区:DMA 推第一个斗车搬运时,CPU 可以同时给第二个斗车装货;第一个斗车卸完,DMA 直接推第二个斗车继续,全程几乎无空闲,效率大幅提升。

三十四、僵尸、孤儿、守护进程

1.僵尸进程:进程已经终止(执行完所有代码),但父进程未回收其资源(如进程描述符 PCB),导致其在系统中残留的 “空壳” 状态。

产生原因:进程执行exit()后,内核会释放其内存、CPU 等资源,但会保留进程 ID(PID)、退出状态、资源使用统计等信息,等待父进程通过wait()waitpid()系统调用回收。如果父进程未调用这些函数,子进程就会变成僵尸进程,留在进程表中。

危害:僵尸进程不占用 CPU 和内存,但会占用 PID 资源(系统 PID 是有限的,通常 32768 个),若大量堆积会导致无法创建新进程。并且无法通过kill杀死僵尸进程。

// 父进程创建子进程后不回收,子进程会变成僵尸
#include <stdio.h>
#include <unistd.h>
int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:执行后退出,此时有用父进程没有调用回收,该子进程变为僵尸进程
        printf("子进程退出\n");
        return 0;
    } else {
        // 父进程:不调用wait(),一直休眠
        while(1) sleep(1);
    }
}

2.孤儿进程:父进程先于子进程终止,导致子进程失去父进程,成为 “孤儿”。

产生原因:父进程意外崩溃、被杀死(如kill -9),或正常退出但未处理子进程。

Linux 中,孤儿进程会被init 进程(PID=1) 收养,init 进程会负责调用wait()回收其资源,因此孤儿进程不会变成僵尸进程。与普通进程无异,可以正常运行。

// 父进程先退出,子进程成为孤儿
#include <stdio.h>
#include <unistd.h>
int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:休眠10秒,观察父进程是否存在
        sleep(10);
        printf("子进程的父进程ID:%d\n", getppid()); // 会输出1(被init收养)
    } else {
        // 父进程:立即退出
        printf("父进程退出\n");
        return 0;
    }
}

3.守护进程:在后台运行,脱离终端控制,独立于用户登录会话的进程,通常用于提供持续服务(如sshdnginx)。

  • 后台运行:不占用终端,终端关闭后仍能继续运行。

  • 无终端关联:与启动它的终端(TTY)完全脱离,避免终端信号(如Ctrl+C)影响。

  • 父进程为 init:启动后会脱离原父进程,被 init 收养(类似孤儿进程,但主动设计)。

  • 生命周期长:通常随系统启动而启动,随系统关闭而终止。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <syslog.h>

// 信号处理:优雅退出
void handle_signal(int sig) {
    if (sig == SIGTERM) {
        syslog(LOG_INFO, "守护进程收到退出信号,正在退出...");
        closelog();
        exit(EXIT_SUCCESS);
    }
}

int main() {
    // 步骤1:创建子进程,父进程退出
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        exit(EXIT_SUCCESS); // 父进程退出
    }

    // 步骤2:创建新会话,脱离终端
    if (setsid() == -1) {
        perror("setsid failed");
        exit(EXIT_FAILURE);
    }

    // 步骤3(可选):再次fork,避免成为会话组长
    pid_t pid2 = fork();
    if (pid2 < 0) {
        perror("fork 2 failed");
        exit(EXIT_FAILURE);
    }
    if (pid2 > 0) {
        exit(EXIT_SUCCESS);
    }

    // 步骤4:切换工作目录到根目录
    if (chdir("/") == -1) {
        perror("chdir failed");
        exit(EXIT_FAILURE);
    }

    // 步骤5:重置文件权限掩码
    umask(0);

    // 步骤6:关闭并重定向标准文件描述符
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    int fd = open("/dev/null", O_RDWR);
    if (fd == -1) {
        perror("open /dev/null failed");
        exit(EXIT_FAILURE);
    }
    dup2(fd, STDIN_FILENO);
    dup2(fd, STDOUT_FILENO);
    dup2(fd, STDERR_FILENO);
    close(fd);

    // 步骤7:核心逻辑
    // 注册信号处理
    signal(SIGTERM, handle_signal);
    // 忽略SIGINT(Ctrl+C)和SIGHUP(终端关闭)
    signal(SIGINT, SIG_IGN);
    signal(SIGHUP, SIG_IGN);

    // 打开系统日志
    openlog("my_daemon", LOG_PID | LOG_NDELAY, LOG_DAEMON);
    syslog(LOG_INFO, "守护进程启动成功");

    // 循环运行(示例:每10秒输出一次日志)
    while (1) {
        syslog(LOG_DEBUG, "守护进程运行中...");
        sleep(10);
    }

    // 理论上不会执行到这里
    closelog();
    return 0;
}

小结:

  • 僵尸进程是 “没被收拾的尸体”,需要父进程 “善后”,否则会占用资源;

  • 孤儿进程是 “没爹的孩子”,会被系统 “福利院”(init)收养,自动处理;

  • 守护进程是 “后台打工人”,主动脱离终端,默默提供持续服务,是系统必备的 “常驻员工”。

总结

有空就写写,未完待续

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

什么都搞的嵌入式

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值