# 写在前面
很偶然的机会,前任 leader 发出邀请,让我去海康萤石尝试一下,说是一个基于 Nas 的本地云影视存储项目缺人。一看 JD, 啥都不匹配,硬着头皮面了一下
问的问题还是比较基础的,但是奈何我电控干的只知道六路波形了,叹气,测试害人。再加上我自己紧张和准备不充分,导致概念模糊,没有调理,基本上是凉了
就当是跳槽第一战
# 面试经历
还是老样子,先来了一个自我介绍,一开始就没讲好。介绍了一下无线节点,技术面顺着问了一下 NRF2401 是如何通信并识别子节点的,如何识别节点 ID 的.
接下去问了 RT-Thread 的主要功能,线程调度方式有几种 (这个似乎不止两种,或许我没有 get 到他的意思), 有接着追问了优先级反转的问题,问什么情况会出现,要如何避免 (这个我完全没有想到,之前之前似乎遇到过)
继续又问了 RT-Thread 的内存管理机制,我着实没有研究过就说自己不会了
接下去又问了 ESP8266 的作用,对 MQTT 的理解,问了一下 MQTT 依靠什么保证实时性 (MQTT 的传输层协议是什么,现在回想起来真就是慌乱)
后面问到了 C 语言,内存分区,static 的作用 (这个说了半天乱七八糟的,没有调理,属实离谱)
还问了一下野指针出现的情况 (因该是说对一种情况,误把空间未释放也理解为了野指针)
最后问了一下是否了解编译过程,这应该是后期唯一一个回答的好
其实问题都很基础,并不是很难,也没有什么特别高深的算法问题,还是自己基础不够扎实,过于紧张,一旦问到不会的就会出现头脑不清晰
需要夯实基础,认清自己的不足,补上漏洞和基础知识,了解面试术语
这次的小结需要更加深入一些,不能只是走过场,需要深挖到为什么,并且有解决方案
# 面试知识点小结
# NRF24L01
NRF24L01 是工作在 2.4GHz~2.5GHz 的 ISM 频段的单片无线收发器芯片. 2.4~2.5G 意味着可以调频
# 一对一
一组 NRF24L01 通信条件:
- 发射接收数据宽度相同 (最大 32 字节)
- 发射接收地址相同 (5 个 8 位地址)
- 发射接收频道相同 (0~125)
- 发射接收速率相同 (250k 1M 2M)
# 一对多
如果存在多个子节点,那么如果都按 1 对 1 方案配置必然会产生冲突,最后导致数据全部丢失
一般来说解决这个问题有两个方案:
- 调频
- 不同地址
# 调频
如果使用频率不同 (优点为实现简单,缺点是在接收端要不断的进行频率切换才能轮询的接收各个发送端的数据) 进行多对一通信的话,那就配置成一对一通信模式,然后发送端的发射频率设置不同
调频一般适合超过 6 个节点的情况,将每一个 2401 放置在一个频段,但是当频道相近的时候就会出现数据串扰
举个例子:现有 5 个点需要测量,网关在机房.
思路:
- 主机主动给第一个节点发送请求包
- 从机收到请求后回复数据
- 主机收到数据后切频道,向第二个发出请求
这样就可以实现实时的数据上报了
# 地址
使用相同频率,使用不同的通道和地址 (优点是使用相同频率,不用切换频率,自动识别来自哪个发送端的数据,缺点是配置较多)
这里有一个比较关键的问题: 自动识别是如何实现的?
从文献中可以获取的信息是:数据包中携带地址,然后通过寄存器来判别地址位
接收到有效的数据包后 (地址匹配、CRC 校验正确), 数据存储在 RX_FIFO 中,同时 RX_DR 位置高,并产生中断.
状态寄存器中 RX_P_NO 位显示数据是由哪个通道接收到的.
nrf 数据如下图
实现思路与调频的一致
# RT-thread
RT-Thread, 全称是 Real Time-Thread, 顾名思义,它是一个嵌入式实时多线程操作系统,基本属性之一是支持多任务,允许多个任务同时运行并不意味着处理器在同一时刻真地执行了多个任务
相较于 Linux、uCos、freertos 而言,RT-thread 更小,更迅捷
# 主要功能
RT-Thread 架构分为三大层:内核层、组件与服务层、软件包
技术面提问应该是想要问内核层
内核层包含:
- 多线程及其调度
- 信号量
- 邮箱
- 消息队列
- 内存管理
- 定时器
# 调度算法
RT-Thread 的线程调度算法有:
- 时间片轮转
- 优先级调度
rt-thread 如何实时确定各个优先级列表中是否存在已经就绪的 task?
很显然,rt-thread 不可能从头遍历一遍最高优先级到最低优先级.
尤其是当优先级分布在两端的时候,中间的查询就是浪费的
为了解决这种问题,更高效的调度任务. rt-thread 使用了线程就绪表 rt_thread_ready_table
这个线程就绪表本质上是一个 32 位的变量,通过位运算来确定哪一个优先级上存在就绪序列
时间片轮转一般用于相同优先级下的
通过 CPU 的 SysTick
生成时基,每个线程有自己的生存周期,当生存周期耗尽或者程序执行完毕,时间片才会被回收
# 优先级反转
# 什么是
优先级反转 (priority inversion), 是高优先级任务与低优先级任务共享临界资源时,低优先级任务锁住临界资源,进而导致高优先级等待,但低优先级执行时却被其它比低优先级任务高的任务抢占 CPU 资源,进而导致低优先级任务无法完成,高优先级任务等待,优先级介于低优先级和高优先级之间的任务先于高优先级任务运行的现象
优先级反转图示:
如图,红色线程为高优先级,绿色为低,黄色为介于两个之间的中间优先级,红绿存在一个临界资源
当黄色线程未就绪时,一切正常.
当黄色线程就绪,并且绿色线程锁住临界资源时,就会出现红色线程因无法获取临界资源而挂起,绿色线程因被黄色线程抢占而执行时间变长,进而形成了黄色线程优于红色线程先执行的现象.
这种现象就叫优先级反转
# 危害
最经典的例子就是 火星探路者.
其实,在没出问题之前,所有计算机概念都是理论性的,很难理解背后的逻辑
(内心:就是优先级反转而已,有啥大不了的)
但优先级反转是存在很大的危害以及隐患的
如果出现优先级反转还能正常运行,只能说是项目对系统的实时性要求不高
对于嵌入式 rtos 开发来说,我们需要确保每一个任务执行时间是可预测的
如果出现了优先级反转,导致低优先级和高优先级运行时间不可以预测,就无法实的检测设备的运行状态,甚至有可能出现线程饿死,进而导致程序崩溃
# 解决方案
- 禁止所有中断 (以保护临近区)
当使用,禁止所有中断,来避免优先级反转时,需要满足下面的条件:
只存在两种优先级:
- 可被抢占的
- 中断已禁止的
- priority inheritance 优先级继承
简单来说就是,让低优先级将被在使用临界资源的时候拥有与高优先级任务一样的优先级,当释放临界资源之后再进行恢复
# 经历
优先级反转,其实在智能车上遇到过,可惜当时想不起来具体现象了.
当时摄像头优先级为 7, 图像处理进程为 5, 电流采集为 4.
出现了,oled 显示图像卡死的问题,本质上,就是电流采集线程抢占了摄像头进程的时间,进而导致图像处理线程饿死
当时解决方案是降低了电流采集的优先级,同时将摄像头传感器的优先级升高来解决这个问题
# 与中断嵌套的区别
中断嵌套是低优先级被打断,高优先级先执行,本身和优先级调度类似,但是没有时间片限制
中断任务在未完成之前只会等待,同级和低级任务不会抢占,只会被高级剥夺,所以本身不会出现优先级反转
# 内存管理
RT-Thread 的内存管理分文两大类: 动态内存堆管理和静态内存池管理
动态内存管理算法分为三类
- 小内存管理算法
- slab 内存管理算法
- memheap 管理算法
这几类内存堆管理算法在系统运行时只能选择其中之一
# 小内存管理算法
小内存管理算法主要针对系统资源比较少,一般用于小于 2MB 内存空间的系统
# 原理
- 开辟一块大内存,大小为
MEM_SET
, 初始内存如下图 - 当需进行分配时,大块的内存中切割出相应的大小.
每个内存块都包含一个数据头用于控制,使用区域和未使用区域通过双向链表连接.
如下图
# 数据头
数据头一般包含两个成员: magic 和 used.
- used - 表示当前内存块是否被使用
- magic - 一般会初始化为 0x1ea0 (即英文单词 heap), 用于标记这个内存块是一个内存管理用的内存数据块
有些时候幻数也被用于检测内存是否被异常访问 (正常情况下只有内存管理器才能访问这块空间)
# 分配实例
如下图,当需要再分配 64 字节时, lfree
指针当前指向的位置不足 64, 此时 lfree 指针就会向后查找,直到找到下一个足够大的空闲块.
如果空闲块大于内存块,则会将内存卡进行拆分. 128 字节就会被划分走 12+64 字节,余下 54 字节作为空闲节点挂载回链表
# 关于 GC
释放时则是相反的过程,分配器会查看前后相邻的内存块是否空闲,如果空闲则合并 成一个大的空闲内存块.
# slab 管理算法
# 概念
slab 内存管理算法则主要是在系统资源比较丰富时,提供了一种近似多内存池管理算法的快速算法
RT-Thread 的 SLAB 分配器是在 DragonFly BSD 创始人 Matthew Dillon 实现的 SLAB 分配器基础上,针对嵌入式系统优化的内存分配算法
PS: 最原始的 SLAB 算法是 Jeff Bonwick 为 Solaris 操作系统而引入的一种高效内核内存分配算法
# 概述
slab 分配器会根据对象的大小分成多个区,一个区可以视为一个内存池
一般来说一个内存池的大小为 32K~128K 字节之间,在堆初始化时会根据堆大小进行自动调整
系统中一个 Zone 最大可以包含 72 种对象,最大可以分配 16K 的空间,超出 16K 则直接从页分配器中分配.
每个 zone 上分配的内存块大小是固定的,能够分配相同大小内存块的 zone 会链接在一个链表中,而 72 种对象的 zone 链表则放在一个数组 (zone_array []) 中统一管理
# 内存分配
假设一个当前需要获取 64 字节内存.
- 查找 zone array 链表表头数组中查找是否有对应的 zone 链表
- 如果为空,则向页分配器分配一个新的 zone, 并从 zone 中返回第一个空闲空间
- 如果非空,那么第一个 zone 节点必然有空闲块,那么就取相应的空闲块
- 若完成分配后,zone 不再存在空闲块,那么需要将 zone 节点从列表中删除
# GC
- 分配器找到要释放内存所在的 zone 节点
- 将内存块链接到 zone 的空闲块链表中
- 释放完毕后,检测 zone 空闲链指出 zone 完全空闲,那么当空闲 zone 到达一定数目后,系统会将全空的 zone 自动释放到页面分配器中
# memheap 管理算法
memheap 方法适用于系统存在多个内存堆的情况,它可以将多个内存 “粘贴” 在一起,形成一个大的内存堆
# 原理
如下图,memheap, 会先将加入 memheap_item
中的链表进行黏合.
内存分配时优先从内存堆获取,当内存堆不足时去查找 memheap_item
链表
这样用户程序就可以避免考虑跨内存块的问题
ps: 这个算法可以单独拆出来再裸机上使用,不过需要优化
# 内存池 与 slab
内存池是一种内存分配方式,用于分配大量大小相同的小内存块,它可以极大地加快内存分配与释放的速度,且能尽量避免内存碎片化
而 slab 是内存池实现的一种策略
# MQTT
# 概念
MQTT 是一个基于客户端 - 服务器的消息发布 / 订阅传输协议
# 工作模式
MQTT
使用的发布 / 订阅消息模式,它提供了一对多的消息分发机制,从而实现与应用程序的解耦
这是一种消息传递模式, 消息不是直接从发送器发送到接收器 (即点对点),而是由 MQTT server
分发的
# 通信保障
MQTT 与 HTTP 一样,MQTT 运行在传输控制协议 / 互联网协议 (TCP/IP) 堆栈之上
一般来说客户端到服务器是通过 TCP 建立的连接
# Qos
其实面试官提问的 "服务器 - 客户端的通信保障是什么", 答案其实就是这个
三种
- Qos0: 这一级别会发生消息丢失或重复,消息发布依赖于底层 TCP/IP 网络
- Qos1: QoS 1 承诺消息将至少传送一次给订阅者
- Qos2: 保证消息仅传送到目的地一次,带有唯一消息 ID 的消息会存储两次,首先来自发送者,然后是接收者
# C 语言
# 内存分区
C 语言内存分为
- 栈区
- 由编译器自动分配
- 栈区内容只在函数范围内有效,结束则销毁
- 栈区按内存地址由高到低方向生长,其最大大小由编译时确定,速度快,但自由性差,最大空间小
- FIFO
- 堆区
- 由用户管理
- 堆区按内存地址由低到高方向生长,其大小由系统内存 / 虚拟内存上限决定,速度较慢,但自由性大,可用空间大.
- 全局区
- 通常是用于那些在编译期间就能确定存储大小的变量的存储区
- 用于的是在整个程序运行期间都可见的全局变量和静态变量
- 全局区有 .bss 段 和 .data 段组成,可读可写
- 未初始化的全局变量和未初始化的静态变量存放在.bss 段
- 初始化为 0 的全局变量和初始化为 0 的静态变量存放在.bss 段
- 常量区
- 字符串 、数字等常量存放在常量区
- const 修饰的全局变量存放在常量区
- 程序运行期间,常量区的内容不可以被修改
- 代码区
- 程序执行代码存放在代码区,其值不能修改(若修改则会出现错误)
- 字符串常量和 define 定义的常量 ** 也有可能 ** 存放在代码区
# 图示
# 堆和栈的区别
管理方式不同
- 栈由操作系统自动分配释放
- 堆的申请和释放工作由程序员控制
空间大小不同
- 每个进程拥有的栈大小要远远小于堆大小
生长方向不同
- 堆的生长方向向上,内存地址由低到高;
- 栈的生长方向向下,内存地址由高到低
分配方式不同
- 堆都是动态分配的,没有静态分配的堆.
- 栈有静态分配和动态分配
- 静态分配是由操作系统完成的,比如局部变量的分配
- 动态分配由 alloca () 函数分配,但是这里分配的空间无需程序员手动释放
分配效率不同
- 栈由操作系统自动分配,会在硬件层级对栈提供支持,效率比较高
- 堆则是由 C/C++ 提供的库函数或运算符来完成申请与管理,频繁的内存申请容易产生内存碎片
存放内容不同
- 栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等
- 堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的
# static
# 概述
static 是 C/C 中的关键字之一,是常见的函数与变量(C 中还包括类)的修饰符,它常被用来控制变量的存储方式和作用范围
# 修饰对象
- 修饰全局变量 (静态全局变量)
- 修饰局部变量 (静态局部变量)
- 修饰函数 (静态函数)
# 官方概念
1️⃣: 在函数中声明变量时, static 关键字指定变量只初始化一次,并在之后调用该函数时保留其状态
2️⃣: 在声明变量时,变量具有静态持续时间,并且除非您指定另一个值
3️⃣: 在全局和 / 或命名空间范围 (在单个文件范围内声明变量或函数时) static 关键字指定变量或函数为内部链接,即外部文件无法引用该变量或函数
4️⃣:static 关键字 没有赋值时,默认赋值为 0
5️⃣:static 修饰局部变量时,会改变局部变量的存储位置,从而使得局部变量的生命周期变长
# 个人理解
- 对于局部变量:
- 不改变作用域的情况下,延长变量生命周期
- 将未初始化的局部变量值设置为 0 (本质是存储到了全局,全局区的特性)
- 对于全局变量和函数
- 将函数变量和作用域作用域限制在当前文件内,取消其外部链接属性
- 将其对其他源文件隐藏起来,从而避免命名冲突 (本质上和上面一致)
# 野指针
# 什么是野指针
简单来说就是地址指向不可知的内存的指针
最典型的案例就是 int *ptr;
ptr
指向的地址不可知,当对 ptr 进行操作,就会产生奇怪的现象,最经典的就是 linux 上的段错误
# 出现情况
- 指针定义时未初始化
- 指针被释放时没有置空
我们在用malloc
开辟空间的时候,要检查返回值是否为空;
指针指向的内存空间在用 **free
和delete
** 释放后,如果程序员没有对其进行置空或者其他赋值操作的话,就会成为一个野指针 - ** 指针操作超越变量作用域:** 不要返回指向栈内存的指针或者引用,因为函数结束栈内空间就自动释放了
# 回调函数
# 定义
回调函数是一种特殊的函数,它作为参数传递给另一个函数,并在被调用函数执行完毕后被调用
PS: 概念扩充
- ** 回调:** 指被传入到另一个函数的函数.
- ** 异步编程:** 指在代码执行时不会阻塞程序运行的方式.
- ** 事件驱动:** 指程序的执行是由外部事件触发而不是顺序执行的方式.
# 使用场景
- 事件处理:回调函数可以用于处理各种事件,例如鼠标点击、键盘输入、网络请求等.
- 异步操作:回调函数可以用于异步操作,例如读取文件、发送邮件、下载文件等.
- 数据处理:回调函数可以用于处理数据,例如对数组进行排序、过滤、映射等.
- 插件开发:回调函数可以用于开发插件,例如 WordPress 插件、jQuery 插件等.
# 实现方法
回调函数可以通过函数指针或函数对象来实现.
函数指针是一个变量,它存储了一个函数的地址。当将函数指针作为参数传递给另一个函数时,另一个函数就可以使用这个指针来调用该函数。函数指针的定义形式如下:
返回类型 (*函数指针名称)(参数列表)
例如,假设有一个回调函数需要接收两个整数参数并返回一个整数值,可以使用以下方式定义函数指针:
int (*callback)(int, int); |
然后,可以将一个实际的函数指针赋值给它,例如:
int add(int a, int b) { | |
return a + b; | |
} | |
callback = add; |
现在,可以将这个函数指针传递给其他函数,使得其他函数可以使用这个指针来调用该函数.
# 参考文献
[1] NRF24L01 一对多通信方法
[2] NRF24l01 一对一通信、多对一 (一对六、六发一收) 通信最终解决办法。亲测好用.
[3] NRF24L01 2.4G 无线模块浅析 (学习笔记)
[4] 【整理】什么是优先级反转 + 有何危害 + 如何避免和解决
[5] RT-Thread 学习笔记 —— 内存管理
[6] 《RT-Thread 内核实现与应用开发实战 — 基于 STM32》
[7] 内存管理
[8] C 语言:内存分配 --- 栈区、堆区、全局区、常量区和代码区_堆内存和栈内存图解 - CSDN 博客
[9] C/C++ static 关键字详解
大道五十,天衍四十九,人遁其一!