笔试合集

1、宏定义printf()

问题提出

有时候我们想用宏定义来决定是编译debug版本的代码还是release的代码,dubug版本的代码会通过printf打印调试信息,release版本的代码则不会。我们总不能对每一条printf都这样写:

#if IS_DEBUG
printf("hello world!");
#endif

解决方法

我后来想到一个方法,可以使用宏定义代替printf函数,由于printf是可变参数的函数,这里就要用到变參宏(…和__VA_ARGS__)。
在头文件下写此代码

后面需要打印调试信息的时候使用PR宏就可以了,如果需要release版本,不打印调试信息,就把DEBUG设置为0,编译出来的程序就不会打印调试信息了。

#include "stdio.h"

#define IS_DEBUG 1    // 需要调试信息

#if IS_DEBUG
#define PR(...) printf(__VA_ARGS__)
#else
#define PR(...)
#endif

int main()
{
    printf("debug test!\r\n");  //一定会打印

    PR("hello world!\r\n");     // 如果IS_DEBUG 1 打印
    PR("string:%s\r\n", "data");
    PR("integer:%d\r\n", 100);

    return 0;
}

2、static 的作用

static 的作用取决于它修饰的位置,核心涉及两个概念:

  • 静态存储期:对象在整个程序运行期间一直存在。
  • 内部链接属性:符号只在当前源文件中可见。

2.1 修饰局部变量

#include <stdio.h>

static void test(void)
{
    static int count = 0;

    printf("%d ", count);
    count++;
}

int main(void)
{
    for (int i = 0; i < 5; ++i)
    {
        test();
    }

    return 0;
}

输出:

0 1 2 3 4

静态局部变量的特点:

  • 只初始化一次。
  • 具有静态存储期。
  • 函数返回后不会销毁。
  • 下次进入函数时保留上一次的值。
  • 作用域仍然局限在该代码块内。
  • 值可以修改,并不是常量。

错误说法:

静态局部变量初始化后不会改变。

正确说法:

静态局部变量只初始化一次,但之后仍然可以被修改。

2.2 修饰文件作用域变量

/* module.c */
static int g_module_state = 0;

该变量:

  • 具有静态存储期。
  • 具有内部链接属性。
  • 只能在当前 .c 文件中直接访问。
  • 其他源文件不能通过 extern 引用该符号。

常用于隐藏模块内部实现,避免全局命名冲突。

2.3 修饰函数

static void update_state(void)
{
}

静态函数只在当前源文件中可见,其他 .c 文件不能直接调用。

这种做法可以:

  • 隐藏内部实现。
  • 减少符号污染。
  • 避免函数名冲突。
  • 帮助编译器进行优化。

2.4 静态对象的默认初始化

具有静态存储期的对象,如果没有显式初始化,默认会被初始化为零:

static int g_value;       /* 默认值为0 */
static int *g_pointer;    /* 默认值为空指针 */

3、linux操作系统是怎么启动的

芯片上电或复位
        ↓
Boot ROM
        ↓
SPL / 第一阶段引导程序
        ↓
U-Boot / 第二阶段引导程序
        ↓
加载 Linux Kernel、设备树 DTB、可选 initramfs
        ↓
内核解压并执行
        ↓
初始化 CPU、内存、中断、时钟、驱动
        ↓
挂载根文件系统 rootfs
        ↓
启动 PID 1:/sbin/init、systemd 或 BusyBox init
        ↓
执行启动脚本和系统服务
        ↓
启动登录程序或业务应用

1、硬件初始化

首先进行的是硬件初始化。

  • 检测并初始化 CPU:CPU 是计算机的核心部件,它的初始化工作主要包括设置 CPU 的工作模式、时钟频率等参数。
  • 检测并初始化内存:内存是计算机的临时存储空间,它的初始化工作主要包括为内存分配物理地址空间、设置内存的工作模式等。
  • 检测并初始化硬盘:硬盘是计算机的主要存储设备,它的初始化工作主要包括检测硬盘的状态、设置硬盘的工作模式等。
  • 检测并初始化键盘、鼠标等输入设备:这些设备是用户与计算机交互的主要工具,它们的初始化工作主要包括检测设备的状态、设置设备的工作模式等。

2、内核启动

硬件初始化完成后,计算机会加载并启动内核。内核启动的过程主要包括以下步骤:

  • 从磁盘读取内核映像:内核映像包含了操作系统的所有代码和数据,它是通过磁盘上的文件系统提供的。
  • 解压内核映像:内核映像通常以压缩的形式提供,需要解压后才能被内核读取。
  • 跳转到内核入口点:内核入口点是一个特殊的函数,它是内核运行的起始点。当内核启动时,它会跳转到这个入口点开始执行。

3、系统初始化

内核启动后,接下来会进行系统初始化。系统初始化主要包括以下任务:

  • 创建进程0(即 init 进程):init 进程是系统的主进程,它的任务是启动其他所有的进程。
  • 初始化各种系统设备和服务:这包括网络接口、文件系统、设备驱动等。

4、文件系统挂载

系统初始化完成后,接下来的工作就是挂载文件系统。文件系统挂载是将文件系统与计算机的文件系统中的某个目录关联起来,使用户可以访问到文件系统中的内容。这一阶段主要完成以下任务:

  • 确定文件系统的类型:文件系统有多种类型,如 ext2、ext3、ntfs 等,需要根据文件系统的类型来确定如何挂载。
  • 确定挂载点的设备和挂载选项:挂载点是一个目录,需要确定这个目录的设备号和挂载选项。设备号决定了文件系统在哪个设备上挂载,挂载选项决定了如何访问文件系统中的内容。
  • 挂载文件系统:根据前面的信息,挂载文件系统到指定的设备和挂载点。

5、登录提示符显示

文件系统挂载完成后,计算机会显示一个登陆提示符,提示用户可以登录操作系统了。此时,用户可以输入用户名和密码来登录操作系统

4、进程与线程的区别

4.1 基本概念

进程

进程是一个正在运行的程序实例,也是操作系统进行资源分配和隔离的基本单位。

一个进程通常包括:

  • 进程控制块 PCB。
  • 独立的虚拟地址空间。
  • 代码段、数据段和 BSS 段。
  • 堆。
  • 一个或多个线程栈。
  • 文件描述符表。
  • 信号处理方式。
  • 用户权限和工作目录。

线程

线程是进程内部的一条执行流,也是操作系统进行 CPU 调度和执行 的基本单位。

同一进程中的线程通常共享:

  • 代码段。
  • 全局变量和静态变量。
  • 堆。
  • 文件描述符。
  • Socket。
  • 内存映射区。

每个线程独立拥有:

  • 线程 ID。
  • 程序计数器。
  • 寄存器上下文。
  • 线程栈。
  • 调度状态。
  • 线程局部存储 TLS。

4.2 主要区别

对比项 进程 线程
基本作用 资源分配和隔离 CPU 调度和执行
地址空间 通常相互独立 同一进程内共享
创建开销 较大 较小
切换开销 通常较大 通常较小
数据共享 需要 IPC 可直接访问共享变量
隔离性 较强 较弱
故障影响 一般不直接影响其他进程 可能导致整个进程退出

4.3 通信与同步

  • 通信:解决数据如何传递。
  • 同步:解决执行顺序以及共享资源如何安全访问。

进程间常见通信方式:

  • 无名管道。
  • 有名管道 FIFO。
  • 信号。
  • 共享内存。
  • 消息队列。
  • 信号量。
  • Socket。

线程间常见同步方式:

  • 互斥锁。
  • 读写锁。
  • 条件变量。
  • 信号量。
  • 自旋锁。
  • 原子操作。

详细原理和示例代码参见单独文档:

进程与线程通信机制-详细版.md

5、并行和并发的区别

file

5.1 并发

并发是指多个任务在一段时间内都取得了执行进展,但不要求它们在同一个时刻真正执行。

在单核 CPU 上,操作系统可以快速切换任务:

时间 →
任务A:运行——等待————运行
任务B:————运行——等待———

从较长时间尺度看,A 和 B 都在推进,因此称为并发。

5.2 并行

并行是指多个任务在同一时刻真正同时执行,通常需要多核 CPU 或多个处理单元。

CPU核0:任务A持续运行
CPU核1:任务B持续运行

5.3 对比

对比项 并发 并行
含义 一段时间内交替推进 同一时刻同时执行
单核是否支持 支持 通常不支持真正并行
多核是否支持 支持 支持
关注点 任务组织和响应性 同时计算和加速
常见问题 竞态、死锁、调度 数据划分、同步、负载均衡

5.4 示例

一个服务器同时处理多个客户端连接属于并发。

图像算法把一幅图像拆成多个区域,由多个 CPU 核同时处理,属于并行。

一个程序可以既并发又并行:

  • 多个任务之间并发。
  • 部分任务在不同 CPU 核上并行。

6、什么是上下文切换

上下文切换是指 CPU 从一个执行单元切换到另一个执行单元时,保存当前执行状态并恢复目标执行状态的过程。

6.1 需要保存的上下文

通常包括:

  • 通用寄存器。
  • 程序计数器 PC。
  • 栈指针 SP。
  • 状态寄存器。
  • 浮点寄存器。
  • SIMD 寄存器。
  • 调度状态。
  • 地址空间相关信息。

6.2 线程上下文切换

同一进程中的线程共享地址空间,所以线程切换通常主要涉及:

  • 保存当前线程寄存器。
  • 保存线程栈指针。
  • 切换线程控制块。
  • 恢复下一个线程寄存器。
  • 恢复下一个线程栈。

通常不需要切换整个进程页表。

6.3 进程上下文切换

进程切换除了线程级上下文外,还可能需要切换:

  • 页表。
  • 虚拟地址空间。
  • 内存管理上下文。
  • 安全和权限上下文。

进程切换可能导致:

  • TLB 失效。
  • CPU Cache 命中率下降。
  • 更多内核调度开销。

因此进程切换通常比同一进程内线程切换开销大,但不是绝对结论。

6.4 中断上下文

中断上下文是 CPU 执行中断服务程序时的执行环境。

中断发生时,CPU 通常会:

保存部分现场
    ↓
根据中断向量跳转到 ISR
    ↓
执行中断处理
    ↓
恢复现场
    ↓
返回被中断位置或切换任务

中断上下文与任务上下文不同:

  • 中断上下文通常不能睡眠。
  • 不应执行长时间阻塞操作。
  • 不一定有独立进程身份。
  • 中断返回后不一定发生任务切换。

6.5 触发上下文切换的常见原因

  • 时间片用完。
  • 高优先级任务就绪。
  • 当前任务主动阻塞。
  • 当前任务等待 I/O。
  • 当前任务调用 sleep()
  • 中断唤醒了更高优先级任务。
  • 当前任务主动让出 CPU。

6.6 上下文切换的代价

上下文切换本身不执行有效业务,还可能带来:

  • 寄存器保存和恢复。
  • 调度器运行。
  • Cache 失效。
  • TLB 失效。
  • 分支预测失效。
  • 锁竞争增加。

因此线程或任务并不是越多越好。

7、strcat、strncat、strcmp、strcpy 哪些函数会导致内存越界?如何改进?

strcpy函数会导致内存越界。

strcpy拷贝函数不安全,他不做任何的检查措施,也不判断拷贝大小,不判断目的地址内存是否够用。

char *strcpy(char *strDest,const char *strSrc)

strncpy拷贝函数,虽然计算了复制的大小,但是也不安全,没有检查目标的边界。

strncpy(dest, src, sizeof(dest)); 

strncpy_s是安全的。
strcmp(str1,str2),是比较函数,若str1=str2,则返回零;若str1<str2,则返回负数;若str1>str2,则返回正数。(比较字符串)

strncat()主要功能是在字符串的结尾追加n个字符。

char * strncat(char *dest, const char *src, size_t n);

strcat()函数主要用来将两个char类型连接。例如:

char d[20]="Golden";
char s[20]="View";
strcat(d,s);
//打印d
printf("%s",d);
//输出 d 为 GoldenView (中间无空格)

8、strcpy和memcpy的区别

memcpy拷贝函数,它与strcpy的区别就是memcpy可以拷贝任意类型的数据,strcpy只能拷贝字符串类型。
memcpy 函数用于把资源内存(src所指向的内存区域)拷贝到目标内存(dest所指向的内存区域);有一个size变量控制拷贝的字节数;

void *memcpy(void *dest, void *src, unsigned int count);

就是说,memcpy是把整个内存拷过去,strcpy只可以拷贝字符串类型

9、const 的用法

const 更准确地表示“通过当前表达式不能修改该对象”,不能简单等同于编译期常量。

9.1 修饰普通变量

const int max_speed = 8000;

不能直接修改:

max_speed = 9000;    /* 编译错误 */

但在 C 语言中,文件作用域或局部 const int 不一定能用于所有要求编译期整数常量的场景,具体取决于上下文。

9.2 修饰函数形参

值传递:

void process_value(const int value)
{
}

value 是调用者参数的副本,const 只限制函数内部不能修改该副本,对调用者没有实质影响。

更常见的是修饰指针参数:

void parse_frame(const unsigned char *data,
                 size_t length);

表示函数不能通过 data 修改输入缓冲区。

9.3 指向 const 对象的指针

int value1 = 10;
int value2 = 20;

const int *ptr = &value1;

ptr = &value2;       /* 可以改变指向 */
/* *ptr = 30; */     /* 不能通过ptr修改对象 */

也可以写成:

int const *ptr;

9.4 const 指针

int value = 10;
int *const ptr = &value;

*ptr = 20;           /* 可以修改对象 */
/* ptr = &other; */  /* 不能改变指向 */

const 指针在定义时必须初始化。

9.5 指向 const 对象的 const 指针

const int value = 10;
const int *const ptr = &value;

既不能改变指向,也不能通过 ptr 修改对象。

9.6 快速判断方法

从变量名开始向两侧阅读:

const int *p;

p 是指针,指向 const int

int *const p = &value;

p 本身是 const 指针,指向 int

9.7 const 不是绝对硬件保护

通过错误的强制类型转换修改一个原本定义为 const 的对象,行为未定义:

const int value = 10;

int *p = (int *)&value;
*p = 20;             /* 未定义行为 */

编译器可能把真正的 const 对象放在只读存储区。

我记得有一个人说,不能把const当成常量,可以把他说成是只读

  • 用const修饰常量:不能被更改,相当于常量
  • 用const修饰形参:func(const int a){};在当前函数中不能被更改,只能读不能写
  • 用const修饰类成员函数:该函数对成员变量只能进行只读操作,就是const类成员函数是不能修改成员变量的数值的。
const int *temp;    // 指向常整型数的指针   只能操作指针,不能写值
int * const temp;   // 指向整型数的常指针   只能操作值,不能改变指针指向

10、volatile作用和用法

面试问题:volatile 有什么作用?能保证线程安全吗?

推荐回答:volatile 告诉编译器变量可能在当前代码控制之外变化,因此访问时不能使用寄存器缓存或省略读写。常用于硬件寄存器以及中断与主循环共享状态。但它不保证原子性、互斥关系、访问顺序或多核缓存一致性,因此不能替代锁、临界区、原子操作和内存屏障。

追问关键词:禁止特定优化;不等于原子;不等于线程安全

一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量在内存中的值,而不是使用保存在寄存器里的备份(虽然读写寄存器比读写内存快)。

简单的来说,设置了volatile之后,编译器就不会对该变量进行优化。

这些情况一般会使用volatile:

  • 并行设备的硬件寄存器(如:状态寄存器)
  • 一个中断服务子程序中会访问到的非自动变量
  • 多线程应用中被几个任务共享的变量

一般就是对寄存器的状态值、中断和多线程中的共享数据变量。

10.1 基本作用

volatile 告诉编译器:

该对象的值可能被当前程序正常控制流之外的因素改变,因此每次访问都必须按照程序要求真正执行,不能简单使用寄存器中的旧值,也不能随意删除相关访问。

示例:

volatile unsigned int *status_reg =
    (volatile unsigned int *)0x40000000U;

每次读取 *status_reg 都应访问对应硬件地址。

10.2 常见使用场景

内存映射硬件寄存器

#define UART_STATUS_REG \
    (*(volatile unsigned int *)0x40001000U)

中断与主循环共享的简单标志

static volatile int g_data_ready = 0;

void UART_IRQHandler(void)
{
    g_data_ready = 1;
}

int main(void)
{
    while (1)
    {
        if (g_data_ready)
        {
            g_data_ready = 0;
            process_data();
        }
    }
}

被硬件或 DMA 修改的内存

某些情况下,编译器必须知道内存可能被外部执行单元修改。但 DMA 与 Cache 一致性问题不能仅靠 volatile 解决。

10.3 volatile 不能保证什么

volatile 不保证:

  • 原子性。
  • 互斥。
  • 线程安全。
  • 操作顺序。
  • CPU 内存屏障。
  • 多核 Cache 一致性。
  • 防止数据竞争。

例如:

volatile int counter = 0;

counter++;

counter++ 可能被拆成:

读取
加1
写回

多个线程同时执行时仍然可能丢失更新。

10.4 多线程应使用的机制

  • C11 _Atomic
  • 互斥锁。
  • 读写锁。
  • 信号量。
  • 条件变量。
  • 平台提供的内存屏障。

10.5 volatile const

硬件只读寄存器常写成:

volatile const unsigned int *status_reg;

含义:

  • 硬件可以改变该值。
  • 软件不能通过该指针修改它。

11、内存四区,什么变量分别存储在什么区域,堆上还是栈上。

file

  • 代码区(text segment):存放程序执行代码的区域,通常是只读的。该区域的内容在程序执行时不能被修改。
  • 数据区(data segment):存放已经初始化的全局变量和静态变量(包括全局和静态变量的指针)的区域。
  • BSS区(bss segment):存放未初始化的全局变量和静态变量的区域,该区域的值默认初始化为0。
  • 栈区(stack segment):存放函数调用时的局部变量、函数参数和返回地址等信息。栈空间是由操作系统自动分配和回收的,它的大小通常是固定的,不能随意增加。栈空间是向下增长的,也就是说,栈顶的地址是越来越小的。
  • 堆区(heap segment):存放由程序员手动申请的内存空间,大小可以动态增加或减少。堆空间是由程序员手动管理的,程序员需要负责在使用完毕后将其释放。堆空间是向上增长的,也就是说,堆顶的地址是越来越大的。

12、c语言编译的四个阶段

  • 预处理阶段(Preprocessing):编译器会处理源文件,包括展开宏定义、头文件的展开、条件编译等,生成一个经过预处理后的文本文件。此阶段的结果是一个以 .i 为扩展名的文件。
  • 编译阶段(Compilation):编译器将经过预处理的文本文件翻译成汇编代码。汇编代码是一种低级的、与机器相关的语言。此阶段的结果是一个以 .s 为扩展名的文件。
  • 汇编阶段(Assembly):汇编器将汇编代码转换成机器可以执行的指令。此阶段的结果是一个以 .o 为扩展名的文件。
  • 链接阶段(Linking):连接器将目标文件以及一些必要的库文件进行链接,生成可执行文件。此阶段的结果是一个没有扩展名的可执行文件。

12.1 预处理

处理内容:

  • #include
  • #define
  • 条件编译。
  • 宏展开。
  • 删除注释。
  • 行号和文件信息处理。

命令:

gcc -E main.c -o main.i

输出通常为 .i 文件。

12.2 编译

把预处理后的 C 代码转换为汇编代码,同时完成:

  • 语法分析。
  • 语义分析。
  • 类型检查。
  • 中间表示生成。
  • 优化。
  • 目标相关代码生成。

命令:

gcc -S main.i -o main.s

输出通常为 .s 文件。

12.3 汇编

把汇编代码转换为目标文件:

gcc -c main.s -o main.o

目标文件中包含:

  • 机器指令。
  • 符号表。
  • 重定位信息。
  • 各种段。
  • 调试信息。

12.4 链接

链接器将多个目标文件和库组合为可执行文件:

gcc main.o module.o -o app

链接过程包括:

  • 符号解析。
  • 地址分配。
  • 重定位。
  • 合并代码段和数据段。
  • 链接静态库或动态库。
  • 生成最终入口点。

12.5 静态链接与动态链接

静态链接

库代码被复制到最终可执行文件中。

优点:

  • 部署简单。
  • 不依赖目标系统对应动态库。

缺点:

  • 文件较大。
  • 多个程序之间不能共享库代码页。
  • 库升级后通常需要重新链接。

动态链接

程序运行时加载共享库:

libxxx.so

优点:

  • 可执行文件较小。
  • 多个进程可共享库。
  • 库可独立升级。

缺点:

  • 依赖目标系统库版本。
  • 可能出现符号或 ABI 不兼容。

12.6 常用分析工具

nm app
readelf -h app
readelf -S app
objdump -d app
ldd app
size app

13、请解释一下什么是中断,以及中断服务程序是如何工作的

就是打断当前正在执行的程序,去执行另一个程序,称为中断服务程序。中断可以由硬件设备或软件发起。
当一个中断被触发时,CPU会立即停止正在执行的程序,并保存当前的上下文信息。然后,CPU会跳转到中断服务程序的入口地址开始执行。中断服务程序会处理中断请求,并根据需要执行相应的操作,例如读取键盘输入、处理磁盘读写等。当中断服务程序执行完毕后,CPU会恢复之前保存的上下文信息,继续执行被打断的程序。
中断服务程序的工作流程包括以下几个步骤:
1、中断请求:硬件设备或软件发起中断请求。
2、中断响应:CPU立即停止正在执行的程序,保存当前的上下文信息,并跳转到中断服务程序的入口地址。
3、中断处理:中断服务程序处理中断请求,并根据需要执行相应的操作。
4、中断返回:中断服务程序执行完毕后,CPU恢复之前保存的上下文信息,继续执行被打断的程序。

13、什么是中断,中断服务程序如何工作

中断是 CPU 响应硬件或软件事件、暂时打断当前执行流并转去执行对应处理程序的一种机制。

13.1 中断来源

硬件中断

例如:

  • 定时器到期。
  • UART 收到数据。
  • CAN 收到报文。
  • GPIO 边沿变化。
  • DMA 传输完成。
  • ADC 转换完成。

软件异常或软件中断

例如:

  • 系统调用。
  • 非法指令。
  • 除零。
  • 内存访问异常。
  • SVC 指令。

13.2 中断处理流程

外设产生中断请求
        ↓
中断控制器判断使能和优先级
        ↓
CPU完成当前必要阶段
        ↓
保存部分上下文
        ↓
根据中断向量跳转到ISR
        ↓
读取并处理中断源
        ↓
清除中断标志
        ↓
恢复上下文
        ↓
返回原执行流或触发调度

不能简单说 CPU “立即”响应,因为响应时间受以下因素影响:

  • 中断是否使能。
  • 全局中断是否屏蔽。
  • 当前中断优先级。
  • 是否有更高优先级中断正在执行。
  • 当前指令和硬件流水线。
  • 临界区长度。

13.3 中断向量表

中断向量表保存异常和中断入口地址。

Cortex-M 启动时通常包含:

  • 初始主栈指针 MSP。
  • Reset_Handler。
  • NMI_Handler。
  • HardFault_Handler。
  • SysTick_Handler。
  • 各外设 IRQHandler。

13.4 中断优先级

常见概念:

  • 抢占优先级。
  • 子优先级或响应优先级。
  • 中断嵌套。
  • 优先级分组。

具体定义取决于芯片和 NVIC 配置。

13.5 ISR 编写原则

中断服务程序应:

  • 尽量短。
  • 快速读取外设数据。
  • 及时清除中断标志。
  • 避免长时间循环。
  • 避免阻塞。
  • 避免动态内存分配。
  • 避免大量日志。
  • 将复杂处理交给任务或主循环。

推荐结构:

中断服务程序
    ↓
读取硬件数据
    ↓
放入环形缓冲区/队列
    ↓
通知处理任务
    ↓
退出中断

13.6 共享变量注意事项

中断和主程序共享简单标志时通常使用 volatile

static volatile int g_rx_ready = 0;

但复杂数据访问还需要:

  • 临界区。
  • 原子操作。
  • 禁止中断。
  • 双缓冲。
  • 无锁队列。

volatile 不能保证复合操作原子性。

14、TCP/IP协议

TCP/IP 是一组网络协议的总称,通常分为四层。

14.1 TCP/IP 四层模型

层次 主要协议 主要作用
应用层 HTTP、MQTT、DNS、DHCP、SSH 面向应用提供服务
传输层 TCP、UDP 进程到进程通信
网络层 IP、ICMP、IGMP 跨网络寻址和路由
网络接口层 Ethernet、Wi-Fi、ARP 局域网传输和物理介质访问

与 OSI 七层模型对应时只是近似关系,不能机械一一等同。

14.2 MAC 地址与 IP 地址

MAC 地址

  • 工作在链路层。
  • 常见长度为 48 位。
  • 用于同一二层网络中的帧传输。

IP 地址

  • 工作在网络层。
  • IPv4 长度为 32 位。
  • 用于跨网段寻址和路由。

14.3 ARP

ARP 用于在 IPv4 局域网中根据目标 IP 地址查询对应 MAC 地址。

基本过程:

主机A不知道目标MAC
        ↓
广播ARP Request
        ↓
目标主机回复ARP Reply
        ↓
A缓存IP与MAC映射

Linux 查看邻居表:

ip neigh

14.4 子网掩码与网关

子网掩码用于判断目标 IP 是否与本机处于同一子网。

例如:

本机:192.168.10.20/24
目标:192.168.10.100

二者都属于:

192.168.10.0/24

可以直接 ARP 通信。

如果目标不在同一子网,数据通常发送给默认网关。

查看路由:

ip route

14.5 ICMP

ICMP 用于网络控制和错误报告。

常见工具:

ping 192.168.10.1
traceroute 8.8.8.8

ping 使用 ICMP Echo Request 和 Echo Reply。

目标不响应 ping 不一定代表网络完全不通,也可能是防火墙禁止 ICMP。

14.6 TCP

TCP 的主要特点:

  • 面向连接。
  • 可靠传输。
  • 按序交付。
  • 丢包重传。
  • 流量控制。
  • 拥塞控制。
  • 全双工。
  • 字节流。

三次握手

客户端                        服务端
  | -------- SYN -----------> |
  | <----- SYN + ACK -------- |
  | -------- ACK -----------> |

为什么不是两次:

  • 双方都需要确认对方的发送和接收能力。
  • 需要协商初始序列号。
  • 避免旧连接请求造成错误连接。

四次挥手

主动关闭方                    被动关闭方
  | -------- FIN -----------> |
  | <------- ACK ------------ |
  | <------- FIN ------------ |
  | -------- ACK -----------> |

TCP 是全双工,两个方向需要分别关闭。

TCP 粘包和拆包

TCP 不保留应用层消息边界。

发送端:

send(fd, "ABC", 3, 0);
send(fd, "DEF", 3, 0);

接收端可能一次读到:

ABCDEF

也可能分多次读到。

应用层应设计协议:

固定长度
或
消息头 + 长度 + 数据
或
分隔符协议

TCP 可靠性机制

  • 序列号。
  • ACK。
  • 校验和。
  • 超时重传。
  • 快速重传。
  • 滑动窗口。
  • 流量控制。
  • 拥塞控制。

14.7 UDP

UDP 的特点:

  • 无连接。
  • 保留数据报边界。
  • 不保证到达。
  • 不保证顺序。
  • 不保证不重复。
  • 头部开销较小。
  • 延迟通常较低。

适用场景:

  • 实时音视频。
  • CAN/传感器低延迟传输。
  • 局域网广播。
  • DNS。
  • 自定义可靠协议。

如果需要可靠 UDP,需要应用层增加:

  • 序列号。
  • ACK。
  • 超时重传。
  • 去重。
  • 乱序重排。
  • 心跳。
  • 流量控制。

14.8 TCP 与 UDP 对比

对比项 TCP UDP
连接 面向连接 无连接
可靠性 可靠 不保证
顺序 保证 不保证
消息边界 不保留 保留
开销 较大 较小
常见场景 文件、控制、HTTP 实时数据、广播

14.9 Socket 基本流程

TCP 服务端

socket
  ↓
bind
  ↓
listen
  ↓
accept
  ↓
recv/send
  ↓
close

TCP 客户端

socket
  ↓
connect
  ↓
send/recv
  ↓
close

UDP

socket
  ↓
bind(接收方通常需要)
  ↓
sendto/recvfrom
  ↓
close

14.10 网络字节序

网络协议通常使用大端字节序。

常用函数:

htons();
htonl();
ntohs();
ntohl();

分别用于主机字节序与网络字节序转换。

14.11 常用排查命令

ip addr
ip link
ip route
ip neigh
ping
ss -lntup
tcpdump -i eth0
ethtool eth0
traceroute
nslookup

15、IIC和SPI协议

I²C 是一种同步、串行、半双工、多主多从总线。

常见信号:

  • SCL:时钟线。
  • SDA:数据线。

15.1.1 为什么需要上拉电阻

I²C 通常使用开漏或开集电极输出:

  • 设备只能主动拉低总线。
  • 高电平由上拉电阻产生。
  • 多个设备可以安全连接到同一总线。

上拉电阻过大:

  • 上升沿过慢。
  • 高频通信可能失败。

上拉电阻过小:

  • 低电平电流过大。
  • 功耗增加。
  • 可能超过器件灌电流能力。

15.1.2 起始和停止条件

START

SCL 为高电平时,SDA 从高变低。

STOP

SCL 为高电平时,SDA 从低变高。

15.1.3 数据传输

数据通常按 8 位发送,之后跟随第 9 个时钟:

  • ACK:接收方拉低 SDA。
  • NACK:接收方不拉低 SDA。

常见写寄存器流程:

START
  ↓
从机地址 + Write
  ↓
ACK
  ↓
寄存器地址
  ↓
ACK
  ↓
数据
  ↓
ACK
  ↓
STOP

常见读寄存器流程:

START
  ↓
从机地址 + Write
  ↓
寄存器地址
  ↓
Repeated START
  ↓
从机地址 + Read
  ↓
读取数据
  ↓
主机发送NACK
  ↓
STOP

15.1.4 地址

常见为 7 位地址。

很多数据手册同时给出:

  • 7 位地址。
  • 左移一位后的 8 位读写地址。

例如 7 位地址:

0x50

在总线上发送时:

写地址:0xA0
读地址:0xA1

不同 HAL API 对参数要求不同,必须确认函数要求传 7 位地址还是已左移地址。

15.1.5 时钟拉伸

从机如果暂时来不及处理,可以主动拉低 SCL,延长时钟低电平时间,这叫 Clock Stretching。

主机控制器必须支持并正确处理。

15.1.6 多主仲裁

多个主机同时发送时,通过监测 SDA 电平进行仲裁。

发送高电平但读到低电平的主机失去仲裁并停止发送。

15.1.7 总线卡死恢复

常见现象:

SDA一直为低电平

可能是从机在一次未完成通信中等待后续时钟。

常见恢复方法:

  1. 将 SCL 临时切换为 GPIO。
  2. 输出 9 个或更多时钟脉冲。
  3. 产生 STOP 条件。
  4. 重新初始化 I²C 控制器。
  5. 必要时复位从设备。

15.1.8 常见故障

  • 地址错误。
  • 7 位和 8 位地址混淆。
  • 没有上拉电阻。
  • 上拉阻值不合适。
  • 主从电压不匹配。
  • 从机未供电。
  • 总线频率过高。
  • SDA/SCL 接反。
  • 时钟拉伸未处理。
  • 中断标志未清除。

15.2 SPI

SPI 是一种同步串行通信接口,通常为主从结构。

常见信号:

  • SCLK:时钟。
  • MOSI:主机输出、从机输入。
  • MISO:主机输入、从机输出。
  • CS/NSS:片选。

15.2.1 特点

  • 通常全双工。
  • 没有统一的设备地址机制。
  • 每个从机一般需要独立片选。
  • 时钟由主机产生。
  • 速率通常高于 I²C。
  • 没有标准 ACK 机制。
  • 协议格式由具体器件定义。

15.2.2 CPOL 和 CPHA

  • CPOL:时钟空闲电平。
  • CPHA:在哪个边沿采样数据。
模式 CPOL CPHA 空闲电平 常见采样边沿
Mode 0 0 0 第一个边沿
Mode 1 0 1 第二个边沿
Mode 2 1 0 第一个边沿
Mode 3 1 1 第二个边沿

必须以从设备数据手册为准。

15.2.3 全双工特性

SPI 每产生一个时钟:

  • MOSI 发送一位。
  • MISO 同时返回一位。

即使主机只想读取数据,也通常需要发送 Dummy Byte:

uint8_t rx = spi_transfer(0xFF);

15.2.4 片选时序

典型过程:

CS拉低
  ↓
发送命令
  ↓
发送地址
  ↓
读写数据
  ↓
CS拉高

有些器件要求一次事务期间 CS 不能拉高。

15.2.5 常见故障

  • CPOL/CPHA 错误。
  • 位序 MSB/LSB 配置错误。
  • 数据宽度不同。
  • CS 时序不正确。
  • 时钟频率过高。
  • 主从没有共地。
  • MISO 没有正确三态。
  • 电平电压不兼容。
  • DMA Cache 一致性问题。

15.3 I²C 与 SPI 对比

对比项 I²C SPI
信号线 2 根 通常 4 根以上
通信方式 半双工 通常全双工
地址 总线内置地址 通常用片选
多从机 方便 需要多个片选
速度 中低速 通常更高
ACK 通常没有
硬件复杂度 较低 较高
常见设备 传感器、EEPROM Flash、ADC、屏幕

16 、请解释arr和psc和ccr的区别与使用

arr 是自动重装载值,用于定时器的计数和自动重装载。

PSC 是预分频器 分频器寄存器,用于定时器时钟的分频。

ccr 捕获/比较器 用于定时器的捕获和比较。PWM

16.1 PSC:预分频器

PSC 用于将定时器输入时钟分频:

计数器时钟频率 =
定时器输入时钟 / (PSC + 1)

例如:

定时器时钟 = 72 MHz
PSC = 71

则:

计数器频率 = 72 MHz / 72 = 1 MHz
单次计数时间 = 1 μs

16.2 ARR:自动重装载寄存器

ARR 决定计数周期。

向上计数时:

0 → 1 → ... → ARR → 重新从0开始

因此一轮包含:

ARR + 1

个计数周期。

更新事件频率:

f_update =
f_timer / ((PSC + 1) × (ARR + 1))

16.3 CCR:捕获/比较寄存器

CCR 的意义取决于定时器工作模式。

输出比较模式

当计数器 CNT 与 CCR 相等时触发动作:

  • 翻转输出。
  • 置位。
  • 清零。
  • 产生中断。

PWM 模式

CCR 通常决定占空比。

在常见 PWM 模式 1、向上计数、有效高电平情况下:

占空比 ≈ CCR / (ARR + 1)

输入捕获模式

当输入边沿到来时:

CCR = 当前CNT值

可用于测量:

  • 脉冲宽度。
  • 输入频率。
  • 周期。
  • 占空比。

16.4 APB 定时器时钟注意事项

某些 STM32 系列中:

  • 当 APB 预分频为 1 时,定时器时钟等于 APB 时钟。
  • 当 APB 预分频不为 1 时,定时器时钟可能等于 APB 时钟的 2 倍。

必须查看具体芯片时钟树和参考手册。

17、PWM计算公式

file

定时器计数频率:

f_cnt =  f_timer / (PSC + 1)

PWM 频率:

f_pwm = f_timer / ((PSC + 1) × (ARR + 1))

常见 PWM 模式 1、向上计数时:

Duty = CCR / (ARR + 1) × 100%

17.2 示例

要求:

定时器时钟:72 MHz
PWM频率:1 kHz
计数分辨率:1 μs
占空比:25%

设置:

PSC = 71

得到:

f_cnt = 72 MHz / 72 = 1 MHz

一个 PWM 周期需要:

1 MHz / 1 kHz = 1000 个计数

因此:

ARR = 999

25% 占空比:

CCR = 250

17.3 中心对齐模式

中心对齐时,计数器通常:

0 → ARR → 0

周期比单向计数更长,频率公式通常近似为:

f_pwm =
f_timer /
(2 × (PSC + 1) × ARR)

具体公式需要结合芯片计数规则和更新事件设置。

17.4 输出极性

占空比还受以下设置影响:

  • PWM Mode 1 / Mode 2。
  • 高有效或低有效。
  • 互补输出。
  • 死区。
  • 主输出使能 MOE。

不能只看 CCR 数值判断实际引脚高电平占比。

17.5 常见问题

  • 忘记 PSC + 1
  • 忘记 ARR + 1
  • APB 定时器时钟判断错误。
  • CCR 大于 ARR。
  • GPIO 复用配置错误。
  • 没有启动定时器通道。
  • 高级定时器没有使能 MOE。
  • 中心对齐和边沿对齐公式混淆。

18、内存,RAM,ROM,Cache的区别与联系

18.1 Memory 与 Storage

日常语境中:

  • Memory 通常指运行内存。
  • Storage 通常指长期存储。

计算机体系结构中,存储层次大致为:

寄存器
  ↓
L1 Cache
  ↓
L2 Cache
  ↓
L3 Cache
  ↓
RAM
  ↓
SSD / eMMC / UFS / HDD

越靠近 CPU:

  • 速度越快。
  • 容量通常越小。
  • 单位成本越高。

18.2 RAM

RAM 是随机存取存储器,可在任意位置读写。

典型特点:

  • 用于程序运行期间保存代码和数据。
  • 读写速度较快。
  • 一般掉电丢失。

常见类型:

SRAM

  • 不需要周期刷新。
  • 速度快。
  • 成本高。
  • 密度低。
  • MCU 片上 RAM 常见。

DRAM

  • 需要刷新。
  • 密度高。
  • 容量大。
  • 成本相对低。
  • PC 内存和 SoC 外部 DDR 常见。

18.3 ROM

ROM 原意是只读存储器,但实际存在多个类型:

  • Mask ROM。
  • PROM。
  • EPROM。
  • EEPROM。
  • Flash。

现代系统中的程序存储通常使用 Flash,它可以擦写,并非真正只能读取。

18.4 Flash

Flash 是非易失半导体存储器,掉电后数据不丢失。

常见类型:

  • NOR Flash:适合随机读取和代码执行。
  • NAND Flash:容量大,适合数据存储。
  • eMMC:NAND + 控制器。
  • UFS:高性能嵌入式存储。
  • SSD:NAND + 控制器。

MCU 内部程序存储通常也是 Flash。

18.5 “手机 ROM”的说法

手机参数中:

8 GB RAM + 256 GB ROM

其中 256 GB 通常是 UFS 或 eMMC Flash 存储,不是严格意义上的只读 ROM。

18.6 Cache

Cache 是位于 CPU 与主存之间的高速缓存。

CPU 访问数据时:

先查Cache
   ↓
命中:直接返回
未命中:从下一级存储加载

常见分类:

  • L1 I-Cache。
  • L1 D-Cache。
  • L2 Cache。
  • L3 Cache。

18.7 Cache 写策略

Write Through

写 Cache 时同时写入下一级存储。

优点:

  • 一致性简单。

缺点:

  • 写流量较大。

Write Back

先只修改 Cache,并标记为 Dirty,之后再写回内存。

优点:

  • 性能较高。

缺点:

  • 一致性管理更复杂。

18.8 DMA 与 Cache 一致性

DMA 直接访问内存,不经过 CPU Cache,可能出现:

CPU 写了新数据,但还在 Cache

DMA 读到旧内存数据。

处理方法:

发送前 Clean / Flush Cache

DMA 写入了新数据,但 CPU Cache 仍保存旧副本

CPU 读到旧数据。

处理方法:

接收后 Invalidate Cache

仅使用 volatile 不能解决 Cache 一致性问题。

18.9 硬盘与 SSD

不能笼统说所有“硬盘”都由磁性材料制作:

  • HDD 使用磁性介质。
  • SSD 使用 NAND Flash。

CPU 通常不会像访问普通 RAM 一样直接执行块设备上的数据,程序和数据一般要先加载到内存,再由 CPU 使用。

19、野指针和空指针、万能指针 void*、const 与指针

1、野指针

任意数值赋值给指针变量没有意义,因为这样的指针就成了野指针,此指针指向的区域是未知(操作系统不允许操作此指针指向的内存区域)。所以,野指针不会直接引发错误,操作野指针指向的内存区域才会出问题

#include <stdio.h>

int main()
{
    int a = 100;
    int* p;
    // 给指针变量p赋值,p为野指针
    p = a; 
    // 给指针变量p赋值,p为野指针
    p = 0x12345678; 
    // 对野指针指向的未知区域进行写操作,内存出问题,error
    *p = 1000;  
    return 0;
}

2、空指针

就是内存空间上的一个地址,这个地址是0x0

int a;
int *p =NULL; ->p就会指向安全区域。 p = &a;

3、万能指针 void*

void* 是一个特殊的指针类型,用来表示一个指向未知类型的指针。它可以存储任何类型的地址,但无法直接解引用或操作其指向的数据。

它可以用于在没有明确类型信息的情况下表示指针。例如,当你需要在函数中传递一个指针,但不确定指针所指向的数据类型时,可以使用 void* 作为参数类型。

使用 void* 类型时,需要注意的是,在使用 void 指针进行操作之前,必须将其转换为适当的指针类型,以便进行正确的解引用和操作。这是因为 void 指针在不确定指向的具体类型时无法进行类型推断。

#include <stdio.h>

int main()
{
    void* p = NULL;
    int a = 10;
    // 指向变量时,最好转换为void *
    p = (void*)&a; 

    //使用指针变量指向的内存时,转换为int *
    *((int*)p) = 11;
    printf("a = %d\n", a);

    return 0;
}

4、const 与指针

在定义指针的时候可以添加const关键字, 根据const关键字的位置可以用其修饰指针本身也可以用来修饰指针指向的值:

  1. 常量指针:const 关键字在 * 左边,常量指针的本质是指针,表示指针所指向的地址可变,但是地址中的数据不能被修改。例如:

    C++
    const int* ptr;  // ptr 是一个指向 int 类型常量的指针
    int const *ptr;  // 等价于 const int* ptr;

    在这个例子中,ptr 是一个指向 int 类型常量的指针。这意味着不能通过 ptr 修改它所指向的整数值,但该指针指向的地址是可以改变的

  2. 指针常量: const 关键字在 * 右边,表示指针指向的地址不能被修改,但是地址中的值可以被修改。例如:

    C++
    int value = 10;
    // ptr 是一个常量指针,指向 int 类型的变量 value, 不能被重新赋值
    int* const ptr = &value;  

    在这个例子中,ptr 是一个指针常量,指向了变量 value。这意味着不能通过 ptr 修改指针的值,即不能将 ptr 指向其它地址,但可以通过 *ptr 来修改所指向的变量的值。

    注意:指针常量在定义时要赋初值。

下面有几句口诀,方便大家记忆常量指针和指针常量:

  • const (*号)左边放,我是指针变量指向常量 - 常量指针
  • const (*号)右边放,我是指针常量指向变量 - 指针常量
  • const (*号)两边放,我是指针常量指向常量 - 常量指针常量

指针变量能改指向,指针常量不能转向,要是全部变成常量,锁死了,我不能转向,你也甭想变样!

#include <stdio.h>

int main()
{
    // 常量指针
    int value1 = 100;
    int value2 = 200;
    const int* ptr = &value1;   // 初始化
    ptr = &value2;  // ok, 可以修改指针指向的内存地址
    *ptr = 300;     // error, 不能修改指针指向的地址中的值

    // 指针常量
    int value = 10;
    int* const ptr1 = &value;   // 初始化
    *ptr1 = 20;     // ok, 可以修改指针指向的地址中的值
    ptr1 = &value1; // error, 不能修改指针指向的内存地址

    // 指向常量的指针常量
    const int* const ptr2 = &value2;
    *ptr2 = 99;     // error, 不能修改指针指向的地址中的值
    ptr2 = &value;  // error, 不能修改指针指向的内存地址

    return 0;
}

const 修饰的指针变量可以帮助确保数据的不可变性和程序的安全性,特别是在函数参数传递、返回值和常量数据的处理方面有广泛的应用。在使用 const 修饰的指针时,需要注意遵守 const 修饰符的规则,以便正确使用和理解指针的行为。

20、STM32中systick延时毫秒和微秒

系统定时器为内核中的一个外设。24位,只能递减,存在于内核嵌套在NVIC中,所有的Cortex-M内核的单片机都具有这个定时器。

系统定时器 是一个 24bit 的向下递减的计数器,计数器每计数一次的时间为 1/SYSCLK us,一般我们设置 系统时钟 SYSCLK 等于 180M。当重装载数值寄存器的值递减到 0 的时候,系统定时器就 产生一次中断,以此循环往复。

分析代码(正点原子)节选一部分

没有操作系统的

#include "delay.h"
static uint32_t fac_us=0;                           //us延时倍乘数
//初始化延迟函数
//当使用ucos的时候,此函数会初始化ucos的时钟节拍
//SYSTICK的时钟固定为AHB时钟
//SYSCLK:系统时钟频率
void delay_init(uint8_t SYSCLK)
{
    HAL_SYSTICK_CLKSourceConfig(SYSTICK_CLKSOURCE_HCLK);//SysTick频率为HCLK
    fac_us=SYSCLK;                      //不论是否使用OS,fac_us都需要使用
}       
//延时nus
//nus为要延时的us数.  
//nus:0~190887435(最大值即2^32/fac_us@fac_us=22.5)   
void delay_us(uint32_t nus)
{       
    uint32_t ticks;
    uint32_t told,tnow,tcnt=0;
    uint32_t reload=SysTick->LOAD;               //LOAD的值  就是重装载寄存器           
    ticks=nus*fac_us;                       //需要的节拍数  就是要先计算延时多少us对应的这个节拍数
    told=SysTick->VAL;                       //刚进入时的计数器值  保存进入函数的systick的节拍数,就是取出当前systick的寄存器的值
    while(1)
    {
        tnow=SysTick->VAL;       //不断获取这个systick的节拍书
        if(tnow!=told)          // 判断这个当前的节拍和我之前记录的节拍数对比,因为是向下梯减所以看你会出现当前的节拍大于我记录的节拍
        {       
            if(tnow<told)tcnt+=told-tnow;    //这里注意一下SYSTICK是一个递减的计数器就可以了.
            else tcnt+=reload-tnow+told;        // 如果大于就是这样算,可以参考如果我当前的节拍为150、我刚进入的节拍told=100,重装载值为200,按道理计算是100+(200-150)
            told=tnow;
            if(tcnt>=ticks)break;            //时间超过/等于要延迟的时间,则退出.
        }  
    };
}
//延时nms
//nms:要延时的ms数
void delay_ms(uint16_t nms)
{
    uint32_t i;
    for(i=0;i<nms;i++) delay_us(1000);
}

21、FREERTOS的任务状态和优先级


优先级数字越低表示任务的优先级越低, 0 的优先级最低, configMAX_PRIORITIES-1 的优先级最高。空闲任务的优先级最低,为 0。

21.1 任务状态

Running:运行态

当前正在 CPU 上执行的任务。

单核系统同一时刻通常只有一个任务处于运行态。

Ready:就绪态

任务已经具备运行条件,但正在等待调度器分配 CPU。

Blocked:阻塞态

任务正在等待某个事件或时间条件,例如:

  • vTaskDelay()
  • 等待队列。
  • 等待信号量。
  • 等待任务通知。
  • 等待事件组。
  • 等待流缓冲区。

阻塞态任务不会持续占用 CPU。

Suspended:挂起态

任务被显式挂起:

vTaskSuspend(task_handle);

恢复:

vTaskResume(task_handle);

挂起态不会因为超时自动回到就绪态。

Deleted:删除状态

任务调用:

vTaskDelete(task_handle);

任务资源通常由空闲任务回收,因此空闲任务必须有机会运行。

21.2 状态转换

Ready
  ↓ 调度器选中
Running
  ↓ 等待事件/延时
Blocked
  ↓ 条件满足
Ready

其他转换:

Running → Suspended
Suspended → Ready
Running → Deleted

21.3 优先级

FreeRTOS 中通常:

0:最低优先级
configMAX_PRIORITIES - 1:最高优先级

空闲任务优先级为 0。

调度器总是选择最高优先级的就绪任务运行。

21.4 抢占式调度

当更高优先级任务进入就绪态时,如果启用抢占:

当前低优先级任务被抢占
        ↓
高优先级任务立即运行

配置项:

#define configUSE_PREEMPTION 1

21.5 同优先级时间片

启用时间片时,同优先级任务可轮流运行:

#define configUSE_TIME_SLICING 1

但只有在同优先级任务都处于就绪态时才有意义。

21.6 任务延时

相对延时

vTaskDelay(pdMS_TO_TICKS(100));

每次从调用时刻开始延时,可能产生周期漂移。

绝对周期延时

TickType_t last_wake =
    xTaskGetTickCount();

for (;;)
{
    execute_periodic_task();

    vTaskDelayUntil(&last_wake,
                    pdMS_TO_TICKS(10));
}

适合固定周期任务。

21.7 优先级设计原则

  • 中断后需要快速处理的任务优先级较高。
  • 长时间计算任务不应一直占据最高优先级。
  • 高优先级任务应尽快阻塞。
  • 不要依赖优先级替代正确同步。
  • 防止低优先级任务长期得不到运行机会。
  • 关注空闲任务是否能执行资源回收。

21.8 任务栈

每个任务都有独立栈。

常见栈溢出原因:

  • 局部大数组。
  • 深度递归。
  • printf()
  • 浮点库。
  • 复杂函数调用。
  • 中断嵌套。

可启用:

#define configCHECK_FOR_STACK_OVERFLOW 2

并实现:

void vApplicationStackOverflowHook(
    TaskHandle_t task,
    char *task_name)
{
}

也可使用:

uxTaskGetStackHighWaterMark();

检查剩余栈空间。

22、PendSV 中断的作用

PendSV 是 Cortex-M 中的可挂起系统异常,FreeRTOS 通常使用它完成任务上下文切换。

22.1 为什么使用 PendSV

PendSV 可以:

  • 由软件主动挂起。
  • 设置为最低异常优先级。
  • 等待其他高优先级中断全部完成后再执行。
  • 避免在外设中断处理中直接完成复杂上下文切换。

22.2 FreeRTOS 中三个关键异常

SVC

通常用于启动第一个任务或执行特权服务。

SysTick

用于产生 RTOS 系统节拍:

  • 更新时间。
  • 检查延时任务。
  • 判断是否需要调度。

PendSV

真正执行任务上下文切换。

22.3 典型切换流程

SysTick到来
    ↓
调度器发现更高优先级任务就绪
    ↓
设置PendSV挂起位
    ↓
SysTick退出
    ↓
高优先级外设中断先执行
    ↓
最后进入PendSV
    ↓
保存当前任务上下文
    ↓
切换pxCurrentTCB
    ↓
恢复下一个任务上下文
    ↓
异常返回到新任务

22.4 Cortex-M 自动保存的寄存器

异常进入时,硬件通常自动压栈:

  • R0。
  • R1。
  • R2。
  • R3。
  • R12。
  • LR。
  • PC。
  • xPSR。

FreeRTOS 的 PendSV 处理程序通常还需要软件保存:

  • R4~R11。
  • 可选浮点寄存器。
  • 当前 PSP。

22.5 任务控制块 TCB

TCB 中通常保存任务的栈顶指针。

切换过程简化为:

保存当前PSP到当前TCB
        ↓
选择下一个任务TCB
        ↓
从新TCB取出PSP
        ↓
恢复新任务寄存器

22.6 PendSV 为什么设置为最低优先级

如果在外设 ISR 尚未完成时立即切换任务,可能使中断嵌套和共享数据状态变复杂。

设置最低优先级后:

  • 先完成所有紧急中断。
  • 最后统一进行任务切换。
  • 降低上下文切换对中断实时性的影响。

23、在FreeRTOS中,二值信号量和互斥量的区别?

  • 互斥型信号量必须是同一个任务申请,同一个任务释放,其他任务释放无效。同一个任务可以递归申请。
  • 二进制信号量,一个任务申请成功后,可以由另一个任务释放。

二值信号量和互斥量内部状态都可能表现为 0/1,但语义和使用规则不同。

23.1 二值信号量

主要用于:

  • 事件通知。
  • 中断通知任务。
  • 任务间同步。

例如:

UART中断收到数据
      ↓
释放二值信号量
      ↓
处理任务被唤醒

创建:

SemaphoreHandle_t sem =
    xSemaphoreCreateBinary();

中断释放:

BaseType_t higher_priority_woken =
    pdFALSE;

xSemaphoreGiveFromISR(
    sem,
    &higher_priority_woken);

portYIELD_FROM_ISR(
    higher_priority_woken);

23.2 互斥量

主要用于保护共享资源:

  • 全局配置。
  • 链表。
  • 文件。
  • 总线。
  • 非线程安全驱动。

创建:

SemaphoreHandle_t mutex =
    xSemaphoreCreateMutex();

使用:

if (xSemaphoreTake(
        mutex,
        pdMS_TO_TICKS(100)) == pdTRUE)
{
    access_shared_resource();

    xSemaphoreGive(mutex);
}

23.3 主要区别

对比项 二值信号量 互斥量
主要用途 事件同步 资源互斥
所有权 无严格所有权 有持有者
谁释放 其他任务或 ISR 可释放 应由持有任务释放
优先级继承 不支持 支持
ISR 使用 支持 FromISR 不允许在 ISR 获取或释放
递归 不支持 普通互斥量不支持

23.4 递归互斥量

普通互斥量不能由同一个任务重复获取。

需要递归锁时应创建:

SemaphoreHandle_t mutex =
    xSemaphoreCreateRecursiveMutex();

获取和释放必须使用:

xSemaphoreTakeRecursive();
xSemaphoreGiveRecursive();

递归获取多少次,就必须释放多少次。

23.5 选择原则

保护共享资源 → 互斥量
通知事件发生 → 二值信号量
统计多个资源 → 计数信号量
单任务快速通知 → 任务通知

24、优先级反转

当一个互斥信号量正在被一个低优先级的任务使用,而此时有个高优先级的任务也尝试获取这个互斥信号量的话就会被阻塞。不过这个高优先级的任务会将低优先级任务的优先级提升到与自己相同的优先级, 这个过程就是优先级继承。优先级继承尽可能的降低了高优先级任务处于阻塞态的时间,并且将已经出现的“优先级翻转”的影响降到最低。

24.1 什么是优先级反转

假设有三个任务:

  • H:高优先级。
  • M:中优先级。
  • L:低优先级。

执行过程:

L获得互斥锁
    ↓
H运行并请求该锁
    ↓
H因为锁被L持有而阻塞
    ↓
M抢占L并长时间运行
    ↓
L无法运行,也无法释放锁
    ↓
H被间接阻塞

结果是:

高优先级任务 H 实际上被中优先级任务 M 延迟。

这种现象叫优先级反转。

24.2 优先级继承

互斥量可使用优先级继承缓解问题:

H等待L持有的互斥锁
        ↓
L临时继承H的优先级
        ↓
L抢占M并尽快运行
        ↓
L释放互斥锁
        ↓
L恢复原优先级
        ↓
H获得锁继续运行

需要区分:

优先级反转:问题
优先级继承:缓解措施

24.3 优先级继承的局限

优先级继承不能解决所有问题:

  • 锁嵌套复杂时分析困难。
  • 长临界区仍会造成高优先级阻塞。
  • 非互斥量同步通常没有优先级继承。
  • 可能出现链式继承。

24.4 其他措施

  • 缩短临界区。
  • 避免在持锁期间阻塞。
  • 减少锁嵌套。
  • 统一锁顺序。
  • 使用优先级天花板协议。
  • 使用消息传递替代共享资源。
  • 由专用任务统一管理硬件资源。

25、freertos的任务通知

任务通知在 FreeRTOS 中是一个可选的功能,要使用任务通知的话就需要将宏configUSE_TASK_NOTIFICATIONS 定义为 1

任务通知的发送使用函数 xTaskNotify()或者 xTaskNotifyGive()(还有此函数的中断版本)来完 成 , 这 个 通 知 值 会 一 直 被 保 存 着 , 直 到 接 收 任 务 调 用 函 数 xTaskNotifyWait() 或 者ulTaskNotifyTake()来获取这个通知值。假如接收任务因为等待任务通知而阻塞的话那么在接收到任务通知以后就会解除阻塞态。任务通知虽然可以提高速度,并且减少 RAM 的使用,但是任务通知也是有使用限制的:

  • FreeRTOS 的任务通知只能有一个接收任务,其实大多数的应用都是这种情况。
  • 接收任务可以因为接收任务通知而进入阻塞态,但是发送任务不会因为任务通知发送失败而阻塞。

    1
BaseType_t xTaskNotify( TaskHandle_t xTaskToNotify,uint32_t ulValue,eNotifyAction eAction )
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的。
ulValue: 任务通知值。
eAction: 任务通知更新的方法, eNotifyAction 是个枚举类型,在文件 task.h 中有如下
typedef enum
{
eNoAction = 0,
eSetBits, //更新指定的 bit
eIncrement, //通知值加一
eSetValueWithOverwrite, //覆写的方式更新通知值
eSetValueWithoutOverwrite //不覆写通知值
} eNotifyAction;

BaseType_t xTaskNotifyFromISR( TaskHandle_t xTaskToNotify,
uint32_t ulValue,
eNotifyAction eAction,
BaseType_t * pxHigherPriorityTaskWoken );
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的。
ulValue: 任务通知值。
eAction: 任务通知更新的方法。
pxHigherPriorityTaskWoken: 记退出此函数以后是否进行任务切换,这个变量的值函数会自动
设置的,用户不用进行设置,用户只需要提供一个变量来保存这
个值就行了。当此值为 pdTRUE 的时候在退出中断服务函数之
前一定要进行一次任务切换。

BaseType_t xTaskNotifyGive( TaskHandle_t xTaskToNotify );
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的

void vTaskNotifyGiveFromISR( TaskHandle_t xTaskHandle,
BaseType_t * pxHigherPriorityTaskWoken );
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的。
pxHigherPriorityTaskWoken: 记退出此函数以后是否进行任务切换,这个变量的值函数会自动
设置的,用户不用进行设置,用户只需要提供一个变量来保存这
个值就行了。当此值为 pdTRUE 的时候在退出中断服务函数之
前一定要进行一次任务切换。

BaseType_t xTaskNotifyAndQuery ( TaskHandle_t xTaskToNotify,
uint32_t ulValue,
eNotifyAction eAction
uint32_t * pulPreviousNotificationValue);
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的。ALIENTEK 阿波罗 FreeRTOS 开发教程
319
STM32F429 FreeRTOS 开发手册
ulValue: 任务通知值。
eAction: 任务通知更新的方法。
pulPreviousNotificationValue:用来保存更新前的任务通知值。

BaseType_t xTaskNotifyAndQueryFromISR ( TaskHandle_t xTaskToNotify,
uint32_t ulValue,
eNotifyAction eAction,
uint32_t * pulPreviousNotificationValue
BaseType_t * pxHigherPriorityTaskWoken );
参数:
xTaskToNotify: 任务句柄,指定任务通知是发送给哪个任务的。
ulValue: 任务通知值。
eAction: 任务通知更新的方法。
pulPreviousNotificationValue:用来保存更新前的任务通知值。
pxHigherPriorityTaskWoken: 记退出此函数以后是否进行任务切换,这个变量的值函数会自动
设置的,用户不用进行设置,用户只需要提供一个变量来保存这
个值就行了。当此值为 pdTRUE 的时候在退出中断服务函数之
前一定要进行一次任务切换。

uint32_t ulTaskNotifyTake( BaseType_t xClearCountOnExit,TickType_t xTicksToWait );
参数:
xClearCountOnExit: 参数为 pdFALSE 的话在退出函数 ulTaskNotifyTake()的时候任务通知值减一,类似计数型信号量。当此参数为 pdTRUE 的话在退出函数的时候任务任务通知值清零,类似二值信号量。
xTickToWait: 阻塞时间

BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry,uint32_t ulBitsToClearOnExit,uint32_t * pulNotificationValue,TickType_t xTicksToWait );
参数:
ulBitsToClearOnEntry: 当没有接收到任务通知的时候将任务通知值与此参数的取反值进行按位与运算,当此参数为 0xffffffff 或者 ULONG_MAX 的时候就会将任务
通知值清零。
ulBitsToClearOnExit: 如果接收到了任务通知,在做完相应的处理退出函数之前将任务通知值与此参数的取反值进行按位与运算,当此参数为 0xffffffff 或者
ULONG_MAX 的时候就会将任务通知值清零。
pulNotificationValue: 此参数用来保存任务通知值。
xTickToWait: 阻塞时间。

1、任务通知模拟二值信号量实验

二值信号量就是值最大为 1 的信号量,这也是名字中“二值”的来源。当任务通知用于替代二值信号量的时候任务通知值就会替代信号量值,函数 ulTaskNotifyTake()就可以替代信号量获取函数 xSemaphoreTake(),函数 ulTaskNotifyTake()的参数 xClearCountOnExit 设置为 pdTRUE。这样在每次获取任务通知的时候模拟的信号量值就会清零。函数 xTaskNotifyGive()和vTaskNotifyGiveFromISR()用于替代函数 xSemaphoreGive()和 xSemaphoreGiveFromISR()。接下来我们通过一个实验来演示一下任务通知是如何用作二值信号量的。

2、任务通知模拟计数型信号量实验

当任务通知用作计数型信号量的时候获取信号量相当于获取任务通知值,使用函数ulTaskNotifyTake()来替代函数 xSemaphoreTake()。函数 ulTaskNotifyTake()的参数 xClearOnExit要设置为 pdFLASE,这样每次获取任务通知成功以后任务通知值就会减一。使用任务通知发送函 数 xTaskNotifyGive() 和 vTaskNotifyGiveFromISR() 来 替 代 计 数 型 信 号 量 释 放 函 数xSemaphoreGive()和 xSemaphoreGiveFromISR()。下面通过一个实验来演示一下任务通知是如何用作计数型信号量。

----------------------

任务通知是 FreeRTOS 为每个任务提供的轻量级通知机制。

与队列、信号量相比:

  • 速度更快。
  • RAM 占用更少。
  • 直接发送给指定任务。
  • 不需要单独创建内核对象。

25.1 配置

通常需要:

#define configUSE_TASK_NOTIFICATIONS 1

新版本 FreeRTOS 支持任务通知数组,通知条目数量由配置控制:

#define configTASK_NOTIFICATION_ARRAY_ENTRIES 1

不能再绝对说每个任务只能有一个通知值,应以所用 FreeRTOS 版本为准。

25.2 通知状态

每个通知条目通常包含:

  • 一个 32 位通知值。
  • 一个通知状态。

通知值可以用于:

  • 二值信号量。
  • 计数信号量。
  • 事件位。
  • 邮箱值。
  • 简单状态传递。

25.3 发送 API

xTaskNotify()

BaseType_t xTaskNotify(
    TaskHandle_t task,
    uint32_t value,
    eNotifyAction action);

常见 eNotifyAction

  • eNoAction:只更新通知状态。
  • eSetBits:按位或设置事件位。
  • eIncrement:通知值加 1。
  • eSetValueWithOverwrite:覆盖旧值。
  • eSetValueWithoutOverwrite:未处理旧值存在时不覆盖。

xTaskNotifyGive()

BaseType_t xTaskNotifyGive(
    TaskHandle_t task);

通常使目标任务通知值加 1。

ISR 版本

BaseType_t xTaskNotifyFromISR(
    TaskHandle_t task,
    uint32_t value,
    eNotifyAction action,
    BaseType_t *higher_priority_woken);
void vTaskNotifyGiveFromISR(
    TaskHandle_t task,
    BaseType_t *higher_priority_woken);

退出 ISR 前:

portYIELD_FROM_ISR(
    higher_priority_woken);

25.4 接收 API

ulTaskNotifyTake()

uint32_t ulTaskNotifyTake(
    BaseType_t clear_count_on_exit,
    TickType_t ticks_to_wait);

当:

clear_count_on_exit = pdTRUE

成功获取后通知值清零,类似二值信号量。

当:

clear_count_on_exit = pdFALSE

成功获取后通知值减 1,类似计数信号量。

xTaskNotifyWait()

BaseType_t xTaskNotifyWait(
    uint32_t bits_to_clear_on_entry,
    uint32_t bits_to_clear_on_exit,
    uint32_t *notification_value,
    TickType_t ticks_to_wait);

适合等待:

  • 事件位。
  • 邮箱值。
  • 状态标志。

25.5 模拟二值信号量

接收任务:

void receiver_task(void *argument)
{
    (void)argument;

    for (;;)
    {
        ulTaskNotifyTake(
            pdTRUE,
            portMAX_DELAY);

        process_event();
    }
}

发送任务:

xTaskNotifyGive(receiver_handle);

中断发送:

void UART_IRQHandler(void)
{
    BaseType_t higher_priority_woken =
        pdFALSE;

    clear_uart_interrupt();

    vTaskNotifyGiveFromISR(
        receiver_handle,
        &higher_priority_woken);

    portYIELD_FROM_ISR(
        higher_priority_woken);
}

25.6 模拟计数信号量

接收任务:

uint32_t count =
    ulTaskNotifyTake(
        pdFALSE,
        portMAX_DELAY);

每次成功获取后通知值减 1。

25.7 模拟事件组

发送:

#define EVENT_RX     (1UL << 0)
#define EVENT_ERROR  (1UL << 1)

xTaskNotify(task_handle,
            EVENT_RX,
            eSetBits);

接收:

uint32_t events = 0;

xTaskNotifyWait(
    0,
    UINT32_MAX,
    &events,
    portMAX_DELAY);

if ((events & EVENT_RX) != 0U)
{
    process_rx();
}

if ((events & EVENT_ERROR) != 0U)
{
    process_error();
}

25.8 使用限制

  • 通知直接绑定目标任务,不适合多个接收者竞争同一消息。
  • 不适合传输复杂或大块数据。
  • 发送方通常不能因为目标未接收而像队列一样自动阻塞等待。
  • 一旦需要保存多条独立数据,应考虑队列或流缓冲区。

26、GPIO的常见寄存器的配置

1、库函数

1、BRR

拉低电平
#define LED_RB_LOW (LED_RB_PORT->BRR = LED_RB_PIN)

2、BSRR

就是设置成高电平

#define IO_UART10_RXD_HIG (IO_UART10_RXD_PORT->BSRR = IO_UART10_RXD_PIN)

SRR寄存器的作用是允许你同时设置或清除GPIO引脚的输出状态,而不会影响其他引脚的状态。这是通过将寄存器中的特定位组合设置为1(置位)或0(清零)来实现的。

在BSRR寄存器中,低16位用于设置要置位的引脚,高16位用于设置要清零的引脚。通过在寄存器中写入数据,你可以同时对多个引脚进行操作,这种并行设置或清零的方式可以提高GPIO引脚控制的效率。

GPIOA->BSRR = GPIO_PIN_0 | GPIO_PIN_1; // 置位引脚0和引脚1
GPIOA->BSRR = GPIO_PIN_2 << 16 | GPIO_PIN_3 << 16; // 清零引脚2和引脚3
以下以 STM32 常见 GPIO 结构为例,不同系列寄存器名称可能略有不同。

26.1 MODER:模式寄存器

每个引脚通常使用 2 位配置:

00:输入
01:通用输出
10:复用功能
11:模拟

示例:

GPIOA->MODER &=
    ~(3UL << (5U * 2U));

GPIOA->MODER |=
    (1UL << (5U * 2U));

将 PA5 配置为输出。

26.2 OTYPER:输出类型

常见:

  • 0:推挽输出。
  • 1:开漏输出。

推挽:

  • 可以主动输出高低电平。
  • 驱动能力强。

开漏:

  • 通常只能主动拉低。
  • 高电平依靠上拉电阻。
  • 常用于 I²C、多设备线与。

26.3 OSPEEDR:输出速度

控制 GPIO 输出边沿速度。

速度越高:

  • 边沿越快。
  • EMI 可能越大。
  • 功耗可能增加。
  • 对布线要求更高。

不应无条件设置为最高速度。

26.4 PUPDR:上下拉

常见:

00:无上下拉
01:上拉
10:下拉
11:保留

输入悬空时可能受到噪声干扰,应根据电路配置上下拉。

26.5 IDR:输入数据寄存器

读取引脚实际输入状态:

if ((GPIOA->IDR & GPIO_PIN_0) != 0U)
{
    /* PA0为高电平 */
}

26.6 ODR:输出数据寄存器

可以读写输出锁存值:

GPIOA->ODR |= GPIO_PIN_5;
GPIOA->ODR &= ~GPIO_PIN_5;

但这种读—改—写在中断或并发环境下可能产生竞争。

26.7 BSRR:原子置位和复位

BSRR 常见结构:

  • 低 16 位写 1:对应引脚置位。
  • 高 16 位写 1:对应引脚复位。
  • 写 0:无影响。

置高 PA5:

GPIOA->BSRR = GPIO_PIN_5;

拉低 PA5:

GPIOA->BSRR =
    ((uint32_t)GPIO_PIN_5 << 16U);

同时置位和复位不同引脚:

GPIOA->BSRR =
    GPIO_PIN_0 |
    ((uint32_t)GPIO_PIN_1 << 16U);

优点:

  • 单次寄存器写入。
  • 通常是原子操作。
  • 不影响其他 GPIO 位。
  • 优于对 ODR 进行读—改—写。

26.8 BRR:复位寄存器

部分 STM32 系列存在独立 BRR:

GPIOA->BRR = GPIO_PIN_5;

并不是所有系列都具有相同形式,必须查看对应参考手册。

26.9 AFR:复用功能寄存器

配置 UART、SPI、I²C、定时器、CAN 等复用功能。

通常:

  • AFRL:引脚 0~7。
  • AFRH:引脚 8~15。

除了设置 GPIO 为复用模式,还必须配置正确 AF 编号。

26.10 输出宏示例

#define LED_ON()                         \
    do                                   \
    {                                    \
        GPIOA->BSRR = GPIO_PIN_5;        \
    } while (0)

#define LED_OFF()                        \
    do                                   \
    {                                    \
        GPIOA->BSRR =                    \
            ((uint32_t)GPIO_PIN_5        \
             << 16U);                    \
    } while (0)

如果 LED 是低电平点亮,则宏含义需要反过来,必须结合原理图。

26.11 GPIO 常见排查步骤

确认GPIO时钟已使能
        ↓
确认引脚复用是否冲突
        ↓
确认MODER模式
        ↓
确认上下拉和输出类型
        ↓
确认AF编号
        ↓
读取IDR/ODR判断软件状态
        ↓
示波器测量实际电平
        ↓
检查外部上拉、负载和电压

附录一、常见面试速答

1. volatile 能保证线程安全吗

不能。它只约束编译器对该对象访问的优化,不提供原子性、互斥和内存同步。

2. static 局部变量是否不能修改

可以修改。它只初始化一次,并在整个程序运行期间存在。

3. memcpy() 能处理重叠内存吗

不能。重叠区域应使用 memmove()

4. 空指针是否指向安全区域

不是。空指针不指向任何有效对象,不能解引用。

5. TCP 为什么会粘包

TCP 是字节流,不保留应用层消息边界。应用层必须自行设计分帧协议。

6. I²C 为什么需要上拉

I²C 通常使用开漏输出,设备只能主动拉低,高电平由上拉电阻形成。

7. SPI 如何选择 Mode

根据从设备数据手册要求配置 CPOL 和 CPHA。

8. PWM 频率如何计算

边沿对齐常见公式:

f_pwm =
f_timer /
((PSC + 1) × (ARR + 1))

9. FreeRTOS 为什么用 PendSV 切换任务

PendSV 可设为最低优先级,保证高优先级中断处理完后再统一进行上下文切换。

10. 二值信号量和互斥量的核心区别

二值信号量用于事件同步,没有严格所有权;互斥量用于保护共享资源,具有所有权和优先级继承。

11.大小端是什么,通信时为什么不能直接发结构体?

推荐回答:小端把低有效字节放在低地址,大端把高有效字节放在低地址。跨平台通信除了字节序,还存在结构体对齐、填充、编译器 ABI 和字段宽度差异。直接发送结构体内存可能导致协议不一致,因此应按协议逐字段序列化,并明确整数宽度、字节序、缩放和校验。 追问关键词:字节序;padding;ABI;序列化

博客内容均系原创,未经允许严禁转载!
您可以通过 RSS 订阅本站文章更新,订阅地址:https://blognas.hwb0307.com/feed/什么是 RSS ?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇