如果你是一个嵌入式面试官,你会问哪些问题?

1.volatile是否可以修饰const

(1)合法性

      “volatile”的含义并非是“non-const”,volatile 和 const 不构成反义词,所以可以放一起修饰一个变量。

(2)同时修饰一个变量的含义

     表示一个变量在程序编译期不能被修改且不能被优化;在程序运行期,变量值可修改,但每次用到该变量的值都要从内存中读取,以防止意外错误。

2.如何用一行代码操作硬件寄存器

假设你有一个寄存器 REG,其地址是 0x40001000,要将值 0x1 写入该寄存器,可以使用以下代码: *(volatile uint32_t *)0x40001000 = 0x1; // 写入寄存器 要读取寄存器的值,可以使用: uint32_t value = *(volatile uint32_t *)0x40001000; // 读取寄存器

3.如何快速比较两个寄存器内有多少位不同

#include <stdint.h> // 计算位中 1 的个数 uint32_t count_set_bits(uint32_t value) { uint32_t count = 0; while (value) { count += value & 1; value >>= 1; } return count; }

// 比较两个寄存器值,返回不同位的数量 uint32_t count_different_bits(uint32_t reg1, uint32_t reg2) { uint32_t xor_result = reg1 ^ reg2; // 找到不同的位 return count_set_bits(xor_result); // 统计这些位中 1 的个数 }

int main() { uint32_t reg1 = 0xABCD1234; // 示例寄存器值 uint32_t reg2 = 0xABCF1234; // 示例寄存器值

uint32_t different_bits = count_different_bits(reg1, reg2);
printf("Number of different bits: %u\n", different_bits);
​
return 0;

}

4.如何降低功耗

1.器件选型

基于成本的考虑,电路使用的元器件可能不是低功耗的最佳选择,如某些传感器,本身功耗就比较大,这时想通过软件降功耗就很麻烦了。最好选择那些可以配置的,存在低功耗模式的传感器。至于MCU,是显而易见的,肯定选一款功耗低,满足功能要求的,这些评审时自然会考虑到。一些8位MCU功耗是几十微安,睡眠模式1uA左右,成为首选。这类MCU最容易出现的就是资源有限,引脚个数少,如某款IC ROM只有1K,RAM32字节,这样最后的软件实现很可能捉襟见肘。

2.降低主频

众所周知,芯片主频越高,功耗越大。降功耗方案一般不使用外部晶振,使用内部晶振,频率选择常用的32768Hz虽然低,却只能得到秒一级别的精度,想得到ms或us级别的精度,大于1M的频率少不了。

3.睡眠模式

睡眠模式是降功耗的主要方式,MCU可以睡眠模式睡眠,模块也可以睡眠。在外部触发唤醒MCU之后,MCU再唤醒功耗更大的模块,完成功能或通信后,马上又进入睡眠,总之进入睡眠状态自然是省电的。一些模块存在多种睡眠模式,都是为了在不影响功能的前提下更加灵活地来降低功耗。

4.关闭未用资源

在使用稍复杂一点的MCU时,它本身所带的外设,未使用时一定关闭。使用简单的MCU时,可能所有的功能都是引脚模拟实现,如IIC,SPI,Uart之类,不过也要注意,进入睡眠停止工作之前,应将与之对应的传感器等器件关闭或使其进入PowerDown Mode,唤醒后再做初始化、配置的工作。

5.配置IO口

前面提到睡眠之前,关闭外部器件,你以为这样就可以了,其实未必。如果某些引脚接了外部上拉电阻,而MCU睡眠时该引脚置低,这样一来,有压差,有电阻,就形成了不必要的功耗。这点容易被忽略,所以各个引脚一定要根据外部电路合理配置。

6.间歇工作原则

所谓间歇工作,就是劳逸结合,工作休息交替进行,采用切电源的方式,开和关交替执行,这样该器件的功耗就降了一半。如果某器件上电后,需要预热一段时间,那这个方法就行不通了。还有一些电平驱动的元件,给一定占空比的脉冲就可以工作,还可以根据电压调整占空比,平衡负载,实现电源最大利用率,不过这又是一项复杂的工作了。

5.什么时候会用到do{}while(0)

do{...}while(0)的用法,超详解 - SuperThinker - 博客园 (cnblogs.com)

6.GPIO有几种状态

[GPIO口的八种工作状态_gpio有几种状态-CSDN博客

7.如何高效处理中断

  • 使用高优先级中断:传感器数据中断被分配为最高优先级中断,以确保及时响应。

  • 优化中断服务函数:中断服务函数被优化以最小化执行时间,仅执行必要的处理。

  • 使用中断屏蔽:在不处理传感器数据时屏蔽中断,以减少中断延迟。

  • 使用中断嵌套:允许其他中断打断传感器数据中断,以处理更紧急的任务。

8.delay和sleep的区别

sleep意为睡眠,即线程挂起,由定时器重新唤醒线程。sleep作用期间,该线程不占用CPU资源。

delay意为延迟,即线程等待,由线程自身进行循环查询,在设定时间之后退出循环。delay作用期间,该线程占用CPU资源。

在上位机,delay常可以和sleep组合使用,即循环查询中调用sleep,降低CPU占用率。sleep是由系统内核、固件库或特殊功能寄存器提供调用接口,而delay是程序员可以完全自己定义的一个循环函数,没有标准。sleep是由硬件提供的延时,如果要中断sleep,也需要通过相关的特殊功能寄存器进行操作。delay是软件提供的延时,可以使用任意内存空间作为信号使delay退出循环。

另外,wait和sleep具有相同的含义,都是由硬件提供的延时,但wait比sleep具有更多的功能。wait常用来表示可中断的延时。而sleep常用来表示不可中断的延时。Java中还有一个叫await的函数。由于Java中wait是Object的成员函数,Lock中只能换用一个近义词await。

关于wait的唤醒,Qt中是叫wakeOne和wakeAll,Java中是叫notify和notifyAll,Java中的await对应的唤醒函数是signal和signalAll。唤醒函数的命名似乎没有什么规律。一些脚本语言中的sleep和wait并没有对应的唤醒函数,而是使用键盘上的任意键唤醒。

9.中断时可否睡眠

在嵌入式系统中,中断处理函数(Interrupt Service Routine, ISR)中通常不允许调用可能导致睡眠或阻塞的函数。这是因为中断处理需要尽可能迅速地完成,以便让系统尽快返回到正常运行状态。

如果在中断中调用了可能导致睡眠的函数,如等待资源或延迟操作,这可能会引起以下问题:

  1. 中断延迟:系统可能无法及时处理其他中断,导致中断延迟增加。
  2. 死锁风险:如果中断处理中等待的资源被中断前的任务持有,可能会导致死锁。
  3. 调度问题:大多数嵌入式操作系统不允许在中断中进行任务调度,因此调用可能导致任务调度的函数会引发错误或不可预期的行为。

通常的做法是在中断处理函数中只执行必要的最小操作,如读取硬件寄存器或设置一个标志,然后在主循环或一个专门的任务中处理需要睡眠的操作。

这样可以确保中断处理函数保持简短且高效,不会影响系统的整体实时性能。

10.如何合理高效静态分配内存

在C语言中,合理且高效地进行静态内存分配可以确保程序运行的稳定性和性能。静态内存分配指的是在编译时确定变量的内存分配,而不是在运行时动态分配。以下是一些方法和建议:

1. 使用全局或静态变量

  • 全局变量:全局变量在程序的整个生命周期中分配一次内存。这种内存分配在程序启动时完成,直到程序结束时才释放。
  • 静态局部变量:使用static关键字修饰的局部变量在函数第一次调用时分配内存,且该内存在整个程序生命周期中保持不变。

优点

  • 不需要动态分配和释放内存的开销。
  • 避免了内存碎片问题。

缺点

  • 内存分配量在编译时固定,灵活性较差。
  • 在嵌入式系统或内存受限的环境中,可能占用过多的内存。

示例

int global_array[100]; // 全局变量 void function() { static int static_array[50]; // 静态局部变量 // 其他代码 } 

2. 使用固定大小的数组

  • 对于需要固定数量元素的容器,可以使用数组进行静态内存分配。数组大小在编译时确定,不会有运行时的分配和释放开销。

优点

  • 内存访问速度快,适合高性能要求的场景。

缺点

  • 不支持动态扩展,可能浪费内存或不够用。

示例

#define SIZE 100 int array[SIZE]; // 静态分配100个int的数组

3. 使用结构体和联合体

  • 结构体和联合体是将不同类型的数据组合在一起的有效方式。通过合理设计结构体,可以在一个内存块中静态分配多种类型的数据。

优点

  • 提高代码可读性和维护性。
  • 内存布局紧凑,有助于减少内存浪费。

示例

typedef struct { int id; char name[50]; float salary; } Employee; Employee emp; // 静态分配一个Employee结构体

4. 内存对齐

  • 确保数据在内存中的对齐方式符合目标平台的要求,可以提高访问效率。编译器通常会自动处理内存对齐,但你可以使用#pragma pack__attribute__((aligned))来手动控制。

示例

#pragma pack(1) typedef struct { char c; int i; } PackedStruct; // 禁用对齐,可能会影响性能但节省内存

5. 避免过度使用静态分配

  • 虽然静态分配内存有很多优点,但也要注意避免过度使用,特别是在嵌入式系统中。静态分配的大数组或结构体会占用大量内存,导致系统内存紧张。建议对实际需要静态分配的内存进行评估,合理分配。

6. 使用const关键字

  • 对于不可变的静态数据,使用const关键字可以确保数据不会被修改,同时让编译器进行优化。例如,常量字符串或查找表可以用const修饰。

示例

const char message[] = "Hello, World!"; // 静态分配不可变字符串

总结

静态内存分配在C语言中是管理内存的关键方式之一。通过全局或静态变量、固定大小数组、结构体和联合体,以及注意内存对齐和避免过度使用,可以实现高效、稳定的内存管理。合理规划内存分配有助于提升程序性能,减少内存碎片和内存泄漏的风险。

11.如何跟踪内存泄漏

一、什么是内存泄漏?
内存泄漏指的是程序中不再需要的内存未能被释放,导致内存使用量不断增加,最终可能耗尽所有可用内存。内存泄漏通常发生在手动内存管理的语言中,如 C 和 C++,但在自动内存管理语言如 Java 和 Python 中也可能发生。

二、常见的内存泄漏原因
1. 未释放的资源 :如文件句柄、网络连接等。
2. 缓存未清理 :长期未使用的缓存数据未能及时清理。
3. 静态集合类:如静态的 Map 或 List 保存了大量对象引用。
4. 循环引用:特别是在垃圾回收机制不完善的语言中。

三、内存泄漏的追踪工具
不同编程语言有不同的工具用于追踪内存泄漏:

Java: 使用工具如 VisualVM、YourKit、Eclipse Memory Analyzer (MAT)。
Python: 使用 tracemalloc 模块、objgraph 库。
C/C++: 使用工具如 Valgrind、AddressSanitizer。
四、内存泄漏的分析步骤
1. 确认内存泄漏
首先需要确认是否存在内存泄漏。可以通过以下方法:

监控内存使用:通过操作系统或应用程序提供的工具监控内存使用情况。如果内存使用量持续上升,可能存在内存泄漏。
日志分析:检查日志文件,看是否有内存不足的错误。
2. 获取内存快照
使用内存分析工具获取内存快照,以便后续分析。

Java: 使用 VisualVM 获取堆转储(Heap Dump)。

Python: 使用 tracemalloc 捕获内存分配快照。

C/C++: 使用 Valgrind 捕获内存分配信息。 https://www.cnblogs.com/const-zpc/p/16364424.html

3. 分析内存快照
使用内存分析工具分析内存快照,寻找可能的内存泄漏点。

Java: 使用 Eclipse MAT 加载堆转储文件,分析对象保留集和引用关系。

Python: 使用 tracemalloc 查看内存分配的对象和位置。

C/C++: 使用 Valgrind 的 memcheck 工具分析内存泄漏位置。

4.定位泄漏源

根据内存分析工具提供的信息,定位代码中导致内存泄漏的具体位置。常见方法包括:

检查长时间存在的对象:找出那些长时间驻留在内存中的对象,分析它们的生命周期。

分析对象引用链:通过分析对象之间的引用关系,找出未能释放的对象引用。

五、内存泄漏的修复
定位内存泄漏后,需对代码进行修改以修复问题。常见的修复方法包括:

1. 及时释放资源:确保所有资源(如文件、网络连接)在使用完毕后及时释放。
2. 清理缓存:定期清理缓存数据,避免长期驻留内存。
3. 优化集合类使用:避免使用静态集合类保存大量对象引用,或确保在不再需要时清空集合。
4. 解决循环引用:在语言支持的情况下,使用弱引用或手动断开引用链。

12.如何实现一个ring buffer以及用途

环形缓冲区(Ring Buffer)通常用于处理连续的数据流,例如音频处理、网络通信、日志记录等场景。环形缓冲区的特点是当它的末尾被填满时,新的数据会覆盖从头开始的旧数据,从而形成一个循环队列。

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

#define BUFFER_SIZE 10

typedef struct {
    int buffer[BUFFER_SIZE];
    int head;
    int tail;
    int size;
} RingBuffer;

// 初始化环形缓冲区
void initRingBuffer(RingBuffer *rb) {
    rb->head = 0;
    rb->tail = 0;
    rb->size = 0;
}

// 检查缓冲区是否为空
bool isEmpty(RingBuffer *rb) {
    return rb->size == 0;
}

// 检查缓冲区是否为满
bool isFull(RingBuffer *rb) {
    return rb->size == BUFFER_SIZE;
}

// 向缓冲区中添加元素
bool enqueue(RingBuffer *rb, int data) {
    if (isFull(rb)) {
        return false;  // 缓冲区已满
    }
    rb->buffer[rb->tail] = data;
    rb->tail = (rb->tail + 1) % BUFFER_SIZE;
    rb->size++;
    return true;
}

// 从缓冲区中移除元素
bool dequeue(RingBuffer *rb, int *data) {
    if (isEmpty(rb)) {
        return false;  // 缓冲区为空
    }
    *data = rb->buffer[rb->head];
    rb->head = (rb->head + 1) % BUFFER_SIZE;
    rb->size--;
    return true;
}

// 获取缓冲区中的元素个数
int bufferSize(RingBuffer *rb) {
    return rb->size;
}

// 示例程序
int main() {
    RingBuffer rb;
    initRingBuffer(&rb);

    // 添加元素
    for (int i = 0; i < 12; i++) {
        if (enqueue(&rb, i)) {
            printf("Enqueued: %d\n", i);
        } else {
            printf("Buffer full, cannot enqueue %d\n", i);
        }
    }

    // 移除元素
    int value;
    while (dequeue(&rb, &value)) {
        printf("Dequeued: %d\n", value);
    }

    return 0;
}

代码解读

  • RingBuffer结构体:定义了缓冲区的基本结构,包括存储数据的数组(buffer)、头指针(head)、尾指针(tail)以及当前缓冲区内的元素个数(size)。
  • 初始化initRingBuffer函数用于初始化缓冲区,将头指针、尾指针和大小设为0。
  • 添加数据enqueue函数向缓冲区中添加数据,若缓冲区已满则返回false
  • 移除数据dequeue函数从缓冲区中移除数据,若缓冲区为空则返回false
  • 缓冲区状态isEmptyisFull函数分别用于判断缓冲区是否为空或已满,bufferSize函数返回缓冲区中的数据个数。

环形缓冲区的用途

  1. 数据流处理:环形缓冲区常用于音频、视频、传感器数据等连续数据流的处理,能够高效地管理生产者与消费者的关系。

  2. 日志系统:在嵌入式系统或服务器中,环形缓冲区可用于日志记录,确保即使缓冲区满了也不会因日志过多而导致系统崩溃,而是覆盖旧的日志。

  3. 网络通信:在网络编程中,环形缓冲区常用于缓存从网络接口接收的数据包,防止数据丢失。

  4. 多任务处理:环形缓冲区可用于任务调度器中,作为任务队列的一部分,使得多个任务之间可以高效共享数据。

环形缓冲区因为其简单的结构和高效的性能,在需要固定容量的FIFO(先进先出)缓冲区时是非常理想的选择。

13.DMA和FIFO的区别

DMA(Direct Memory Access,直接内存访问)和FIFO(First In, First Out,先进先出队列)都是在嵌入式系统和计算机体系结构中用于数据传输和处理的重要机制。虽然它们都有助于提高数据传输效率,但它们的工作原理和用途有所不同。

1. 工作原理

  • DMA

    • 功能:DMA是一种允许外设直接访问内存而不经过CPU的技术。在DMA操作中,CPU只需配置DMA控制器,然后可以将数据传输的任务交给DMA完成。DMA控制器会在不占用CPU资源的情况下,在内存和外设(如硬盘、串口、音频设备等)之间传输数据。
    • 优点:DMA可以大大减少CPU的负担,提高数据传输速率,使得CPU可以处理其他任务,而不是花费时间在数据搬运上。
    • 典型应用:DMA通常用于需要大数据量快速传输的场景,如硬盘读取/写入、音频和视频数据流处理等。
  • FIFO

    • 功能:FIFO是一种数据结构,遵循先进先出的原则,即最早进入的数据最早被取出。FIFO通常用于缓冲数据流,确保数据以正确的顺序处理。FIFO常见于数据队列的管理,尤其是在硬件设备与软件之间传递数据时。
    • 优点:FIFO通过缓冲和顺序处理数据流,使系统在处理数据时更加有序,减少数据丢失的可能性。
    • 典型应用:FIFO常用于串口通信、音频数据缓冲、任务调度、数据流管理等场景。

2. 用途和场景

  • DMA的用途

    • 高速数据传输:如从硬盘读取大块数据到内存、从内存传输音频数据到音频设备。
    • 外设与内存间的数据交换:如网络接口卡(NIC)将接收到的数据直接存入内存。
    • 减少CPU负载:让CPU在数据传输期间处理其他任务,提高系统整体效率。
  • FIFO的用途

    • 数据缓冲:在数据生成和消费速率不一致的场景中,FIFO可以临时存储数据,缓解速度差异。
    • 顺序处理:确保数据按照输入顺序被处理,适用于通信协议实现、任务队列等。
    • 数据同步:在多任务或多线程系统中,FIFO可以用于任务之间的同步和通信。

3. 性能和复杂性

  • DMA

    • 性能:高效,因为数据传输不需要CPU参与,可以并行处理多个任务。
    • 复杂性:较高。配置DMA控制器需要较多设置,并且可能需要考虑内存对齐、传输单位大小等问题。
  • FIFO

    • 性能:相对简单。主要用于缓冲小规模数据流,FIFO的性能取决于其实现和应用场景。
    • 复杂性:较低。FIFO是一种简单的队列结构,易于实现和管理。

4. 总结

  • DMA是用于高效数据传输的机制,尤其是在需要快速、大量数据传输的情况下,DMA可以极大地减轻CPU的负担,提高系统性能。
  • FIFO则是一种数据缓冲和队列管理的工具,用于保证数据流在传输和处理时的顺序和完整性。

两者可以在一些系统中协同工作,例如使用DMA将数据块传输到FIFO中,然后通过FIFO逐步处理这些数据。

14.如何做到统一API对接不同外设驱动

在嵌入式系统开发中,统一API对接不同外设驱动是为了抽象硬件层,使得应用层代码可以通过统一的接口访问不同的硬件设备,从而提高代码的可移植性、可维护性和复用性。实现这一目标通常涉及设计一个抽象层或驱动框架,以屏蔽具体硬件实现的差异。

实现步骤

  1. 定义抽象接口(API)

    • 首先,定义一组抽象接口,用于描述外设的通用操作。这些接口应该与具体的硬件无关,只描述需要实现的功能。
    • 示例:假设你需要支持不同的I2C设备,可以定义如下抽象接口:
typedef struct {
    int (*init)(void);                          // 初始化设备
    int (*read)(uint8_t *data, int length);     // 读取数据
    int (*write)(uint8_t *data, int length);    // 写入数据
    int (*deinit)(void);                        // 释放设备资源
} I2C_Driver_API;

实现具体的驱动程序

  • 为每个具体的硬件设备实现上述抽象接口。每个驱动程序需要遵循相同的接口规范,但其内部实现可以因硬件而异。
// 驱动程序 A 的实现
int I2C_A_Init(void) {
    // 初始化 A 设备的 I2C
}
int I2C_A_Read(uint8_t *data, int length) {
    // 从 A 设备读取数据
}
int I2C_A_Write(uint8_t *data, int length) {
    // 向 A 设备写入数据
}
int I2C_A_Deinit(void) {
    // 释放 A 设备的资源
}

I2C_Driver_API I2C_A_Driver = {
    .init = I2C_A_Init,
    .read = I2C_A_Read,
    .write = I2C_A_Write,
    .deinit = I2C_A_Deinit
};

// 驱动程序 B 的实现
int I2C_B_Init(void) {
    // 初始化 B 设备的 I2C
}
int I2C_B_Read(uint8_t *data, int length) {
    // 从 B 设备读取数据
}
int I2C_B_Write(uint8_t *data, int length) {
    // 向 B 设备写入数据
}
int I2C_B_Deinit(void) {
    // 释放 B 设备的资源
}

I2C_Driver_API I2C_B_Driver = {
    .init = I2C_B_Init,
    .read = I2C_B_Read,
    .write = I2C_B_Write,
    .deinit = I2C_B_Deinit
};

通过抽象层选择驱动

  • 通过抽象层,应用层可以选择合适的驱动程序,并通过统一的API调用具体的驱动实现。驱动的选择可以基于配置文件、硬件检测或其他机制。
I2C_Driver_API *active_driver;

void select_driver(bool use_A) {
    if (use_A) {
        active_driver = &I2C_A_Driver;
    } else {
        active_driver = &I2C_B_Driver;
    }
}

void perform_operation() {
    active_driver->init();
    uint8_t data[10];
    active_driver->read(data, 10);
    active_driver->write(data, 10);
    active_driver->deinit();
}

应用层代码使用统一API

  • 应用层代码通过统一的API与外设交互,而不需要关心底层硬件的具体实现。
int main() {
    select_driver(true);  // 使用 A 设备的驱动
    perform_operation();

    select_driver(false); // 切换到 B 设备的驱动
    perform_operation();

    return 0;
}

优势

  • 可移植性:应用层代码不依赖具体的硬件实现,容易在不同硬件平台之间移植。
  • 可维护性:驱动的更换或更新不会影响到应用层代码,只需更改驱动实现即可。
  • 代码复用:相同的应用层代码可以复用于多个硬件平台,减少重复开发。

适用场景

  • 多设备支持:例如支持多个I2C、SPI、UART等外设的不同实现。
  • 可插拔驱动架构:如操作系统内核或嵌入式系统中的驱动程序框架。
  • 硬件抽象层(HAL):为复杂的嵌入式系统或操作系统提供硬件抽象,使得上层应用可以在不改变代码的情况下支持不同的硬件设备。

通过设计良好的抽象层和统一的API,能够有效地管理多个外设的驱动,使得系统更加模块化、灵活性更高。

15.如何合理设计flash分区表
16.正常非掉电重启是否要释放内存
17.正常掉电关机流程是否要释放内存
18.非掉电异常如何处理
19.如何实现异常后的dump
20.非正常掉电如何保护
21.如何设计一个简单的profiling工具
22.低功耗深睡眠如何唤醒后继续之前工作
23.rtos不能断点和打印的时候如何调试
24.什么是交叉编译
25.如何保证makefile的增量编译
26.如何用一套代码支持不同硬件
27.如何用一版软件支持不同硬件
28.不同代码编译后的存放区域有何不同
29.release和debug编译的区别
30.ARM多核之间有多少通讯机制及优缺点
31.两个线程之间不同锁的区别是什么
32.如何理解收益边界
33.介绍一下自己关于代码优化的经验
34.关于代码移植有什么经验分享?

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值