嵌入式软件C/C++/OS/驱动程序开发笔记

包含C/C++,操作系统,linux内核/驱动,嵌入式等一系列开发经验和知识总结。

希奥端面试

怎么查看可执行文件等库

模块安装的时候依赖项怎么处理的

NS访问S的地址会发生异常吗?

TF-M SPM怎么判断内存越界

怎么裁剪内核

大疆面试

对将来的职业规划?

secure boot的流程

HUK存放位置?存储空间有多大?密钥长度是多少?

安全数据怎么从NS到S的?

内存的虚拟化是怎么实现的

页表存了程序的什么?

缺页异常是什么?系统怎么处理的?快表英文是?访问零地址的时候会发生缺页异常吗?

内存水位的概念了解吗

Linux的调度机制,完全公平调度的机制,vruntime是怎么计算的

驱动开发需要实现的接口有哪些?

驱动模块的注册依据是什么?主设备号,次设备号分别是什么

线程同步机制有哪些?

条件变量是什么,怎么使用?

多线程里什么是鲸群效应

gdb调试怎么查看栈调用链路,这是怎么实现的

进程内存数据的分布,从低地址到高地址分别是?高地址上是栈还是堆?进程控制块存放在?

进程数据段有哪些内容?什么时候静态变量初始化?

可执行文件怎么加载到内存的?

有了解什么是自动驾驶吗?

控制器TL1是什么?

海光

求1的个数并尽量优化

C语言

程序编译的过程?

  • 程序编译的过程中就是将用户的文本形式的源代码(c/c++)转化成计算机可以直接执行的机器代码的过程。主要经过四个过程:预处理、编译、汇编和链接。具体示例如下。
  • 一个hello.c的c语言程序如下。
    • 预处理阶段:hello.c–>hello.i
    • 编译阶段:hello.i–>hello.s
    • 汇编阶段:hello.s–>hello.o
    • 链接阶段:hello.o–>hello

动态库和静态库的区别,后缀格式,以及函数的相对地址区别

区别

  1. 命名方式不同:
    静态库libxxx.a:库名前加”lib”,后缀用”.a”,“xxx”为静态库名。
    动态库libxxx.so:库名前加”lib”,后缀变为“.so”。

  2. 链接时间不同:
    静态库的代码是在编译过程中被载入程序中。
    动态库的代码是当程序运行到相关函数才调用动态库的相应函数

  3. 链接方式不同:
    静态库的链接是将整个函数库的所有数据在编译时都整合进了目标代码。
    动态库的链接是程序执行到哪个函数链接哪个函数的库。(用哪个链接哪个)

优缺点?

  • 静态库:
    优点是,在编译后的执行程序不再需要外部的函数库支持,运行速度相对快些;
    缺点是,如果所使用的静态库发生更新改变,你的程序必须重新编译。

  • 动态库 :
    优点是,动态库的改变并不影响你的程序,所以动态函数库升级比较方便;
    缺点是,因为函数库并没有整合进程序,所以程序的运行环境必须提供相应的库。

C和C++的区别?

  • C++是C的超集;
  • C是一个结构化语言,它的重点在于算法和数据结构。C程序的设计首要考虑的是如何通过一个过程,对输入(或环境条件)进行运算处理得到输出(或实现过程(事务)控制),而对于C++,首要考虑的是如何构造一个对象模型,让这个模型能够契合与之对应的问题域,这样就可以通过获取对象的状态信息得到输出或实现过程(事务)控制。

int的长度由什么决定

int 的字节长度是由CPU和操作系统编译器共同决定的,一般情况下,主要是由操作系统决定,比如,你在64位AMD的机器上安装的是32位操作系统,那 么,int默认是32位的;如果是64位操作系统,64位操作系统分为两种,1种是int为32位long为64位,2种int long均为64位。之所以说int同时也与编译器有关,编译器完全可以在32位系统模拟64位整数,例如Unix/Linux下的long long和Windows下的__int64均为64位整数。

指针

指针和引用的区别?

相同点:

  • 都是地址的概念;
  • 都是“指向”一块内存。指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名;
  • 引用在内部实现其实是借助指针来实现的,一些场合下引用可以替代指针,比如作为函数形参。

不同点:

  • 指针是一个实体,而引用(看起来,这点很重要)仅是个别名;
  • 引用只能在定义时被初始化一次,之后不可变;指针可变;引用“从一而终”,指针可以“见异思迁”;
  • 引用不能为空,指针可以为空;
  • “sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小;
  • 指针和引用的自增(++)运算意义不一样;
  • 引用是类型安全的,而指针不是 (引用比指针多了类型检查)
  • 引用具有更好的可读性和实用性。

C++补充

  • 引用

    • 引用就是 C++对 C语言的重要扩充。引用就是某一变量的一个别名,对引用的操作与对变量直接操作完全一样。
    • 引用的声明方法:类型标识符 &引用名=目标变量名;
    • 引用引入了对象的一个同义词。定义引用的表示方法与定义指针相似,只是用&代替了*。
    • 引用使用注意事项:
      • 引用必须被初始化;
      • 引用不能改变绑定的对象;
  • 指针

    • 指针利用地址,它的值直接指向存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元,可以说,地址指向该变量单元。因此,将地址形象化的称为“指针”。意思是通过它能找到以它为地址的内存单元。
    • 指针使用注意事项:
      • 初始化时要置空;
      • 使用时要考虑指向对象边界问题;
      • 不能对未初始化的指针取值或赋值;
      • 释放时要置空;
      • 如果返回动态分配内存或对象,必须使用指针;

区别:

  • 指针是一个实体,需要分配内存空间。引用只是变量的别名,不需要分配内存空间;
  • 使用 sizeof 看一个指针的大小是 4,而引用则是被引用对象的大小;
  • 指针可以被初始化为 NULL,而引用必须被初始化且必须是一个已有对象的引用;
  • 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象;
  • 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
  • 指针可以有多级指针(**p),而引用至多一级;
  • 指针和引用使用自增运算符的意义不一样;(指针是指向下一个空间,引用时引用的变量值加1)
  • 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。

引用占用内存空间吗?

如下代码中对引用取地址,其实是取的引用所对应的内存空间的地址。这个现象让人觉得引用好像并非一个实体。但是引用是占用内存空间的,而且其占用的内存和指针一样,因为引用的内部实现就是通过指针来完成的。
比如 Type& name; <===> Type* const name。

int main(void)
{
        int a = 8;
        const int &b = a;
        int *p = &a;
        *p = 0;
        cout<<a; //output 0
    return 0;
}

请你来说一下函数指针

  • 定义:函数指针是一个指向函数首地址的指针变量。
    C在编译时,每一个函数都有一个人口地址,函数指针指向的就是这个人口地址。有了函数指针,可以通过指针来调用函数。就向指针数组一样。
  • 用途:
    调用函数和做函数的参数,比如回调函数。
  • 示例:
char* fun(char * p) {…};  // 普通函数 fun
char* (*pf)(char * p);     // 函数指针 pf
pf = fun; // 函数指针 pf 指向函数 fun
pf(p);    // 通过函数指针 pf 调用函数 fun

请你回答一下野指针是什么?

野指针就是指向一个已释放的内存或者无访问权限的内存区域的指针。

请回答一下数组和指针的区别

指针和数组的主要区别如下:

指针数组和数组指针的区别

  • 数组指针,是指向数组的指针,而指针数组则是指该数组的元素均为指针。
  • 数组指针,是指向数组的指针,其本质为指针,形式如下。如int (*p)[10],p即为指向数组的指针,()优先级高,首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度是n,也可以说是p的步长。也就是说执行p+1时,p要跨过n个整型数据的长度。数组指针是指向数组首元素的地址的指针,其本质为指针,可以看成是二级指针。
类型名 (*数组标识符)[数组长度]

指针数组,在C语言和C++中,数组元素全为指针的数组称为指针数组,其中一维指针数组的定义形式如下。指针数组中每一个元素均为指针,其本质为数组。如int *p[n][]优先级高,先与p结合成为一个数组,再由int*说明这是一个整型指针数组,它有n个指针类型的数组元素。这里执行p+1时,则p指向下一个数组元素,这样赋值是错误的:p=a;因为p是个不可知的表示,只存在p[0]、p[1]、p[2]…p[n-1],而且它们分别是指针变量可以用来存放变量地址。但可以这样*p=a; 这里*p表示指针数组第一个元素的值,a的首地址的值。

类型名 *数组标识符[数组长度]

函数

int fun() 和 int fun(void)的区别?

  • 这里考察的是c中的默认类型机制。在c中,int fun() 会解读为返回值为int(即使前面没有int,也是如此,但是在c++中如果没有返回类型将报错),输入类型和个数没有限制, 而int fun(void)则限制输入类型为一个void。
  • 在c++下,这两种情况都会解读为返回int类型,输入void类型。

函数调用的过程?

如下结构的代码,

int main(void)
{
  ...
  d = fun(a, b, c);
  cout<<d<<endl;
  ...
  return 0;
}

调用fun()的过程大致如下:

  • main()
    • 参数拷贝(压栈),注意顺序是从右到左,即c-b-a;
    • 保存d = fun(a, b, c)的下一条指令,即cout<<d<<endl(实际上是这条语句对应的汇编指令的起始位置);
    • 跳转到fun()函数,注意,到目前为止,这些都是在main()中进行的;
  • fun()
    • 移动ebp、esp形成新的栈帧结构;
    • 压栈(push)形成临时变量并执行相关操作;
    • return一个值;
    • 出栈(pop);
    • 恢复main函数的栈帧结构;
    • 返回main函数;
  • main()

关键字

static有什么用途

  • 全局静态变量

    • 在全局变量前加上关键字 static,全局变量就定义成一个全局静态变量。
    • 内存中位置:静态存储区,在整个程序运行期间一直存在。
    • 初始化:未经初始化的全局静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);
    • 作用域:全局静态变量在声明他的文件之外是不可见的,准确地说是从定义之处开始,到文件结尾。
  • 局部静态变量

    • 在局部变量之前加上关键字static,局部变量就成为一个局部静态变量。
    • 内存中的位置:静态存储区
    • 初始化:未经初始化的局部静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);
    • 作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域结束。但是当局部静态变量离开作用域后,并没有销毁,而是仍然驻留在内存当中,只不过我们不能再对它进行访问,直到该函数再次被调用,并且值不变;
  • 静态函数

    • 在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。
    • 函数的实现使用static修饰,那么这个函数只可在本cpp内使用,不会同其他cpp中的同名函数引起冲突;
    • 注意:不要在头文件中声明static的全局函数,不要在cpp内声明非static的全局函数,如果你要在多个cpp中复用该函数,就把它的声明提到头文件里去,否则cpp内部声明需加上static修饰;
  • 类的静态成员

    • 在类中,静态成员可以实现多个对象之间的数据共享,并且使用静态数据成员还不会破坏隐藏的原则,即保证了安全性。因此,静态成员是类的所有对象中共享的成员,而不是某个对象的成员。对多个对象来说,静态数据成员只存储一处,供所有对象共用。
  • 类的静态函数

    • 静态成员函数和静态数据成员一样,它们都属于类的静态成员,它们都不是对象成员。因此,对静态成员的引用不需要用对象名。

在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员(这点非常重要)。如果静态成员函数中要引用非静态成员时,可通过对象来引用。
从中可看出,调用静态成员函数使用如下格式:<类名>::<静态成员函数名>(<参数表>);
简洁回答:(推荐)

  • 加了static 关键字的全局变量只能在本文件中使用。
  • 例如在 a.c 中定义了static int a=10;那么在 b.c 中用extern int a 是拿不到 a 的值得,a 的作用域只在 a.c 中。
  • static定义的静态局部变量分配在数据段上,普通的局部变量分配在栈上,会因为函数栈帧的释放而被释放掉。
  • 对一个类中成员变量和成员函数来说,加了 static 关键字,则此变量/函数就没有了 this 指针了,必须通过类名才能访问

类的静态成员变量和静态成员函数各有哪些特性?

  • 静态成员变量
    • 静态成员变量需要在类内声明(加static),在类外初始化(不能加static),如下例所示;
    • 静态成员变量在类外单独分配存储空间,位于全局数据区,因此静态成员变量的生命周期不依赖于类的某个对象,而是所有类的对象共享静态成员变量;
    • 可以通过对象名直接访问公有静态成员变量;
    • 可以通过类名直接调用公有静态成员变量,即不需要通过对象,这一点是普通成员变量所不具备的。
class example{
private:
static int m_int; //static成员变量
};

int example::m_int = 0; //没有static

cout<<example::m_int; //可以直接通过类名调用静态成员变量
  • 静态成员函数
    • 静态成员函数是类所共享的;
    • 静态成员函数可以访问静态成员变量,但是不能直接访问普通成员变量(需要通过对象来访问);需要注意的是普通成员函数既可以访问普通成员变量,也可以访问静态成员变量;
    • 可以通过对象名直接访问公有静态成员函数;
    • 可以通过类名直接调用公有静态成员函数,即不需要通过对象,这一点是普通成员函数所不具备的。
class example{
private:
static int m_int_s; //static成员变量
int m_int;
static int getI() //静态成员函数在普通成员函数前加static即可
{
  return m_int_s; //如果返回m_int则报错,但是可以return d.m_int是合法的
}
};

cout<<example::getI(); //可以直接通过类名调用静态成员变量

const 有什么用途

  • 定义只读变量,或者常量(只读变量和常量的区别参考下面一条);
  • 修饰函数的参数和函数的返回值;
  • 修饰函数的定义体,这里的函数为类的成员函数,被const修饰的成员函数代表不能修改成员变量的值,因此const成员函数只能调用const成员函数;
  • 只读对象。只读对象只能调用const成员函数。
class Screen {
public:
const char cha; //const成员变量
char get() const; //const成员函数
};

const Screen screen; //只读对象

在C中用const能定义真正意义上的常量吗?C++中的const呢?

不能。c中的const仅仅是从编译层来限定,不允许对const 变量进行赋值操作,在运行期是无效的,所以并非是真正的常量(比如通过指针对const变量是可以修改值的),但是c++中是有区别的,c++在编译时会把const常量加入符号表,以后(仍然在编译期)遇到这个变量会从符号表中查找,所以在C++中是不可能修改到const变量的。

  • c中的局部const常量存储在栈空间,全局const常量存在只读存储区,所以全局const常量也是无法修改的,它是一个只读变量。
  • 这里需要说明的是,常量并非仅仅是不可修改,而是相对于变量,它的值在编译期已经决定,而不是在运行时决定。
  • c++中的const 和宏定义是有区别的,宏是在预编译期直接进行文本替换,而const发生在编译期,是可以进行类型检查和作用域检查的。
  • c语言中只有enum可以实现真正的常量。
  • c++中只有用字面量初始化的const常量会被加入符号表,而变量初始化的const常量依然只是只读变量。
  • c++中const成员为只读变量,可以通过指针修改const成员的值,另外const成员变量只能在初始化列表中进行初始化。

下面我们通过代码来看看区别。同样一段代码,在c编译器下,打印结果为pa = 4, 4;在c++编译下打印的结果为pa = 4, 8

int main(void)
{
    const int a = 8;
    int *pa = (int *)&a;
    *pa = 4;
    printf("*pa = %d, a = %d", *pa, a);
    return 0;
}

另外值得一说的是,由于c++中const常量的值在编译期就已经决定,下面的做法是OK的,但是c中是编译通不过的。

int main(void)
{
    const int a = 8;
    const int b = 2;
    int array[a+b] = {0};
    return 0;
}

请说一下extern关键字的作用?

C++调用 C函数需要 extern C,因为 C 语言没有函数重载。

  • extern修饰变量的声明。举例来说,如果文件a.c需要引用b.c中变量int v,就可以在a.c中声明extern int v,然后就可以引用变量v。这里需要注意的是,被引用的变量v的链接属性必须是外链接(external)的,也就是说a.c要引用到v,不只是取决于在a.c中声明extern int v,还取决于变量v本身是能够被引用到的。这涉及到c语言的另外一个话题--变量的作用域。能够被其他模块以extern修饰符引用到的变量通常是全局变量。还有很重要的一点是,extern int v可以放在a.c中的任何地方,比如你可以在a.c中的函数fun定义的开头处声明extern int v,然后就可以引用到变量v了,只不过这样只能在函数fun作用域中引用v罢了,这还是变量作用域的问题。对于这一点来说,很多人使用的时候都心存顾虑。好像extern声明只能用于文件作用域似的。
  • extern修饰函数声明。从本质上来讲,变量和函数没有区别。函数名是指向函数二进制块开头处的指针。如果文件a.c需要引用b.c中的函数,比如在b.c中原型是int fun(int mu),那么就可以在a.c中声明extern int fun(int mu),然后就能使用fun来做任何事情。就像变量的声明一样,extern int fun(int mu)可以放在a.c中任何地方,而不一定非要放在a.c的文件作用域的范围中。对其他模块中函数的引用,最常用的方法是包含这些函数声明的头文件。
  • 此外,extern修饰符可用于指示C或者C++函数的调用规范。比如在C++中调用C库函数,就需要在C++程序中用extern “C”声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C函数规范来链接。主要原因是C++和C程序编译完成后在目标代码中命名规则不同。

预处理

请你来回答一下include头文件的顺序以及双引号” “和尖括号< > 的区别

对于使用双引号包含的头文件,查找头文件路径的顺序为:

  • 当前头文件目录→编译器设置的头文件目录→系统变量指定的头文件路径

对于使用尖括号包含的头文件,查找头文件的路径顺序为:

  • 编译器设置的头文件路径→系统变量指定的头文件路径

宏和内联(inline)函数的比较?

  • 首先宏是C中引入的一种预处理功能;
  • 内联(inline)函数是C++中引用的一个新的关键字;C++中推荐使用内联函数来替代宏代码片段;
  • 内联函数将函数体直接扩展到调用内联函数的地方,这样减少了参数压栈,跳转,返回等过程;
  • 由于内联发生在编译阶段,所以内联相较宏,是有参数检查和返回值检查的,因此使用起来更为安全;
  • 需要注意的是, inline会向编译期提出内联请求,但是是否内联由编译期决定(当然可以通过设置编译器,强制使用内联);
  • 由于内联是一种优化方式,在某些情况下,即使没有显示的声明内联,比如定义在class内部的方法,编译器也可能将其作为内联函数。
  • 内联函数不能过于复杂,最初C++限定不能有任何形式的循环,不能有过多的条件判断,不能对函数进行取地址操作等,但是现在的编译器几乎没有什么限制,基本都可以实现内联。

宏函数是在程序编译时进行简单的字符替换,而函数是在程序生成后才进行调用,这时会占用开销时间(主程序函数保留现场,在子函数体中需要进行参数的传递–实参传递给形参以及调用完后形参的销毁等步骤),因此宏函数占用的编译时的时间,而函数占用的是执行时的时间。

头文件中的ifndef/define/endif是干什么用的? 该用法和program once的区别?

相同点:它们的作用是防止头文件被重复包含。
不同点:

  • ifndef 由语言本身提供支持,但是program once一般由编译器提供支持,也就是说,有可能出现编译器不支持的情况(主要是比较老的编译器)。
  • 通常运行速度上 ifndef 一般慢于program once,特别是在大型项目上,区别会比较明显,所以越来越多的编译器开始支持program once。
  • fndef 作用于某一段被包含(define 和 endif 之间)的代码, 而 program once 则是针对包含该语句的文件, 这也是为什么 program once 速度更快的原因。
  • 如果用 ifndef 包含某一段宏定义,当这个宏名字出现“撞车”时,可能会出现这个宏在程序中提示宏未定义的情况(在编写大型程序时特性需要注意,因为有很多程序员在同时写代码)。相反由于program once 针对整个文件, 因此它不存在宏名字“撞车”的情况, 但是如果某个头文件被多次拷贝,program once 无法保证不被多次包含,因为program once 是从物理上判断是不是同一个头文件,而不是从内容上。

内存

堆和栈的区别?

  • 堆存放动态分配的对象——即那些在程序运行时分配的对象,比如局部变量,其生存期由程序控制;
  • 栈用来保存定义在函数内的非static对象,仅在其定义的程序块运行时才存在;
  • 静态内存用来保存static对象,类static数据成员以及定义在任何函数外部的变量,static对象在使用之前分配,程序结束时销毁;
  • 栈和静态内存的对象由编译器自动创建和销毁。

堆和自由存储区的区别?

  • 总的来说,堆是C语言和操作系统的术语,是操作系统维护的一块动态分配内存;自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。他们并不是完全一样。
  • 从技术上来说,堆(heap)是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。
  • 而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。

C++中有了malloc / free , 为什么还需要 new / delete?

  • malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。
  • 对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。
  • 由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。最后补充一点体外话,new 在申请内存的时候就可以初始化(如下代码), 而malloc是不允许的。另外,由于malloc是库函数,需要相应的库支持,因此某些简易的平台可能不支持,但是new就没有这个问题了,因为new是C++语言所自带的运算符。
int *p = new int(1);
  • 特别的,在C++中,如下的代码,用new创建一个对象(new 会触发构造函数, delete会触发析构函数),但是malloc仅仅申请了一个空间,所以在C++中引入new和delete来支持面向对象。
#include <cstdlib>
class Test
{
    ...
}

Test* pn = new Test;
Test* pm = (Test*)malloc(sizeof(Test));

什么是内存泄漏?面对内存泄漏和指针越界,你有哪些方法?你通常采用哪些方法来避免和减少这类错误?

用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元即为内存泄露。

  • 使用的时候要记得指针的长度.
  • malloc的时候得确定在那里free.
  • 对指针赋值的时候应该注意被赋值指针需要不需要释放.
  • 动态分配内存的指针最好不要再次赋值.
  • 在C++中应该优先考虑使用智能指针.

内核如何分配内存给进程

参考Linux内存分配机制
Linux 的虚拟内存管理有几个关键概念:

  1. 每个进程都有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址;
  2. 虚拟地址可通过每个进程上的页表(在每个进程的内核虚拟地址空间)与物理地址进行映射,获得真正物理地址;
  3. 如果虚拟地址对应物理地址不在物理内存中,则产生缺页中断,真正分配物理地址,同时更新进程的页表;如果此时物理内存已耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。

为什么需要MMU

MMU是Memory Management Unit的缩写,中文名是内存管理单元,有时称作分页内存管理单元(英语:paged memory management unit,缩写为PMMU)。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。它的功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制,在较为简单的计算机体系结构中,负责总线的仲裁以及存储体切换(bank switching,尤其是在8位的系统上)。

TLB(Translation Lookaside Buffer)传输后备缓冲器是一个内存管理单元用于改进虚拟地址到物理地址转换速度的缓存。TLB是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。如果没有TLB,则每次取数据都需要两次访问内存,即查页表获得物理地址和取数据。

多进程间频繁切换对TLB有什么影响?现代的处理器是如何面对这个问题的?

在现代处理器中,软件使用虚拟地址访问内存,而处理器的MMU单元负责把虚拟地址转换成物理地址,为了完成这个映射过程,软件和硬件共同来维护一个多级映射的页表。当处理器发现页表中无法映射到对应的物理地址时,会触发一个缺页异常,挂起出错的进程,操作系统软件需要处理这个缺页异常。我们之前有提到过二级页表的查询过程,为了完成虚拟地址到物理地址的转换,查询页表需要两次访问内存,即一级页表和二级页表都是存放在内存中的。

TLB( Translation Look- aside buffer)专门用于缓存内存中的页表项,一般在MMU单元内部。TLB是一个很小的 cache,TLB表项( TLB entry)数量比较少,每个TLB表项包含一个页面的相关信息,例如有效位、虚拟页号、修改位、物理页帧号等。当处理器要访问一个虚拟地址时,首先会在TLB中查询。如果TLB表项中没有相应的表项,称为TLB Miss,那么就需要访问页表来计算出相应的物理地址。如果TLB表项中有相应的表项,那么直接从TLB表项中获取物理地址,称为TLB命中。

TLB内部存放的基本单位是TLB表项,TLB容量越大,所能存放的TLB表项就越多,TLB命中率就越高,但是TLB的容量是有限的。目前 Linux内核默认采用4KB大小的小页面,如果一个程序使用512个小页面,即2MB大小,那么至少需要512个TLB表项才能保证不会出现 TLB Miss的情况。但是如果使用2MB大小的大页,那么只需要一个TLB表项就可以保证不会出现 TLB Miss的情况。对于消耗内存以GB为单位的大型应用程序,还可以使用以1GB为单位的大页,从而减少 TLB Miss的情况。

TLB是什么

根据功能可以译为快表,直译可以翻译为旁路转换缓冲,也可以把它理解成页表缓冲。里面存放的是一些页表文件(虚拟地址到物理地址的转换表)。当处理 器要在主内存寻址时,不是直接在内存的物理地址里查找的,而是通过一组虚拟地址转换到主内存的物理地址,TLB就是负责将虚拟内存地址翻译成实际的物理内 存地址,而CPU寻址时会优先在TLB中进行寻址。处理器的性能就和寻址的命中率有很大的关系。
参考TLB

cache是什么,有什么作用

Cache存储器,电脑中为高速缓冲存储器,是位于CPU和主存储器DRAM(Dynamic Random Access Memory)之间,规模较小,但速度很高的存储器,通常由SRAM(Static Random Access Memory 静态存储器)组成。它是位于CPU与内存间的一种容量较小但速度很高的存储器。CPU的速度远高于内存,当CPU直接从内存中存取数据时要等待一定时间周期,而Cache则可以保存CPU刚用过或循环使用的一部分数据,如果CPU需要再次使用该部分数据时可从Cache中直接调用,这样就避免了重复存取数据,减少了CPU的等待时间,因而提高了系统的效率。Cache又分为L1Cache(一级缓存)和L2Cache(二级缓存),L1Cache主要是集成在CPU内部,而L2Cache集成在主板上或是CPU上。

操作系统

基础

系统并发和并行,分得清吗?

并发是指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。
并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。
操作系统通过引入进程和线程,使得程序能够并发运行。

计算机组成,架构按指令分类

计算机组成指的是系统结构的逻辑实现,包括机器机内的数据流和控制流的组成及逻辑设计等。主要分为五个部分:控制器,运算器,存储器,输入设备,输出设备。

哈佛结构(Harvard architecture)是一种将程序指令储存和数据储存分开的存储器结构。中央处理器首先到程序指令储存器中读取程序指令内容,解码后得到数据地址,再到相应的数据储存器中读取数据,并进行下一步的操作(通常是执行)。程序指令储存和数据储存分开,数据和指令的储存可以同时进行,可以使指令和数据有不同的数据宽度,如Microchip公司的PIC16芯片的程序指令是14位宽度,而数据是8位宽度。哈佛结构的微处理器通常具有较高的执行效率。其程序指令和数据指令分开组织和储存的,执行时可以预先读取下一条指令。目前使用哈佛结构的中央处理器和微控制器有很多,除了上面提到的Microchip公司的PIC系列芯片,还有摩托罗拉公司的MC68系列、Zilog公司的Z8系列、ATMEL公司的AVR系列和安谋公司的ARM9、ARM10和ARM11。大多数DSP是哈佛结构的。

冯.诺伊曼结构(von Neumann architecture),也称普林斯顿结构,是一种将程序指令存储器和数据存储器合并在一起的电脑设计概念结构。该结构隐约指导了将储存装置与中央处理器分开的概念,因此依该结构设计出的计算机又称储存程式型电脑。

ARM架构过去称作进阶精简指令集机器(AdvancedRISCMachine,更早称作:AcornRISCMachine),是一个32位精简指令集(RISC)处理器架构,其广泛地使用在许多嵌入式系统设计。由于节能的特点,ARM处理器非常适用于移动通讯领域,符合其主要设计目标为低耗电的特性。

目前的PC架构绝大多数都是Intel的X86架构,貌似也是因为INTEL的这个X86架构早就了目前INTEL如日中天的地位。X86架构(The X86 architecture)是微处理器执行的计算机语言指令集,指一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合。

软件维护是软件生命周期中持续时间最长的阶段。在软件开发完成并投入使用后,由于多方面的原因,软件不能继续适应用户的要求。要延续软件的使用寿命,就必须对软件进行维护。软件的维护包括纠错性维护和改进性维护两个方面。

区别参考:

从硬件角度来看CISC处理的是不等长指令集,它必须对不等长指令进行分割,因此在执行单一指令的时候需要进行较多的处理工作。而RISC执行的是等长精简指令集,CPU在执行指令的时候速度较快且性能稳定。因此在并行处理方面RISC明显优于CISC,RISC可同时执行多条指令,它可将一条指令分割成若干个进程或线程,交由多个处理器同时执行。由于RISC执行的是精简指令集,所以它的制造工艺简单且成本低廉。

从软件角度来看,CISC运行的则是我们所熟识的DOS、Windows操作系统。而且它拥有大量的应用程序。因为全世界有65%以上的软件厂商都理为基于CISC体系结构的PC及其兼容机服务的,象赫赫有名的Microsoft就是其中的一家。而RISC在此方面却显得有些势单力薄。虽然在RISC上也可运行DOS、Windows,但是需要一个翻译过程,所以运行速度要慢许多。

什么是SDLC

SDLC:sdlc(系统生命周期,系统生存周期)是软件的产生直到报废的生命周期,是软件工程中的一种思想原则,即按部就班、逐步推进,每个阶段都要有定义、工作、审查、形成文档以供交流或备查,以提高软件的质量。包括:

  • 问题定义及规划
    此阶段是软件开发方与需求方共同讨论,主要确定软件的开发目标及其可行性。
  • 需求分析
    在确定软件开发可行的情况下,对软件需要实现的各个功能进行详细分析。需求分析阶段是一个很重要的阶段,这一阶段做得好,将为整个软件开发项目的成功打下良好的基础。“唯一不变的是变化本身。”,同样需求也是在整个软件开发过程中不断变化和深入的,因此我们必须制定需求变更计划来应付这种变化,以保护整个项目的顺利进行。
  • 软件设计
    此阶段主要根据需求分析的结果,对整个软件系统进行设计,如系统框架设计,数据库设计等等。软件设计一般分为总体设计和详细设计。好的软件设计将为软件程序编写打下良好的基础。
  • 程序编码
    此阶段是将软件设计的结果转换成计算机可运行的程序代码。在程序编码中必须要制定统一,符合标准的编写规范。以保证程序的可读性,易维护性,提高程序的运行效率。
  • 软件测试
    在软件设计完成后要经过严密的测试,以发现软件在整个设计过程中存在的问题并加以纠正。整个测试过程分单元测试、组装测试以及系统测试三个阶段进行。测试的方法主要有白盒测试和黑盒测试两种。在测试过程中需要建立详细的测试计划并严格按照测试计划进行测试,以减少测试的随意性。
  • 运行维护

一个C/C++程序从开始编译到生成可执行文件的完整过程,你能说出来多少?

  • 预编译 主要处理源代码文件中的以“#”开头的预编译指令。处理规则见下

1、删除所有的#define,展开所有的宏定义。
2、处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
3、处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他 文件。
4、删除所有的注释,“//”和“/**/”。
5、保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重 复引用。
6、添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是 能够显示行号。

  • 编译 把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

1、词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
2、语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
4、优化:源代码级别的一个优化过程。
5、目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
6、目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。

  • 汇编

将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。
经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Linux下)、xxx.obj(Windows下)。

  • 链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接:

1、静态链接: 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本; 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。

2、动态链接: 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失

进程线程

服务器高并发的解决方案你知道多少?

  • 应用数据与静态资源分离 将静态资源(图片,视频,js,css等)单独保存到专门的静态资源服务器中,在客户端访问的时候从静态资源服务器中返回静态资源,从主服务器中返回应用数据。
  • 客户端缓存 因为效率最高,消耗资源最小的就是纯静态的html页面,所以可以把网站上的页面尽可能用静态的来实现,在页面过期或者有数据更新之后再将页面重新缓存。或者先生成静态页面,然后用ajax异步请求获取动态数据。
  • 集群和分布式 (集群是所有的服务器都有相同的功能,请求哪台都可以,主要起分流作用)
    (分布式是将不同的业务放到不同的服务器中,处理一个请求可能需要使用到多台服务器,起到加快请求处理的速度。)
    可以使用服务器集群和分布式架构,使得原本属于一个服务器的计算压力分散到多个服务器上。同时加快请求处理的速度。
  • 反向代理 在访问服务器的时候,服务器通过别的服务器获取资源或结果返回给客户端。

多进程和多线程的区别是什么?换句话说,什么时候该用多线程,什么时候该用多进程?

频繁修改:需要频繁创建和销毁的优先使用多线程
计算量:需要大量计算的优先使用多线程 因为需要消耗大量CPU资源且切换频繁,所以多线程好一点
相关性:任务间相关性比较强的用多线程,相关性比较弱的用多进程。因为线程之间的数据共享和同步比较简单。
多分布:可能要扩展到多机分布的用多进程,多核分布的用多线程。
但是实际中更常见的是进程加线程的结合方式,并不是非此即彼的。

如何避免僵尸进程?

通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN);表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。

父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。

如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。

通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。

第一种方法忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。

一个进程可以创建多少线程,和什么有关?

这个要分不同系统去看:

如果是32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。

如果是64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
顺便多说一句,过多的线程将会导致大量的时间浪费在线程切换上,给程序运行效率带来负面影响,无用线程要及时销毁。

调度

进程控制

  • 进程地址空间(地址空间)

虚拟存储器为每个进程提供了独占系统地址空间的假象。

尽管每个进程地址空间内容不尽相同,但是他们的都有相似的结构。X86 Linux进程的地址空间底部是保留给用户程序的,包括文本、数据、堆、栈等,其中文本区和数据区是通过存储器映射方式将磁盘中可执行文件的相应段映射至虚拟存储器地址空间中。

有一些”敏感”的地址需要注意下,对于32位进程来说,代码段从0x08048000开始。从0xC0000000开始到0xFFFFFFFF是内核地址空间,通常情况下代码运行在用户态(使用0x00000000 ~ 0xC00000000的用户地址空间),当发生系统调用、进程切换等操作时CPU控制寄存器设置模式位,进入内和模式,在该状态(超级用户模式)下进程可以访问全部存储器位置和执行全部指令。

也就说32位进程的地址空间都是4G,但用户态下只能访问低3G的地址空间,若要访问3G ~ 4G的地址空间则只有进入内核态才行。

  • 进程控制块(处理机)

进程的调度实际就是内核选择相应的进程控制块,被选择的进程控制块中包含了一个进程基本的信息。

  • 上下文切换

内核管理所有进程控制块,而进程控制块记录了进程全部状态信息。每一次进程调度就是一次上下文切换,所谓的上下文本质上就是当前运行状态,主要包括通用寄存器、浮点寄存器、状态寄存器、程序计数器、用户栈和内核数据结构(页表、进程表、文件表)等。

进程执行时刻,内核可以决定抢占当前进程并开始新的进程,这个过程由内核调度器完成,当调度器选择了某个进程时称为该进程被调度,该过程通过上下文切换来改变当前状态。

一次完整的上下文切换通常是进程原先运行于用户态,之后因系统调用或时间片到切换到内核态执行内核指令,完成上下文切换后回到用户态,此时已经切换到进程B。

nice的默认优先级大小,取值范围

Linux内核实现了两种不同的优先级范围。第一种是用nice值,它的范围是[-20, 19],默认值是0。越大的nice值意味着优先级越低–你对系统中的别的进程”nice”。相比于那些高nice值(低优先级)的进程来说,低nice值(高优先级)的进程可以获得更多的处理器时间。

Linux nice命令以更改过的优先序来执行程序,如果未指定程序,则会印出目前的排程优先序,内定的 adjustment 为 10,范围为 -20(最高优先序)到 19(最低优先序)。

Linux内核抢占机制(preempt)原理

早期的Linux核心是不可抢占的。它的调度方法是:一个进程可以通过schedule()函数自愿地启动一次调度。非自愿的强制性调度只能发生在每次从系统调用返回的前夕以及每次从中断或异常处理返回到用户空间的前夕。但是,如果在系统空间发生中断或异常是不会引起调度的。这种方式使内核实现得以简化。但常存在下面两个问题:

  • 如果这样的中断发生在内核中,本次中断返回是不会引起调度的,而要到最初使CPU从用户空间进入内核空间的那次系统调用或中断(异常)返回时才会发生调度。
  • 另外一个问题是优先级反转。在Linux中,在核心态运行的任何操作都要优先于用户态进程,这就有可能导致优先级反转问题的出现。例如,一个低优先级的用户进程由于执行软/硬中断等原因而导致一个高优先级的任务得不到及时响应。

当前的Linux内核加入了内核抢占(preempt)机制。内核抢占指用户程序在执行系统调用期间可以被抢占,该进程暂时挂起,使新唤醒的高优先级进程能够运行。这种抢占并非可以在内核中任意位置都能安全进行,比如在临界区中的代码就不能发生抢占。临界区是指同一时间内不可以有超过一个进程在其中执行的指令序列。在Linux内核中这些部分需要用自旋锁保护。

内核抢占要求内核中所有可能为一个以上进程共享的变量和数据结构就都要通过互斥机制加以保护,或者说都要放在临界区中。在抢占式内核中,认为如果内核不是在一个中断处理程序中,并且不在被 spinlock等互斥机制保护的临界代码中,就认为可以”安全”地进行进程切换。

Linux内核将临界代码都加了互斥机制进行保护,同时,还在运行时间过长的代码路径上插入调度检查点,打断过长的执行路径,这样,任务可快速切换进程状态,也为内核抢占做好了准备。

Linux内核抢占只有在内核正在执行例外处理程序(通常指系统调用)并且允许内核抢占时,才能进行抢占内核。禁止内核抢占的情况列出如下:

(1)内核执行中断处理例程时不允许内核抢占,中断返回时再执行内核抢占。
(2)当内核执行软中断或tasklet时,禁止内核抢占,软中断返回时再执行内核抢占。
(3)在临界区禁止内核抢占,临界区保护函数通过抢占计数宏控制抢占,计数大于0,表示禁止内核抢占。

抢占式内核实现的原理是在释放自旋锁时或从中断返回时,如果当前执行进程的 need_resched 被标记,则进行抢占式调度。

Linux内核在线程信息结构上增加了成员preempt_count作为内核抢占锁,为0表示可以进行内核高度,它随spinlock和rwlock等一起加锁和解锁。线程信息结构thread_info列出如下(在include/asm-x86/thread_info.h中):

struct thread_info {
    struct task_struct *task;           /*主任务结构 */
    struct exec_domain *exec_domain;    /* 执行的域*/
    __u32               flags;          /* low level flags */
    __u32               status;         /* 线程同步标识*/
    __u32               cpu;            /* 当前的CPU */
    int                 preempt_count;  /* 0 => 可以抢占(preemptable),
                                           <0 => BUG */
    mm_segment_t  addr_limit;
    struct restart_block    restart_block;
#ifdef CONFIG_IA32_EMULATION
    void __user  *sysenter_return;
#endif
};
#endif

内核调度器的入口为preempt_schedule(),他将当前进程标记为TASK_PREEMPTED状态再调用schedule(),在TASK_PREEMPTED状态,schedule()不会将进程从运行队列中删除。

内核抢占API函数

在中断或临界区代码中,线程需要关闭内核抢占,因此,互斥机制(如:自旋锁(spinlock)、RCU等)、中断代码、链表数据遍历等需要关闭内核抢占,临界代码运行完时,需要开启内核抢占。关闭/开启内核抢占需要使用内核抢占API函数preempt_disable和preempt_enable。

内核抢占API函数说明如下(在include/linux/preempt.h中):

preempt_enable() //内核抢占计数preempt_count减1
preempt_disable() //内核抢占计数preempt_count加1
preempt_enable_no_resched()  //内核抢占计数preempt_count减1,但不立即抢占式调度
preempt_check_resched () //如果必要进行调度
preempt_count() //返回抢占计数
preempt_schedule() //核抢占时的调度程序的入口点

内核抢占API函数的实现宏定义列出如下(在include/linux/preempt.h中):

# define preempt_disable() /
do { /
    inc_preempt_count(); /
    barrier(); /       //加内存屏障,阻止gcc编译器对内存进行优化
} while (0)

# define inc_preempt_count() /
do { /
    preempt_count()++; /
} while (0)
# define preempt_count() (current_thread_info()->preempt_count)

内核抢占调度
Linux内核在硬中断或软中断返回时会检查执行抢占调度。分别说明如下:

(1)硬中断返回执行抢占调度

Linux内核在硬中断或出错退出时执行函数retint_kernel,运行抢占函数,函数retint_kernel列出如下(在arch/x86/entry_64.S中):

# ifdef CONFIG_PREEMPT
    /*返回到内核空间,检查是否需要执行抢占*/
    /*寄存器rcx存放threadinfo地址,此时,中断关闭*/
ENTRY(retint_kernel)
    cmpl $0,threadinfo_preempt_count(%rcx)
    jnz  retint_restore_args
    bt   $TIF_NEED_RESCHED,threadinfo_flags(%rcx)
    jnc  retint_restore_args
    bt   $9,EFLAGS-ARGOFFSET(%rsp) /*中断是否关闭?*/
    jnc  retint_restore_args
    call preempt_schedule_irq
    jmp exit_intr
# endif

函数preempt_schedule_irq是出中断上下文时内核抢占调度的入口点,该函数被调用和返回时中断应关闭,保护此函数从中断递归调用。该函数列出如下(在kernel/sched.c中):

asmlinkage void __sched preempt_schedule_irq(void)
{
    struct thread_info *ti = current_thread_info();

    /* 用于捕捉需要修补的调用者 */
    BUG_ON(ti->preempt_count || !irqs_disabled());
 
    do {
        /*内核抢占计数加一个较大的值PREEMPT_ACTIVE,表示正在处理抢占,由于计数值较大,基本上不会再进行抢占调度*/
        add_preempt_count(PREEMPT_ACTIVE); 
        local_irq_enable();   /*开启中断*/
        schedule();           /*内核调度,用于内核抢占,即运行优先级较高的任务*/
        local_irq_disable();  /*关闭中断*/
        sub_preempt_count(PREEMPT_ACTIVE);
 
        /*再次检查,避免在调度与现在时刻之间失去抢占机会*/
        barrier();   /*加内存屏障*/
    } while (unlikely(test_thread_flag(TIF_NEED_RESCHED)));
}

调度函数schedule会检测进程的 preempt_counter 是否很大,避免普通调度时又执行内核抢占调度。

(2)软中断返回执行抢占调度

在打开页出错函数pagefault_enable和软中断底半部开启函数local_bh_enable中,会调用函数preempt_check_resched检查是否需要执行内核抢占。如果不是并能调度,进程才可执行内核抢占调度。函数preempt_check_resched列出如下:

# define preempt_check_resched() /
do { /     /*如果不是普通调度,才可执行抢占调度*/
    if (unlikely(test_thread_flag(TIF_NEED_RESCHED))) /
        preempt_schedule(); /
} while (0)

函数preempt_schedule源代码与函数preempt_schedule_irq基本上一样,对进程进行调度,这里不再分析。

内存管理

页面置换算法

  • 最佳置换法(OPT)

最佳置换算法(OPT,Optimal) :每次选择淘汰的页面将是以后永不使用,或者在最长时间内不再被访问的页面,这样可以保证最低的缺页率。 最佳置换算法可以保证最低的缺页率,但实际上,只有在进程执行的过程中才能知道接下来会访问到的是哪个页面。操作系统无法提前预判页面访问序列。因此,最佳置换算法是无法实现的

  • 先进先出置换算法(FIFO) :

每次选择淘汰的页面是最早进入内存的页面 实现方法:把调入内存的页面根据调入的先后顺序排成一个队列,需要换出页面时选择队头页面队列的最大长度取决于系统为进程分配了多少个内存块。

Belady异常—当为进程分配的物理块数增大时,缺页次数不减反增的异常现象。

只有FIFO算法会产生Belady异常,而LRU和OPT算法永远不会出现Belady异常。另外,FIFO算法虽然实现简单,但是该算法与进程实际运行时的规律不适应,因为先进入的页面也有可能最经常被访问。因此,算法性能差

FIFO的性能较差,因为较早调入的页往往是经常被访问的页,这些页在FIFO算法下被反复调入和调出,并且有Belady现象。所谓Belady现象是指:采用FIFO算法时,如果对—个进程未分配它所要求的全部页面,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。

  • 最近最久未使用置换算法(LRU)

最近最久未使用置换算法(LRU,least recently used) :每次淘汰的页面是最近最久未使用的页面 实现方法:赋予每个页面对应的页表项中,用访问字段记录该页面自.上次被访问以来所经历的时间t(该算法的实现需要专门的硬件支持,虽然算法性能好,但是实现困难,开销大)。当需要淘汰一个页面时,选择现有页面中t值最大的,即最近最久未使用的页面。

LRU性能较好,但需要寄存器和栈的硬件支持。LRU是堆栈类算法,理论上可以证明,堆栈类算法不可能出现Belady异常。

在手动做题时,若需要淘汰页面,可以逆向检查此时在内存中的几个页面号。在逆向扫描过程中最后一个出现的页号就是要淘汰的页面。

  • 时钟置换算法(CLOCK)

最佳置换算法性OPT能最好,但无法实现;先进先出置换算法实现简单,但算法性能差;最近最久未使用置换算法性能好,是最接近OPT算法性能的,但是实现起来需要专门的硬件支持,算法开销大。

所以操作系统的设计者尝试了很多算法,试图用比较小的开销接近LRU的性能,这类算法都是CLOCK算法的变体,因为算法要循环扫描缓冲区像时钟一样转动。所以叫clock算法。

时钟置换算法是一种性能和开销较均衡的算法,又称CLOCK算法,或最近未用算法(NRU,Not Recently Used)

简单的CLOCK算法实现方法:为每个页面设置一个访问位,再将内存中的页面都通过链接指针链接成一个循环队列。当某页被访问时,其访问位置为1。当需要淘汰-一个页面时,只需检查页的访问位。如果是0,就选择该页换出;如果是1,则将它置为0,暂不换出,继续检查下一个页面,若第- - ~轮扫描中所有页面都是1,则将这些页面的访问位依次置为0后,再进行第二轮扫描(第二轮扫描中一定会有访问位为0的页面,因此简单的CLOCK算法选择–个淘汰页面最多会经过两轮扫描)

  • 改进型的时钟置换算法

简单的时钟置换算法仅考虑到一个页面最近是否被访问过。事实上,如果被淘汰的页面没有被修改过,就不需要执行I/O操作写回外存。只有被淘汰的页面被修改过时,才需要写回外存。

因此,除了考虑一个页面最近有没有被访问过之外,操作系统还应考虑页面有没有被修改过。在其他条件都相同时,应优先淘汰没有修改过的页面,避免I/O操作。这就是改进型的时钟置换算法的思想。修改位=0,表示页面没有被修改过;修改位=1,表示页面被修改过。

为方便讨论,用(访问位,修改位)的形式表示各页面状态。如(1, 1)表示一个页面近期被访问过,且被修改过。

改进型的Clock算法需要综合考虑某一内存页面的访问位和修改位来判断是否置换该页面。在实际编写算法过程中,同样可以用一个等长的整型数组来标识每个内存块的修改状态。访问位A和修改位M可以组成一下四种类型的页面。
算法规则:将所有可能被置换的页面排成–个循环队列

第一轮:从当前位置开始扫描到第一个(A =0, M = 0)的帧用于替换。表示该页面最近既未被访问,又未被修改,是最佳淘汰页 第二轮:若第一轮扫描失败,则重新扫描,查找第一个(A =1, M = 0)的帧用于替换。本轮将所有扫描过的帧访问位设为0。表示该页面最近未被访问,但已被修改,并不是很好的淘汰页。 第三轮:若第二轮扫描失败,则重新扫描,查找第一个(A =0, M = 1)的帧用于替换。本轮扫描不修改任何标志位。表示该页面最近已被访问,但未被修改,该页有可能再被访问。 第四轮:若第三轮扫描失败,则重新扫描,查找第一个A =1, M = 1)的帧用于替换。表示该页最近已被访问且被修改,该页可能再被访问。

由于第二轮已将所有帧的访问位设为0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型CLOCK置换算法选择- -个淘汰页面最多会进行四轮扫描

算法规则:将所有可能被置换的页面排成一个循环队列 第一轮:从当前位置开始扫描到第-一个(0, 0)的帧用于替换。本轮扫描不修改任何标志位。(第一优先级:最近没访问,且没修改的页面) 第二轮:若第一轮扫描失败,则重新扫描,查找第一个(0, 1)的帧用于替换。本轮将所有扫描过的帧访问位设为0 (第二优先级: 最近没访问,但修改过的页面) 第三轮:若第二轮扫描失败,则重新扫描,查找第一个(0, 0)的帧用于替换。本轮扫描不修改任何标志位(第三优先级:最近访问过,但没修改的页面) 第四轮:若第三轮扫描失败,则重新扫描,查找第一个(0, 1)的帧用于替换。(第四优先级:最近访问过,且修改过的页面) 由于第二轮已将所有帧的访问位设为0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型CLOCK置换算法选择一个淘汰页面最多会进行四轮扫描

虚拟内存的目的是什么?

虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。

为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。

这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。

从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。

例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。

如果系统中具有快表后,那么地址的转换过程变成什么样了?

  • CPU给出逻辑地址,由某个硬件算得页号、页内偏移量,将页号与快表中的所有页号进行比较。
  • 如果找到匹配的页号,说明要访问的页表项在快表中有副本,则直接从中取出该页对应的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表命中,则访问某个逻辑地址仅需一次访存即可。
  • 如果没有找到匹配的页号,则需要访问内存中的页表,找到对应页表项,得到页面存放的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表未命中,则访问某个逻辑地址需要两次访存(注意:在找到页表项后,应同时将其存入快表,以便后面可能的再次访问。但若快表已满,则必须按照-定的算法对旧的页表项进行替换)

由于查询快表的速度比查询页表的速度快很多,因此只要快表命中,就可以节省很多时间。 因为局部性原理,–般来说快表的命中率可以达到90%以上。

例:某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。访问- -次快表耗时1us, 访问一次内存耗时100us。若快表的命中率为90%,那么访问一个逻辑地址的平均耗时是多少? (1+100) x 0.9 + (1+100+100) x 0.1 = 111 us 有的系统支持快表和慢表同时查找,如果是这样,平均耗时应该是(1+100) x 0.9+ (100+100) x 0.1=110.9 us 若未采用快表机制,则访问一个逻辑地址需要100+100 = 200us 显然,引入快表机制后,访问一个逻辑地址的速度快多了。

虚拟技术你了解吗?

虚拟技术把一个物理实体转换为多个逻辑实体。

主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。

多进程与多线程:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。

虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。

操作系统在对内存进行管理的时候需要做些什么?

操作系统负责内存空间的分配与回收。
操作系统需要提供某种技术从逻辑上对内存空间进行扩充。
操作系统需要提供地址转换功能,负责程序的逻辑地址与物理地址的转换。
操作系统需要提供内存保护功能。保证各进程在各自存储空间内运行,互不干扰

内存的覆盖是什么?有什么特点?

由于程序运行时并非任何时候都要访问程序及数据的各个部分(尤其是大程序),因此可以把用户空间分成为一个固定区和若干个覆盖区。将经常活跃的部分放在固定区,其余部分按照调用关系分段,首先将那些即将要访问的段放入覆盖区,其他段放在外存中,在需要调用前,系统将其调入覆盖区,替换覆盖区中原有的段。

覆盖技术的特点:是打破了必须将一个进程的全部信息装入内存后才能运行的限制,但当同时运行程序的代码量大于主存时仍不能运行,再而,大家要注意到,内存中能够更新的地方只有覆盖区的段,不在覆盖区的段会常驻内存。

内存交换是什么?有什么特点?

交换(对换)技术的设计思想:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)

换入:把准备好竞争CPU运行的程序从辅存移到内存。 换出:把处于等待状态(或CPU调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。

内存交换中,被换出的进程保存在哪里?

保存在磁盘中,也就是外存中。具有对换功能的操作系统中,通常把磁盘空间分为文件区和对换区两部分。文件区主要用于存放文件,主要追求存储空间的利用率,因此对文件区空间的管理采用离散分配方式;对换区空间只占磁盘空间的小部分,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区空间的管理主要追求换入换出速度,因此通常对换区采用连续分配方式(学过文件管理章节后即可理解)。总之,对换区的I/O速度比文件区的更快。

内存交换你知道有哪些需要注意的关键点吗?

交换需要备份存储,通常是快速磁盘,它必须足够大,并且提供对这些内存映像的直接访问。

为了有效使用CPU,需要每个进程的执行时间比交换时间长,而影响交换时间的主要是转移时间,转移时间与所交换的空间内存成正比。
如果换出进程,比如确保该进程的内存空间成正比。

交换空间通常作为磁盘的一整块,且独立于文件系统,因此使用就可能很快。

交换通常在有许多进程运行且内存空间吃紧时开始启动,而系统负荷降低就暂停。

普通交换使用不多,但交换的策略的某些变种在许多系统中(如UNIX系统)仍然发挥作用。

在执行malloc申请内存的时候,操作系统是怎么做的?

从操作系统层面上看,malloc是通过两个系统调用来实现的: brk和mmap

  • brk是将进程数据段(.data)的最高地址指针向高处移动,这一步可以扩大进程在运行时的堆大小
  • mmap是在进程的虚拟地址空间中寻找一块空闲的虚拟内存,这一步可以获得一块可以操作的堆内存。

通常,分配的内存小于128k时,使用brk调用来获得虚拟内存,大于128k时就使用mmap来获得虚拟内存。

进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才算完成。

一个由C/C++编译的程序占用的内存分为哪几个部分?

1、栈区(stack)— 地址向下增长,由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的数据结构中的栈,先进后出。
2、堆区(heap)— 地址向上增长,一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
3、全局区(静态区)(static)—全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 - 程序结束后有系统释放
4、文字常量区 —常量字符串就是放在这里的。程序结束后由系统释放
5、程序代码区(text)—存放函数体的二进制代码。

程序从堆中动态分配内存时,虚拟内存上怎么操作的

页表:是一个存放在物理内存中的数据结构,它记录了虚拟页与物理页的映射关系

在进行动态内存分配时,例如malloc()函数或者其他高级语言中的new关键字,操作系统会在硬盘中创建或申请一段虚拟内存空间,并更新到页表(分配一个页表条目(PTE),使该PTE指向硬盘上这个新创建的虚拟页),通过PTE建立虚拟页和物理页的映射关系。

交换空间与虚拟内存的关系

  • 交换空间
    Linux 中的交换空间(Swap space)在物理内存(RAM)被充满时被使用。如果系统需要更多的内存资源,而物理内存已经充满,内存中不活跃的页就会被移到交换空间去。虽然交换空间可以为带有少量内存的机器提供帮助,但是这种方法不应该被当做是对内存的取代。交换空间位于硬盘驱动器上,它比进入物理内存要慢。 交换空间可以是一个专用的交换分区(推荐的方法),交换文件,或两者的组合。 交换空间的总大小应该相当于你的计算机内存的两倍和 32 MB这两个值中较大的一个,但是它不能超过 2048MB(2 GB)。
  • 虚拟内存
    虚拟内存是文件数据交叉链接的活动文件。是WINDOWS目录下的一个”WIN386.SWP”文件,这个文件会不断地扩大和自动缩小。 就速度方面而言,CPU的L1和L2缓存速度最快,内存次之,硬盘再次之。但是虚拟内存使用的是硬盘的空间,为什么我们要使用速度最慢的硬盘来做 为虚拟内存呢?因为电脑中所有运行的程序都需要经过内存来执行,如果执行的程序很大或很多,就会导致我们只有可怜的256M/512M内存消耗殆尽。而硬盘空间动辄几十G上百G,为了解决这个问题,Windows中运用了虚拟内存技术,即拿出一部分硬盘空间来充当内存使用。

抖动你知道是什么吗?它也叫颠簸现象

刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)

为进程分配的物理块太少,会使进程发生抖动现象。为进程分配的物理块太多,又会降低系统整体的并发度,降低某些资源的利用率 为了研究为应该为每个进程分配多少个物理块,Denning 提出了进程工作集” 的概念

从堆和栈上建立对象哪个快?(考察堆和栈的分配效率比较)

从两方面来考虑:

分配和释放,堆在分配和释放时都要调用函数(malloc,free),比如分配时会到堆空间去寻找足够大小的空间(因为多次分配释放后会造成内存碎片),这些都会花费一定的时间,具体可以看看malloc和free的源代码,函数做了很多额外的工作,而栈却不需要这些。

访问时间,访问堆的一个具体单元,需要两次访问内存,第一次得取得指针,第二次才是真正的数据,而栈只需访问一次。另外,堆的内容被操作系统交换到外存的概率比栈大,栈一般是不会被交换出去的。

常见内存分配方式有哪些?

  • 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。
  • 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
  • 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。

常见内存分配内存错误

  • 内存分配未成功,却使用了它。
    编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。
  • 内存分配虽然成功,但是尚未初始化就引用它。
    犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
  • 内存分配成功并且已经初始化,但操作越过了内存的边界。
    例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。
  • 忘记了释放内存,造成内存泄露。
    含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然挂掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。
  • 释放了内存却继续使用它。常见于以下有三种情况:
    • 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
    • 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
    • 使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

中断

中断处理流程,中断的现场保护保护什么

响应中断-关中断-根据中断源跳转服务程序-现场保护-中断服务-恢复现场-开中断

CPU开始利用栈保护被暂停执行的程序的现场:依次压入当前程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息护现场就是:当出现中断时,把CPU的状态,也就是中断的入口地址保存在寄存器中,随后转向执行其他任务,当任务完成,从寄存器中取出地址继续执行。保护现场其实就是保存中断前一时刻的状态不被破坏。

中断的传递过程,信号量监听的函数

当linux内核空间发生中断后怎么使用户空间的应用程序运行相应的函数呢,当芯片有数据到来时内核会产生一个中断,但是怎样通知应用程序来取数据?linux中有异步通知机制,在用户程序中用signal注册一个响应SIGIO信号的回调函数,然后在驱动程序中向该进程发出SIGIO信号便完成该功能,下面是该功能具体实施方法:

  • 在驱动中定义一个static struct fasync_struct * async;
  • 在fasync系统调用中注册fasync_helper(fd, filp, mode, &async);
  • 在中断服务程序(顶半部、底半部都可以)发出信号kill_fasync(&async, SIGIO, POLL_IN);
  • 在用户应用程序中用signal注册一个响应SIGIO的回调函数signal(SIGIO, sig_handler);
  • 通过fcntl(fd, F_SETOWN, getpid())将将进程pid传入内核6.通过fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | FASYNC)设置异步通知

参考代码

void sig_handler(int sig) {
    if (sig == SIGUSR1) {
        // printf("Receive io signal from kernel!\n");
        spi_test(spifd);
    }
}

int main(int argc, char *argv[]) {
    //......
    signal(SIGUSR1, sig_handler);
    parse_opts(argc, argv);
    spifd = open(device, O_RDWR);
    fcntl(spifd, F_SETSIG, SIGUSR1);
    fcntl(spifd, F_SETOWN, getpid());
    //fcntl(spifd, F_GETOWN, getpid());
    fcntl(spifd, F_SETFL, fcntl(spifd, F_GETFL) | FASYNC);
    printf("waiting key interrupt:\n");
    while(1);
}

在驱动中注册模块时,需要注册中断,中断的回调函数发送信号,即可完成信号的传递。

补充:
我们在使用request_irq申请中断的时候注册的中断服务函数属于中断处理的上半部,只要中断触发,那么中断处理函数就会执行。我们都知道中断处理函数一定要快点执行完毕,越短越好,但是现实往往是残酷的,有些中断处理过程就是比较费时间,我们必须要对其进行处理,缩小中断处理函数的执行时间。比如电容触摸屏通过中断通知 SOC 有触摸事件发生,SOC 响应中断,然后通过 IIC 接口读取触摸坐标值并将其上报给系统。但是我们都知道 IIC 的速度最高也只有400Kbit/S,所以在中断中通过 IIC 读取数据就会浪费时间。我们可以将通过 IIC 读取触摸数据的操作暂后执行,中断处理函数仅仅相应中断,然后清除中断标志位即可。这个时候中断处理过程就分为了两部分:

  • 上半部:上半部就是中断处理函数,那些处理过程比较快,不会占用很长时间的处理就可以放在上半部完成。
  • 下半部:如果中断处理过程比较耗时,那么就将这些比较耗时的代码提出来,交给下半部去执行,这样中断处理函数就会快进快出。

因此,Linux 内核将中断分为上半部和下半部的主要目的就是实现中断处理函数的快进快出,那些对时间敏感、执行速度快的操作可以放到中断处理函数中,也就是上半部。剩下的所有工作都可以放到下半部去执行,比如在上半部将数据拷贝到内存中,关于数据的具体处理就可以放到下半部去执行。至于哪些代码属于上半部,哪些代码属于下半部并没有明确的规定,一切根据实际使用情况去判断,这个就很考验驱动编写人员的功底了。这里有一些可以借鉴的参考点:

  • 如果要处理的内容不希望被其他中断打断,那么可以放到上半部。
  • 如果要处理的任务对时间敏感,可以放到上半部。
  • 如果要处理的任务与硬件有关,可以放到上半部
  • 除了上述三点以外的其他任务,优先考虑放到下半部。

上半部处理很简单,直接编写中断处理函数就行了,关键是下半部该怎么做呢?Linux 内核提供了多种下半部机制。例如软中断、tasklet、工作队列等。

外中断和异常有什么区别?

外中断是指由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
而异常时由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。

Linux中异常和中断的区别

  • 中断

大家都知道,当我们在敲击键盘的同时就会产生中断,当硬盘读写完数据之后也会产生中断,所以,我们需要知道,中断大多数是由硬件设备产生的,而它们从物理上说就是电信号,之后,它们通过中断控制器发送给CPU,接着CPU判断收到的中断来自于哪个硬件设备(这定义在内核中),最后,由CPU发送给内核,有内核处理中断。下面这张图显示了中断处理的流程:

软件中断与软中断 除了硬件中断,还存在部分由软件产生的中断,称之为软件中断(Software Interrupt),最常见的有引发系统调用的Int 0x80。同时,软件中断又区别于软中断(SoftIRQ),软中断主要用于中断处理的下半程(Bottom Halves),非关键逻辑的部分,来提高中断处理的效率与实时性,最常用于I/O相关的中断处理。 处理中断下半程的方法除了软中断,还有tasklet,二者存在一定的区别与联系:

二者都可以被注册用于中断处理下半程的延时任务;

同一种类型的tasklet只能串行执行,而同类型的软中断可以多个CPU并发执行,不同类型的软中断和tasklet均可并发执行;

tasklet底层是基于两种软中断来实现的,分别是HI_SOFTIRQ和TASKLET_SOFTIRQ;

  • 异常

我们在学习《计算机组成原理》的时候会知道两个概念,CPU处理程序的时候一旦程序不在内存中,会产生缺页异常;当运行除法程序时,当除数为0时,又会产生除0异常。所以,大家也需要记住的是,异常是由CPU产生的,同时,它会发送给内核,要求内核处理这些异常,下面这张图显示了异常处理的流程:

  • 相同点

最后都是由CPU发送给内核,由内核去处理
处理程序的流程设计上是相似的

  • 不同点

产生源不相同,异常是由CPU产生的,而中断主要是由硬件设备产生的
内核需要根据是异常还是中断调用不同的处理程序
中断不是时钟同步的,这意味着中断可能随时到来;异常由于是CPU产生的,所以它是时钟同步的
当处理中断时,处于中断上下文中;处理异常时,处于进程上下文中

临界资源

介绍一下几种典型的锁?

  • 读写锁

多个读者可以同时进行读
写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

  • 互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁

  • 条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制

  • 自旋锁

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

原子操作的是如何实现的

处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。

首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

  • 使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如图下图所示。

CPU1    CPU2
 i=1     i=1
 i+1     i+1
 i=2     i=2

原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

  • 使用缓存锁保证原子性

第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如上图所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能使用同时缓存i的缓存行。

但是有两种情况下处理器不会使用缓存锁定。 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。 第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

文件系统

文件系统的制作和优化方式,不同文件系统格式内核是如何管理的

  • 根文件系统之所以在前面加一个”根“,说明它是加载其它文件系统的”根“,既然是根的话,那么如果没有这个根,其它的文件系统也就没有办法进行加载的。它包含系统引导和使其他文件系统得以挂载(mount)所必要的文件。根文件系统包括Linux启动时所必须的目录和关键性的文件,例如Linux启动时都需要有init目录下的相关文件,在 Linux挂载分区时Linux一定会找/etc/fstab这个挂载文件等,根文件系统中还包括了许多的应用程序bin目录等,任何包括这些Linux 系统启动所必须的文件都可以成为根文件系统。Linux启动时,第一个必须挂载的是根文件系统;若系统不能从指定设备上挂载根文件系统,则系统会出错而退出启动。成功之后可以自动或手动挂载其他的文件系统。因此,一个系统中可以同时存在不同的文件系统。在 Linux 中将一个文件系统与一个存储设备关联起来的过程称为挂载(mount)。使用 mount 命令将一个文件系统附着到当前文件系统层次结构中(根)。在执行挂装时,要提供文件系统类型、文件系统和一个挂装点。根文件系统被挂载到根目录下“/”上后,在根目录下就有根文件系统的各个目录,文件:/bin /sbin /mnt等,再将其他分区挂接到/mnt目录上,/mnt目录下就有这个分区的各个目录,文件。
  • 技术上说Linux是一个内核。“内核”指的是一个提供硬件抽象层、磁盘及文件系统控制、多任务等功能的系统软件。一个内核不是一套完整的操作系统。一套基于Linux内核的完整操作系统叫作Linux操作系统,或是GNU/Linux。
  • 文件系统是kernel的一部分。文件系统实现了系统上存储介质和其他资源的交互。kernel tree中的fs目录都是关于文件系统的,可以说它是kernel的一个大子系统。
  • 嵌入式系统在flash中分配了存放内核、根文件系统的区域。bootloader加载了内核,内核启动,加载文件系统,进入Linux系统。
  • 整个嵌入式系统而言,可以分为三个部分1.uboot 2.kernel 3.文件系统。其中kernel中以VFS去支持各种文件系统,如yaffs,ext3,cramfs等等。yaffs/yaffs2是专为嵌入式系统使用NAND型闪存而设计的一种日志型文件系统。在内核中以VFS来屏蔽各种文件系统的接口不同,以VFS向kernel提供一个统一的接口。如打开一个文件时统一使用open,写时采用write,而不用去考虑是那种文件系统,也不用去考虑文件系统是如何将数据写入物理介质的。其中 kernel中的配置,只是让VFS支持这种接口。

参考 vfsvfsvfs

操作系统知识点

基本特征

  • 并发

    • 并发是指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。
    • 并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。
    • 操作系统通过引入进程和线程,使得程序能够并发运行。
  • 共享

    • 共享是指系统中的资源可以被多个并发进程共同使用。
    • 有两种共享方式:互斥共享和同时共享。
    • 互斥共享的资源称为临界资源,例如打印机等,在同一时间只允许一个进程访问,需要用同步机制来实现对临界资源的访问。
  • 虚拟

    • 虚拟技术把一个物理实体转换为多个逻辑实体。
    • 主要有两种虚拟技术:时分复用技术和空分复用技术。
    • 多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占有处理器,每次只执行一小个时间片并快速切换。
    • 虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。
  • 异步

    • 异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。

基本功能

  • 进程管理
    • 进程控制、进程同步、进程通信、死锁处理、处理机调度等。
  • 内存管理
    • 内存分配、地址映射、内存保护与共享、虚拟内存等。
  • 文件管理
    • 文件存储空间的管理、目录管理、文件读写管理和保护等。
  • 设备管理
    • 完成用户的 I/O 请求,方便用户使用各种设备,并提高设备的利用率。
    • 主要包括缓冲管理、设备分配、设备处理、虛拟设备等。
  • 系统调用
    • 如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
    • Linux 的系统调用主要有以下这些:
Task Commands
进程控制 fork(); exit(); wait();
进程通信 pipe(); shmget(); mmap();
文件操作 open(); read(); write();
设备操作 ioctl(); read(); write();
信息维护 getpid(); alarm(); sleep();
安全 chmod(); umask(); chown();

大内核和微内核

  • 大内核
    大内核是将操作系统功能作为一个紧密结合的整体放到内核。由于各模块共享信息,因此有很高的性能。
  • 微内核
    由于操作系统不断复杂,因此将一部分操作系统功能移出内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。

中断分类

  • 外中断
    由CPU执行指令以外的事件引起,如I/O完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
  • 异常
    由CPU执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。
  • 陷入
    在用户程序中使用系统调用。

进程管理

进程与线程

  • 进程
    进程是资源分配的基本单位。
    进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。
  • 线程
    线程是独立调度的基本单位。一个进程中可以有多个线程,它们共享进程资源。QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 HTTP 请求线程、事件响应线程、渲染线程等等,线程的并发执行使得在浏览器中点击一个新链接从而发起 HTTP 请求时,浏览器还可以响应用户的其它事件。

区别

  • 拥有资源
    进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
  • 调度
    线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
  • 系统开销
    由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
  • 通信方面
    线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。

进程状态的切换

  • 就绪状态(ready):等待被调度
  • 运行状态(running)
  • 阻塞状态(waiting):等待资源

应该注意以下内容:

  • 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
  • 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。

进程调度算法

不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。

  • 批处理系统
    批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。

    • 先来先服务 first-come first-serverd(FCFS)
      按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
    • 短作业优先 shortest job first(SJF)
      按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
    • 最短剩余时间优先 shortest remaining time next(SRTN)
      按估计剩余时间最短的顺序进行调度。
  • 交互式系统
    交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。

    • 时间片轮转
      将所有就绪进程按FCFS的原则排成一个队列,每次调度时,把CPU时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系:

      • 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
      • 而如果时间片过长,那么实时性就不能得到保证。
    • 优先级调度
      为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

    • 多级反馈队列
      一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。
      每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
      可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。

  • 实时系统
    实时系统要求一个请求在一个确定时间内得到响应。分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。

进程同步

  • 临界区
    对临界资源进行访问的那段代码称为临界区。为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
// entry section
// critical section;
// exit section
  • 同步与互斥
    同步:多个进程按一定顺序执行;
    互斥:多个进程在同一时刻只有一个进程能进入临界区。

  • 信号量
    信号量(Semaphore)是一个整型变量,可以对其执行down和up操作,也就是常见的P和V操作。
    down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
    up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
    down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
    如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

typedef int semaphore;
semaphore mutex = 1;
void P1() {
    down(&mutex);
    // 临界区
    up(&mutex);
}
 
void P2() {
    down(&mutex);
    // 临界区
    up(&mutex);
}
  • 使用信号量实现生产者-消费者问题
    问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
    因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。
    为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。
    注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
 
void producer() {
    while(TRUE) {
        int item = produce_item();
        down(&empty);
        down(&mutex);
        insert_item(item);
        up(&mutex);
        up(&full);
    }
}
 
void consumer() {
    while(TRUE) {
        down(&full);
        down(&mutex);
        int item = remove_item();
        consume_item(item);
        up(&mutex);
        up(&empty);
    }
}
  • 管程
    使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。c语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
monitor ProducerConsumer
    integer i;
    condition c;
 
    procedure insert();
    begin
        // ...
    end;
 
    procedure remove();
    begin
        // ...
    end;
end monitor;

管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否者其它进程永远不能使用管程。
管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

  • 使用管程实现生产者-消费者问题
monitor ProducerConsumer
    condition full, empty;
    integer count := 0;
    condition c;
 
    procedure insert(item: integer);
    begin
        if count = N then wait(full);
        insert_item(item);
        count := count + 1;
        if count = 1 then signal(empty);
    end;
 
    function remove: integer;
    begin
        if count = 0 then wait(empty);
        remove = remove_item;
        count := count - 1;
        if count = N -1 then signal(full);
    end;
end monitor;
 
// 生产者客户端
procedure producer
begin
    while true do
    begin
        item = produce_item;
        ProducerConsumer.insert(item);
    end
end;
 
// 消费者客户端
procedure consumer
begin
    while true do
    begin
        item = ProducerConsumer.remove;
        consume_item(item);
    end
end;

经典同步问题

生产者和消费者问题前面已经讨论过了。

  • 读者-写者问题
    允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
    一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
 
void reader() {
    while(TRUE) {
        down(&count_mutex);
        count++;
        if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
        up(&count_mutex);
        read();
        down(&count_mutex);
        count--;
        if(count == 0) up(&data_mutex);
        up(&count_mutex);
    }
}
 
void writer() {
    while(TRUE) {
        down(&data_mutex);
        write();
        up(&data_mutex);
    }
}

以下内容由 @Bandi Yugandhar 提供。
The first case may result Writer to starve. This case favous Writers i.e no writer, once added to the queue, shall be kept waiting longer than absolutely necessary(only when there are readers that entered the queue before the writer).

int readcount, writecount;                    //(initial value = 0)
semaphore rmutex, wmutex, readLock, resource; //(initial value = 1)
 
//READER
void reader() {
<ENTRY Section>
  down(&readLock);                //  reader is trying to enter
  down(&rmutex);                  //   lock to increase readcount
  readcount++;                 
  if (readcount == 1)          
    down(&resource);              //if you are the first reader then lock  the resource
  up(&rmutex);                    //release  for other readers
  up(&readLock);                  //Done with trying to access the resource
 
<CRITICAL Section>
//reading is performed
 
<EXIT Section>
  down(&rmutex);                  //reserve exit section - avoids race condition with readers
  readcount--;                    //indicate you're leaving
  if (readcount == 0)             //checks if you are last reader leaving
    up(&resource);                //if last, you must release the locked resource
  up(&rmutex);                    //release exit section for other readers
}
 
//WRITER
void writer() {
  <ENTRY Section>
  down(&wmutex);                  //reserve entry section for writers - avoids race conditions
  writecount++;                   //report yourself as a writer entering
  if (writecount == 1)            //checks if you're first writer
    down(&readLock);              //if you're first, then you must lock the readers out. Prevent them from trying to enter CS
  up(&wmutex);                    //release entry section
 
<CRITICAL Section>
  down(&resource);                //reserve the resource for yourself - prevents other writers from simultaneously editing the shared resource
  //writing is performed
  up(&resource);                  //release file
 
<EXIT Section>
  down(&wmutex);                  //reserve exit section
  writecount--;                   //indicate you're leaving
  if (writecount == 0)            //checks if you're the last writer
    up(&readLock);                //if you're last writer, you must unlock the readers. Allows them to try enter CS for reading
  up(&wmutex);                    //release exit section
}

We can observe that every reader is forced to acquire ReadLock. On the otherhand, writers doesn’t need to lock individually. Once the first writer locks the ReadLock, it will be released only when there is no writer left in the queue.

From the both cases we observed that either reader or writer has to starve. Below solutionadds the constraint that no thread shall be allowed to starve; that is, the operation of obtaining a lock on the shared data will always terminate in a bounded amount of time.

int readCount;                  // init to 0; number of readers currently accessing resource
 
// all semaphores initialised to 1
Semaphore resourceAccess;       // controls access (read/write) to the resource
Semaphore readCountAccess;      // for syncing changes to shared variable readCount
Semaphore serviceQueue;         // FAIRNESS: preserves ordering of requests (signaling must be FIFO)
 
void writer()
{ 
    down(&serviceQueue);           // wait in line to be servicexs
    // <ENTER>
    down(&resourceAccess);         // request exclusive access to resource
    // </ENTER>
    up(&serviceQueue);             // let next in line be serviced
 
    // <WRITE>
    writeResource();               // writing is performed
    // </WRITE>
 
    // <EXIT>
    up(&resourceAccess);           // release resource access for next reader/writer
    // </EXIT>
}
 
void reader()
{ 
    down(&serviceQueue);           // wait in line to be serviced
    down(&readCountAccess);        // request exclusive access to readCount
    // <ENTER>
    if (readCount == 0)            // if there are no readers already reading:
        down(&resourceAccess);     // request resource access for readers (writers blocked)
    readCount++;                   // update count of active readers
    // </ENTER>
    up(&serviceQueue);             // let next in line be serviced
    up(&readCountAccess);          // release access to readCount
 
    // <READ>
    readResource();                // reading is performed
    // </READ>
 
    down(&readCountAccess);        // request exclusive access to readCount
    // <EXIT>
    readCount--;                   // update count of active readers
    if (readCount == 0)            // if there are no readers left:
        up(&resourceAccess);       // release resource access for all
    // </EXIT>
    up(&readCountAccess);          // release access to readCount
}
  • 哲学家进餐问题
    五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。
#define N 5
 
void philosopher(int i) {
    while(TRUE) {
        think();
        take(i);       // 拿起左边的筷子
        take((i+1)%N); // 拿起右边的筷子
        eat();
        put(i);
        put((i+1)%N);
    }
}

为了防止死锁的发生,可以设置两个条件:

  • 必须同时拿起左右两根筷子;
  • 只有在两个邻居都没有进餐的情况下才允许进餐。
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N    // 右邻居
#define THINKING 0
#define HUNGRY   1
#define EATING   2
typedef int semaphore;
int state[N];                // 跟踪每个哲学家的状态
semaphore mutex = 1;         // 临界区的互斥
semaphore s[N];              // 每个哲学家一个信号量
 
void philosopher(int i) {
    while(TRUE) {
        think();
        take_two(i);
        eat();
        put_two(i);
    }
}
 
void take_two(int i) {
    down(&mutex);
    state[i] = HUNGRY;
    test(i);
    up(&mutex);
    down(&s[i]);
}
 
void put_two(i) {
    down(&mutex);
    state[i] = THINKING;
    test(LEFT);
    test(RIGHT);
    up(&mutex);
}
 
void test(i) {         // 尝试拿起两把筷子
    if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
        state[i] = EATING;
        up(&s[i]);
    }
}

进程通信

进程同步与进程通信很容易混淆,它们的区别在于:进程同步:控制多个进程按一定顺序执行;进程通信:进程间传输信息。
进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。

  • 管道
    管道是通过调用 pipe 函数创建的,fd[0]用于读,fd[1]用于写。
#include <unistd.h>
int pipe(int fd[2]);

它具有以下限制:只支持半双工通信(单向交替传输);只能在父子进程中使用。

  • FIFO
    也称为命名管道,去除了管道只能在父子进程中使用的限制。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);

FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。

  • 消息队列
    相比于 FIFO,消息队列具有以下优点:

    • 消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;
    • 避免了FIFO的同步阻塞问题,不需要进程自己提供同步方法;
    • 读进程可以根据消息类型有选择地接收消息,而不像FIFO那样只能默认地接收。
  • 信号量
    它是一个计数器,用于为多个进程提供对共享数据对象的访问。

  • 共享存储
    允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。
    需要使用信号量用来同步对共享存储的访问。
    多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用使用内存的匿名段。

  • 套接字
    与其它通信机制不同的是,它可用于不同机器间的进程通信。

死锁

必要条件

  • 互斥:每个资源要么已经分配给了一个进程,要么就是可用的。
  • 占有和等待:已经得到了某个资源的进程可以再请求新的资源。
  • 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
  • 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。

处理方法

主要有以下四种方法:

  • 鸵鸟策略
  • 死锁检测与死锁恢复
  • 死锁预防
  • 死锁避免
鸵鸟策略

把头埋在沙子里,假装根本没发生问题。因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。大多数操作系统,包括 Unix,Linux 和 Windows,处理死锁问题的办法仅仅是忽略它。
死锁检测与死锁恢复
不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。

  • 每种类型一个资源的死锁检测
    每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。
  • 每种类型多个资源的死锁检测
    每个进程最开始时都不被标记,执行过程有可能被标记。当算法结束时,任何没有被标记的进程都是死锁进程。寻找一个没有标记的进程 Pi,它所请求的资源小于等于 A。如果找到了这样一个进程,那么将 C 矩阵的第 i 行向量加到 A 中,标记该进程,并转回 1。如果没有这样一个进程,算法终止。
死锁恢复
  • 利用抢占恢复
  • 利用回滚恢复
  • 通过杀死进程恢复
死锁预防

在程序运行之前预防发生死锁。

  • 破坏互斥条件
    例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。
  • 破坏占有和等待条件
    一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
  • 破坏不可抢占条件
  • 破坏环路等待
    给资源统一编号,进程只能按编号顺序来请求资源。
死锁避免

在程序运行时避免发生死锁。

  • 安全状态
    • 定义:如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。安全状态的检测与死锁的检测类似,因为安全状态必须要求不能发生死锁。
  • 单个资源的银行家算法
  • 多个资源的银行家算法

内存管理

虚拟内存

虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。

分页系统地址映射

内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表(Page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量。
下图的页表存放着 16 个页,这 16 个页需要用 4 个比特位来进行索引定位。例如对于虚拟地址(0010 000000000100),前 4 位是存储页面号 2,读取表项内容为(110 1),页表项最后一位表示是否存在于内存中,1 表示存在。后 12 位存储偏移量。这个页对应的页框的地址为 (110 000000000100)。

页面置换算法

在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
页面置换算法和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓存的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间存放新的缓存数据。页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。

  • 最佳OPT, Optimal replacement algorithm

    • 所选择的被换出的页面将是最长时间内不再被访问,通常可以保证获得最低的缺页率。
    • 是一种理论上的算法,因为无法知道一个页面多长时间不再被访问。
    • 举例:一个系统为某进程分配了三个物理块,并有如下页面引用序列:
    • 开始运行时,先将 7, 0, 1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。
  • 最近最久未使用LRU, Least Recently Used

    • 虽然无法知道将来要使用的页面情况,但是可以知道过去使用页面的情况。LRU 将最近最久未使用的页面换出。
    • 为了实现 LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。
    • 因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高。
  • 最近未使用NRU, Not Recently Used
    每个页面都有两个状态位:R 与 M,当页面被访问时设置页面的 R=1,当页面被修改时设置 M=1。其中 R 位会定时被清零。可以将页面分成以下四类:

R=0,M=0
R=0,M=1
R=1,M=0
R=1,M=1

当发生缺页中断时,NRU算法随机地从类编号最小的非空类中挑选一个页面将它换出。
NRU优先换出已经被修改的脏页面(R=0,M=1),而不是被频繁使用的干净页面(R=1,M=0)。

  • 先进先出FIFO, First In First Out
    选择换出的页面是最先进入的页面。该算法会将那些经常被访问的页面也被换出,从而使缺页率升高。

  • 第二次机会算法

    • FIFO算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:
      • 当页面被访问 (读或写) 时设置该页面的 R 位为 1。需要替换的时候,检查最老页面的 R 位。如果 R 位是 0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是 1,就将 R 位清 0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。
  • 时钟Clock
    第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面连接起来,再使用一个指针指向最老的页面。

分段

虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
下图为一个编译器在编译过程中建立的多个表,有 4 个表是动态增长的,如果使用分页系统的一维地址空间,动态增长的特点会导致覆盖问题的出现。

分段的做法是把每个表分成段,一个段构成一个独立的地址空间。每个段的长度可以不同,并且可以动态增长。

段页式

程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。

分页与分段的比较

  • 对程序员的透明性:分页透明,但是分段需要程序员显示划分每个段。
  • 地址空间的维度:分页是一维地址空间,分段是二维的。
  • 大小是否可以改变:页的大小不可变,段的大小可以动态改变。
  • 出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。

设备管理

磁盘结构

  • 盘面(Platter):一个磁盘有多个盘面;

  • 磁道(Track):盘面上的圆形带状区域,一个盘面可以有多个磁道;

  • 扇区(Track Sector):磁道上的一个弧段,一个磁道可以有多个扇区,它是最小的物理储存单位,目前主要有 512 bytes 与 4 K 两种大小;

  • 磁头(Head):与盘面非常接近,能够将盘面上的磁场转换为电信号(读),或者将电信号转换为盘面的磁场(写);

  • 制动手臂(Actuator arm):用于在磁道之间移动磁头;

  • 主轴(Spindle):使整个盘面转动。

磁盘调度算法

读写一个磁盘块的时间的影响因素有:

  • 主旋转时间(主轴转动盘面,使得磁头移动到适当的扇区上)
  • 主寻道时间(制动手臂移动,使得磁头移动到适当的磁道上)
  • 主实际的数据传输时间

其中,寻道时间最长,因此磁盘调度的主要目标是使磁盘的平均寻道时间最短。

先来先服务FCFS, First Come First Serve

按照磁盘请求的顺序进行调度。优点是公平和简单。缺点也很明显,因为未对寻道做任何优化,使平均寻道时间可能较长。

最短寻道时间优先SSTF, Shortest Seek Time First

优先调度与当前磁头所在磁道距离最近的磁道。虽然平均寻道时间比较低,但是不够公平。如果新到达的磁道请求总是比一个在等待的磁道请求近,那么在等待的磁道请求会一直等待下去,也就是出现饥饿现象。具体来说,两端的磁道请求更容易出现饥饿现象。

电梯算法SCAN

电梯总是保持一个方向运行,直到该方向没有请求为止,然后改变运行方向。
电梯算法(扫描算法)和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。
因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了 SSTF 的饥饿问题。

链接

编译系统

  • 预处理阶段:处理以 # 开头的预处理命令;
  • 编译阶段:翻译成汇编文件;
  • 汇编阶段:将汇编文件翻译成可重定向目标文件;
  • 链接阶段:将可重定向目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件。

静态链接

静态链接器以一组可重定向目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:

  • 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
  • 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

目标文件

  • 可执行目标文件:可以直接在内存中执行;

  • 可重定向目标文件:可与其它可重定向目标文件在链接阶段合并,创建一个可执行目标文件;

  • 共享目标文件:这是一种特殊的可重定向目标文件,可以在运行时被动态加载进内存并链接;

动态链接

静态库有以下两个问题:

  • 当静态库更新时那么整个程序都要重新进行链接;
  • 对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。

共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:

  • 在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
  • 在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。

Linux驱动的开发过程

嵌入式系统中,操作系统是通过各种驱动程序来驾驭硬件设备的。设备驱动程序是操作系统内核和硬件设备之间的接口,它为应用程序屏蔽了硬件的细节,这样在应用程序看来,硬件设备只是一个设备文件,可以像操作普通文件一样对硬件设备进行操作。设备驱动程序是内核的一部分,完成以下功能:
驱动程序的注册和注销。

  • 设备的打开和释放。
  • 设备的读写操作。
  • 设备的控制操作。
  • 设备的中断和轮询处理。

Linux主要将设备分为三类:字符设备、块设备和网络设备。字符设备是指发送和接收数据以字符的形式进行,没有缓冲区的设备;块设备是指发送和接收数据以整个数据缓冲区的形式进行的设备;网络设备是指网络设备访问的BSD socket 接口。下面以字符设备为例,写出其驱动编写框架:

编写驱动程序初始化函数

驱动程序的初始化在函数xxx_init()中完成,包括对硬件初始化、中断函数、向内核注册驱动程序等。

  • 首先理解硬件结构,搞清楚其功能,接口寄存器以及CPU怎么访问控制这些寄存器等。
  • 其次向内核注册驱动程序。设备驱动程序可以直接编译进内核,在系统启动的时候初始化,也可以在需要的时候以模块的方式动态加载到内核中去。每个字符设备或是块设备都是通过register_chrdev()函数注册,调用该函数后就可以向系统申请主设备号,操作成功,设备名就会出现在/proc/devices里。
  • 此外,在关闭设备时,需要先解除原先设备的注册,需要有清除函数,在xxx_exit()中通过unregister_chrdev()函数在实现,此后设备就会从/proc/devices里消失。
  • 当驱动程序被编译成模块时,使用insmod加载模块,模块的初始化函数xxx_init()被调用,向内核注册驱动程序;使用rmmod卸载模块,模块的清除函数xxx_exit()被调用。

构造file_operations结构中要用到的各个成员函数

Linux操作系统将所有的设备都看成文件,以操作文件的方式访问设备。应用程序不能直接操作硬件,使用统一的接口函数调用硬件驱动程序,这组接口被成为系统调用。每个系统调用中都有一个与之对应的函数(open、release、read、write、ioctl等),在字符驱动程序中,这些函数集合在一个file_operations类型的数据结构中。以一个键盘驱动程序为例:

struct file_operations Key7279_fops =  
{
    .open = Key7279_Open,  
    .ioctl = Key7279_Ioctl,  
    .release = Key7279_Close,
    .read = Key7279_Read,
};
  1. 设备的打开和释放
    • 打开设备是由open()函数来完成,在大部分设备驱动中open完成如下工作:
      • 递增计数器
      • 检查特定设备的特殊情况
      • 初始化设备
      • 识别次设备号
    • 释放设备由release()函数来完成。当一个进程释放设备时,其它进程还能继续使用该设备,只是该进程暂时停止对该设备的的使用,而当一个进程关闭设备时,其它进程必须重新打开此设备才能使用。Release完成如下工作:
      • 递减计数
      • 在最后一次释放设备操作时关闭设备
  2. 设备的读写操作
    读写设备的主要任务就是把内核空间的数据复制到用户空间,或者是从用户空间复制到内核空间,也就是将内核空间缓冲区里的数据复制到用户空间的缓冲区中或者相反。字符设备使用各自的read()函数和write()函数来进行数据读写。
  3. 设备的控制操作
    大部分设备除了读写能力,还可进行超出简单的数据传输之外的操作,所以设备驱动也必须具备进行各种硬件控制操作的能力. 这些操作常常通过 ioctl 方法来支持。与读写操作不同,ioctl()的用法与具体设备密切相关。以键盘Key7279_Ioctl为例:
static int Key7279_Ioctl(struct inode *inode,struct file *file,unsigned int cmd, unsigned long arg)
{
    switch(cmd)  
    {
        case Key7279_GETKEY:
            return key7279_getkey();
        default:
        printk("Unkown Keyboard Command ID.\n");
    }
        return 0;
}

cmd的取值及含义都与具体的设备有关,除了ioctl(),设备驱动程序还可能有其他控制函数,比如llseek()等。当应用程序使用open、release等函数打开某个设备时,设备驱动程序的file_operations结构中的相应成员就会被调用。

设备的中断和轮询处理

对于不支持中断的设备,读写时需要轮询设备状态,以及是否需要继续进行数据传输。例如,打印机。如果设备支持中断,则可按照中断方式进行。模块在使用中断前要先请求一个中断通道(或者 IRQ中断请求),并在使用后释放它。通过request_irq()函数来注册中断,free_irq()函数来释放。

驱动程序的测试

对驱动程序的调试可以通过打印的方式来进行,就是通过在驱动程序中添加printk()打印函数,来跟踪驱动程序的执行过程,以此来判断问题。 以上是我根据自己的学习总结的,可能写的比较简单,对于比较复杂的驱动函数,会添加更多的函数,但是大体的框架就是这样了。
基于操作系统的驱动就是在无操作系统下的硬件接口函数加上操作系统的接口

总结

实现一个嵌入式Linux设备驱动程序的大致流程如下:

  • 查看原理图,理解设备的工作原理。
  • 定义主设备号。设备由一个主设备号和一个次设备号来标识。主设备号唯一标识了设备类型,即设备驱动程序类型,它是块设备表或字符设备表中设备表项的索引。次设备号仅由设备驱动程序解释,区分被一个设备驱动控制下的某个独立的设备。
  • 实现初始化函数。在驱动程序中实现驱动的注册和卸载。
  • 设计所要实现的文件操作,定义file–operations结构。
  • 实现所需的文件操作调用,如read,write等。
  • 实现中断服务,并用request–irq向内核注册,中断并不是每个设备驱动所必需的。
  • 编译该驱动程序到内核中,或者用insmod命令加载模块。
  • 测试该设备,编写应用程序,对驱动程序进行测试。

典型字符设备驱动编写框架:

  1. 编写硬件接口函数
  2. 建立文件系统与设备驱动程序间的接口,如:struct file_operations结构体
  3. 注册设备到chrdevfs全局数组中,注册或注销设备可以在任何时候,但一般在模块加载时注册设备,在模块退出时注销设备。module_init()/module_exit()
  4. 以模块方式编译驱动源码,并将其加载到内核中
  5. 创建设备节点,mknode
  6. 编写应用程序访问底层设备

想成为嵌入式程序员应知道的0x10个基本问题

这是嵌入式C程序员的基本知识。作者(Jones Nigel)在Embedded Systems Programming杂志上发表了很多嵌入式系统开发方面的文章。

C语言测试是招聘嵌入式系统程序员过程中必须而且有效的方法。这些年,我既参加也组织了许多这种测试,在这过程中我意识到这些测试能为面试者和被面试者提供许多有用信息,此外,撇开面试的压力不谈,这种测试也是相当有趣的。从被面试者的角度来讲,你能了解许多关于出题者或监考者的情况。这个测试只是出题者为显示其对ANSI标准细节的知识而不是技术技巧而设计吗?这是个愚蠢的问题吗?如要你答出某个字符的ASCII值。这些问题着重考察你的系统调用和内存分配策略方面的能力吗?这标志着出题者也许花时间在微机上而不是在嵌入式系统上。如果上述任何问题的答案是”是”的话,那么我知道我得认真考虑我是否应该去做这份工作。从面试者的角度来讲,一个测试也许能从多方面揭示应试者的素质:最基本的,你能了解应试者C语言的水平。不管怎么样,看一下这人如何回答他不会的问题也是满有趣。应试者是以好的直觉做出明智的选择,还是只是瞎蒙呢?当应试者在某个问题上卡住时是找借口呢,还是表现出对问题的真正的好奇心,把这看成学习的机会呢?我发现这些信息与他们的测试成绩一样有用。有了这些想法,我决定出一些真正针对嵌入式系统的考题,希望这些令人头痛的考题能给正在找工作的人一点帮助。这些问题都是我这些年实际碰到的。其中有些题很难,但它们应该都能给你一点启迪。这个测试适于不同水平的应试者,大多数初级水平的应试者的成绩会很差,经验丰富的程序员应该有很好的成绩。为了让你能自己决定某些问题的偏好,每个问题没有分配分数,如果选择这些考题为你所用,请自行按你的意思分配分数。

预处理器(Preprocessor)

  • 用预处理指令#define 声明一个常数,用以表明1年中有多少秒(忽略闰年问题)
#define SECONDS_PER_YEAR (60 * 60 * 24 * 365)UL

我在这想看到几件事情:

  • #define 语法的基本知识(例如:不能以分号结束,括号的使用,等等)
  • 懂得预处理器将为你计算常数表达式的值,因此,直接写出你是如何计算一年中有多少秒而不是计算出实际的值,是更清晰而没有代价的。
  • 意识到这个表达式将使一个16位机的整型数溢出-因此要用到长整型符号L,告诉编译器这个常数是的长整型数。
  • 如果你在你的表达式中用到UL(表示无符号长整型),那么你有了一个好的起点。记住,第一印象很重要。
  • 写一个”标准”宏MIN ,这个宏输入两个参数并返回较小的一个。
#define MIN(A,B) ((A) <= (B) ? (A) : (B))

这个测试是为下面的目的而设的:

  • 标识#define在宏中应用的基本知识。这是很重要的。因为在 嵌入(inline)操作符 变为标准C的一部分之前,宏是方便产生嵌入代码的唯一方法,对于嵌入式系统来说,为了能达到要求的性能,嵌入代码经常是必须的方法。
  • 三重条件操作符的知识。这个操作符存在C语言中的原因是它使得编译器能产生比if-then-else更优化的代码,了解这个用法是很重要的。
  • 懂得在宏中小心地把参数用括号括起来
  • 我也用这个问题开始讨论宏的副作用,例如:当你写下面的代码时会发生什么事?least = MIN(*p++, b);
  • 预处理器标识#error的目的是什么?
    如果你不知道答案,请看参考文献1。这问题对区分一个正常的伙计和一个书呆子是很有用的。只有书呆子才会读C语言课本的附录去找出像这种问题的答案。当然如果你不是在找一个书呆子,那么应试者最好希望自己不要知道答案。

死循环(Infinite loops)

– 嵌入式系统中经常要用到无限循环,你怎么样用C编写死循环呢?
这个问题用几个解决方案。我首选的方案是:

while(1){
}

一些程序员更喜欢如下方案:

for(;;){
}

这个实现方式让我为难,因为这个语法没有确切表达到底怎么回事。如果一个应试者给出这个作为方案,我将用这个作为一个机会去探究他们这样做的基本原理。如果他们的基本答案是:”我被教着这样做,但从没有想到过为什么。”这会给我留下一个坏印象。
第三个方案是用 goto

Loop:
...
goto Loop;

应试者如给出上面的方案,这说明或者他是一个汇编语言程序员(这也许是好事)或者他是一个想进入新领域的BASIC/FORTRAN程序员。

数据声明(Data declarations)

  • 用变量a给出下面的定义
  • 一个整型数(An integer)
  • 一个指向整型数的指针( A pointer to an integer)
  • 一个指向指针的的指针,它指向的指针是指向一个整型数( A pointer to a pointer to an intege)r
  • 一个有10个整型数的数组( An array of 10 integers)
  • 一个有10个指针的数组,该指针是指向一个整型数的。(An array of 10 pointers to integers)
  • 一个指向有10个整型数数组的指针( A pointer to an array of 10 integers)
  • 一个指向函数的指针,该函数有一个整型参数并返回一个整型数(A pointer to a function that takes an integer as an argument and returns an integer)
  • 一个有10个指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数( An array of ten pointers to functions that take an integer argument and return an integer )

答案是:

  • int a; // An integer
  • int *a; // A pointer to an integer
  • int **a; // A pointer to a pointer to an integer
  • int a[10]; // An array of 10 integers
  • int *a[10]; // An array of 10 pointers to integers
  • int *a; // A pointer to an array of 10 integers
  • int (*a)(int); // A pointer to a function a that takes an integer argument and returns an integer
  • int (*a[10])(int); // An array of 10 pointers to functions that take an integer argument and return an integer

人们经常声称这里有几个问题是那种要翻一下书才能回答的问题,我同意这种说法。当我写这篇文章时,为了确定语法的正确性,我的确查了一下书。但是当我被面试 的时候,我期望被问到这个问题(或者相近的问题)。因为在被面试的这段时间里,我确定我知道这个问题的答案。应试者如果不知道所有的答案(或至少大部分答 案),那么也就没有为这次面试做准备,如果该面试者没有为这次面试做准备,那么他又能为什么出准备呢?

Static

  • 关键字static的作用是什么?
    这个简单的问题很少有人能回答完全。在C语言中,关键字static有三个明显的作用:
    • 在函数体,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
    • 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。
    • 在模块内,一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用。

大多数应试者能正确回答第一部分,一部分能正确回答第二部分,同是很少的人能懂得第三部分。这是一个应试者的严重的缺点,因为他显然不懂得本地化数据和代码范围的好处和重要性。

Const

  • 关键字const有什么含意?
    我 只要一听到被面试者说:”const意味着常数”,我就知道我正在和一个业余者打交道。去年Dan Saks已经在他的文章里完全概括了const的所有用法,因此ESP(译者:Embedded Systems Programming)的每一位读者应该非常熟悉const能做什么和不能做什么.如果你从没有读到那篇文章,只要能说出const意味着”只读”就可 以了。尽管这个答案不是完全的答案,但我接受它作为一个正确的答案。(如果你想知道更详细的答案,仔细读一下Saks的文章吧。)
    如果应试者能正确回答这个问题,我将问他一个附加的问题:
    下面的声明都是什么意思?
const int a;
int const a;
const int *a;
int * const a;
int const * a const;

前两个的作用是一样,a是一个常整型数。第三个意味着a是一个指向常整型数的指针(也就是,整型数是不可修改的,但指针可以)。第四个意思a是一个指向整型 数的常指针(也就是说,指针指向的整型数是可以修改的,但指针是不可修改的)。最后一个意味着a是一个指向常整型数的常指针(也就是说,指针指向的整型数 是不可修改的,同时指针也是不可修改的)。如果应试者能正确回答这些问题,那么他就给我留下了一个好印象。顺带提一句,也许你可能会问,即使不用关键字 const,也还是能很容易写出功能正确的程序,那么我为什么还要如此看重关键字const呢?我也如下的几下理由:

  • 关键字const的作用是为给读你代码的人传达非常有用的信息,实际上,声明一个参数为常量是为了告诉了用户这个参数的应用目的。如果你曾花很多时间清理 其它人留下的垃圾,你就会很快学会感谢这点多余的信息。(当然,懂得用const的程序员很少会留下的垃圾让别人来清理的。)
  • 通过给优化器一些附加的信息,使用关键字const也许能产生更紧凑的代码。
  • 合理地使用关键字const可以使编译器很自然地保护那些不希望被改变的参数,防止其被无意的代码修改。简而言之,这样可以减少bug的出现。

Volatile

  • 关键字volatile有什么含意?并给出三个不同的例子。
    一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。下面是volatile变量的几个例子:
    • 并行设备的硬件寄存器(如:状态寄存器)
    • 一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)
    • 多线程应用中被几个任务共享的变量

回答不出这个问题的人是不会被雇佣的。我认为这是区分C程序员和嵌入式系统程序员的最基本的问题。搞嵌入式的家伙们经常同硬件、中断、RTOS等等打交道,所有这些都要求用到volatile变量。不懂得volatile的内容将会带来灾难。
假设被面试者正确地回答了这是问题(嗯,怀疑是否会是这样),我将稍微深究一下,看一下这家伙是不是直正懂得volatile完全的重要性。

  • 一个参数既可以是const还可以是volatile吗?解释为什么。
  • 一个指针可以是volatile 吗?解释为什么。

下面的函数有什么错误:

int square(volatile int *ptr)
{
    return *ptr * *ptr;
}

下面是答案:

  • 是的。一个例子是只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。
  • 是的。尽管这并不很常见。一个例子是当一个中服务子程序修该一个指向一个buffer的指针时。

这段代码有点变态。这段代码的目的是用来返指针ptr指向值的平方,但是,由于ptr指向一个volatile型参数,编译器将产生类似下面的代码:

int square(volatile int *ptr)
{
    int a,b;
    a = *ptr;
    b = *ptr;
    return a * b;
}

由于*ptr的值可能被意想不到地该变,因此a和b可能是不同的。结果,这段代码可能返不是你所期望的平方值!正确的代码如下:

long square(volatile int *ptr)
{
    int a;
    a = *ptr;
    return a * a;
}

位操作(Bit manipulation)

  • 嵌入式系统总是要用户对变量或寄存器进行位操作。给定一个整型变量a,写两段代码,第一个设置a的bit 3,第二个清除a 的bit 3。在以上两个操作中,要保持其它位不变。
    对这个问题有三种基本的反应:
    • 不知道如何下手。该被面者从没做过任何嵌入式系统的工作。
    • 用bit fields。Bit fields是被扔到C语言死角的东西,它保证你的代码在不同编译器之间是不可移植的,同时也保证了的你的代码是不可重用的。我最近不幸看到 Infineon为其较复杂的通信芯片写的驱动程序,它用到了bit fields因此完全对我无用,因为我的编译器用其它的方式来实现bit fields的。从道德讲:永远不要让一个非嵌入式的家伙粘实际硬件的边。
    • 用 #defines 和 bit masks 操作。这是一个有极高可移植性的方法,是应该被用到的方法。最佳的解决方案如下:
#define BIT3 (0x1 << 3)
static int a;

void set_bit3(void)
{
    a |= BIT3;
}
void clear_bit3(void)
{
    a &= ~BIT3;
}

一些人喜欢为设置和清除值而定义一个掩码同时定义一些说明常数,这也是可以接受的。我希望看到几个要点:说明常数、|=和&=~操作。

访问固定的内存位置(Accessing fixed memory locations)

  • 嵌入式系统经常具有要求程序员去访问某特定的内存位置的特点。在某工程中,要求设置一绝对地址为0x67a9的整型变量的值为0xaa66。编译器是一个纯粹的ANSI编译器。写代码去完成这一任务。
    这一问题测试你是否知道为了访问一绝对地址把一个整型数强制转换(typecast)为一指针是合法的。这一问题的实现方式随着个人风格不同而不同。典型的类似代码如下:
int *ptr;
ptr = (int *)0x67a9;
*ptr = 0xaa55;

一个较晦涩的方法是:

*(int * const)(0x67a9) = 0xaa55;

即使习惯于更接近第二种方案,但我建议你在面试时使用第一种方案。

中断(Interrupts)

  • 中断是嵌入式系统中重要的组成部分,这导致了很多编译开发商提供一种扩展—让标准C支持中断。具代表事实是,产生了一个新的关键字 __interrupt。下面的代码就使用了__interrupt关键字去定义了一个中断服务子程序(ISR),请评论一下这段代码的。
__interrupt double compute_area (double radius)
{
    double area = PI * radius * radius;
    printf("\nArea = %f", area);
    return area;
}

这个函数有太多的错误了,以至让人不知从何说起了:

  • ISR不能返回一个值。如果你不懂这个,那么你不会被雇用的。
  • ISR 不能传递参数。如果你没有看到这一点,你被雇用的机会等同第一项。
  • 在许多的处理器/编译器中,浮点一般都是不可重入的。有些处理器/编译器需要让额处的寄存器入栈,有些处理器/编译器就是不允许在ISR中做浮点运算。此外,ISR应该是短而有效率的,在ISR中做浮点运算是不明智的。
  • 与第三点一脉相承,printf()经常有重入和性能上的问题。如果你丢掉了第三和第四点,我不会太为难你的。不用说,如果你能得到后两点,那么你的被雇用前景越来越光明了。

代码例子(Code examples)

  • 下面的代码输出是什么,为什么?
void foo(void)
{
    unsigned int a = 6;
    int b = -20;
    (a+b > 6) ? puts("> 6") : puts("<= 6");
}

这个问题测试你是否懂得C语言中的整数自动转换原则,我发现有些开发者懂得极少这些东西。不管如何,这无符号整型问题的答案是输出是 “>6”。原因是当表达式中存在有符号类型和无符号类型时所有的操作数都自动转换为无符号类型。 因此-20变成了一个非常大的正整数,所以该表达式计算出的结果大于6。这一点对于应当频繁用到无符号数据类型的嵌入式系统来说是丰常重要的。如果你答错 了这个问题,你也就到了得不到这份工作的边缘。

  • 评价下面的代码片断:
unsigned int zero = 0;
unsigned int compzero = 0xFFFF;
/*1's complement of zero */

对于一个int型不是16位的处理器为说,上面的代码是不正确的。应编写如下:

unsigned int compzero = ~0;

这一问题真正能揭露出应试者是否懂得处理器字长的重要性。在我的经验里,好的嵌入式程序员非常准确地明白硬件的细节和它的局限,然而PC机程序往往把硬件作为一个无法避免的烦恼。到了这个阶段,应试者或者完全垂头丧气了或者信心满满志在必得。如果显然应试者不是很好,那么这个测试就在这里结束了。但如果显然应试者做得不错,那么我就 扔出下面的追加问题,这些问题是比较难的,我想仅仅非常优秀的应试者能做得不错。提出这些问题,我希望更多看到应试者应付问题的方法,而不是答案。不管如 何,你就当是这个娱乐吧…

动态内存分配(Dynamic memory allocation)

  • 尽管不像非嵌入式计算机那么常见,嵌入式系统还是有从堆(heap)中动态分配内存的过程的。那么嵌入式系统中,动态分配内存可能发生的问题是什么?

这里,我期望应试者能提到内存碎片,碎片收集的问题,变量的持行时间等等。这个主题已经在ESP杂志中被广泛地讨论过了(主要是 P.J. Plauger, 他的解释远远超过我这里能提到的任何解释),所有回过头看一下这些杂志吧!让应试者进入一种虚假的安全感觉后,我拿出这么一个小节目:下面的代码片段的输出是什么,为什么?

char *ptr;
if ((ptr = (char *)malloc(0)) == NULL)
    puts("Got a null pointer");
else
    puts("Got a valid pointer");

这是一个有趣的问题。最近在我的一个同事不经意把0值传给了函数malloc,得到了一个合法的指针之后,我才想到这个问题。这就是上面的代码,该代码的输 出是”Got a valid pointer”。我用这个来开始讨论这样的一问题,看看被面试者是否想到库例程这样做是正确。得到正确的答案固然重要,但解决问题的方法和你做决定的基 本原理更重要些。

Typedef

  • Typedef 在C语言中频繁用以声明一个已经存在的数据类型的同义字。也可以用预处理器做类似的事。例如,思考一下下面的例子:
#define dPS struct s *
typedef struct s * tPS;

以上两种情况的意图都是要定义dPS 和 tPS 作为一个指向结构s指针。哪种方法更好呢?(如果有的话)为什么?
这是一个非常微妙的问题,任何人答对这个问题(正当的原因)是应当被恭喜的。答案是:typedef更好。思考下面的例子:

dPS p1,p2;
tPS p3,p4;

第一个扩展为

struct s * p1, p2;

上面的代码定义p1为一个指向结构的指,p2为一个实际的结构,这也许不是你想要的。第二个例子正确地定义了p3 和p4 两个指针。

晦涩的语法

  • C语言同意一些令人震惊的结构,下面的结构是合法的吗,如果是它做些什么?
int a = 5, b = 7, c;
c = a+++b;

这个问题将做为这个测验的一个愉快的结尾。不管你相不相信,上面的例子是完全合乎语法的。问题是编译器如何处理它?水平不高的编译作者实际上会争论这个问题,根据最处理原则,编译器应当能处理尽可能所有合法的用法。因此,上面的代码被处理成:

c = a++ + b;

因此, 这段代码持行后a = 6, b = 7, c = 12。
如果你知道答案,或猜出正确答案,做得好。如果你不知道答案,我也不把这个当作问题。我发现这个问题的最大好处是这是一个关于代码编写风格,代码的可读性,代码的可修改性的好的话题。

参考文献

  1. Jones, Nigel, “In Praise of the #error directive,” Embedded Systems Programming, September 1999, p. 114.
  2. Jones, Nigel, “ Efficient C Code for Eight-bit MCUs ,” Embedded Systems Programming, November 1998, p. 66.

ChatGPT汇总

操作系统相关

系统基础

什么是操作系统?

操作系统是计算机系统中的系统软件,它管理和控制计算机硬件资源,并为用户和应用程序提供服务。它负责处理任务调度、内存管理、文件系统管理、设备驱动程序等核心功能。

操作系统的主要功能有哪些?

操作系统的主要功能包括:
进程管理:创建、调度和终止进程。
内存管理:分配和释放内存,虚拟内存管理。
文件系统管理:文件的创建、读写、删除等操作。
设备驱动程序管理:与硬件设备的通信。
用户接口:提供命令行或图形用户界面等用户交互方式。

什么是多任务操作系统?

多任务操作系统允许同时执行多个任务。它可以通过时间片轮转、优先级调度等机制,使多个任务交替执行,从而提高系统的利用率和响应速度。

进程线程相关

进程和线程的区别

进程是程序的执行实例,有独立的地址空间、文件描述符和系统资源。线程是进程内的执行单元,共享进程的地址空间和资源。
在安全固件开发中,可能更倾向于使用线程,因为它们相对轻量,更适合嵌入式环境。但进程提供更强的隔离。

进程是程序的执行实例,拥有独立的内存空间和系统资源。线程是进程内的执行单元,共享进程的资源。多线程可以在同一进程内并发执行,共享进程的数据和资源。

协程和线程的区别

协程(Coroutine)和线程(Thread)是两种并发编程的概念,它们在实现方式、调度方式和应用场景等方面存在一些关键区别。

  • 执行单位:
    线程: 是操作系统调度的最小执行单位。每个线程都有自己的堆栈和寄存器,由操作系统调度执行。
    协程: 是由程序员控制的执行单位,它在用户层代码中显式地挂起和恢复。协程并不依赖于操作系统的线程,而是在单个线程内部执行。
  • 调度方式:
    线程: 由操作系统进行调度,操作系统决定何时暂停一个线程,何时切换到另一个线程。
    协程: 由程序员在代码中显式地调度。协程可以在需要时主动挂起,等待某个条件满足后再恢复执行。
  • 并发性:
    线程: 线程是并发执行的,多个线程可以同时运行在多个处理器核心上。
    协程: 通常在单个线程内部执行,因此协程在某一时刻只能有一个在执行。但通过协作式调度,可以在一个线程内模拟并发。
  • 开销:
    线程: 操作系统线程的创建和切换会带来一定的开销,包括堆栈分配、寄存器保存等。
    协程: 协程的创建和切换通常比线程轻量级,因为它们不依赖于操作系统的调度。
  • 共享状态:
    线程: 线程之间通常共享相同的进程内存,需要使用锁等机制来同步对共享数据的访问。
    协程: 协程通常在同一个线程内部执行,共享数据不需要额外的同步机制,减少了并发编程中一些常见问题的可能性。
  • 适用场景:
    线程: 适用于需要充分利用多核处理器的密集计算或I/O密集型任务。
    协程: 适用于需要处理大量并发任务、I/O阻塞,或者需要管理大量轻量级任务的场景,同时希望避免线程切换的开销。

总体而言,协程和线程各有优劣,并且在不同的应用场景中可能更为适用。协程通常更适用于高并发、I/O密集型的场景,而线程更适用于需要充分利用多核处理器、CPU密集型的场景。在一些现代编程语言中,如Go、Python(使用asyncio)、Kotlin(使用协程)、JavaScript(使用Promise和async/await),协程得到了广泛的支持和应用。

进程隔离和安全性

进程隔离通过使用虚拟内存和权限机制,确保进程间无法直接访问对方的地址空间。
最小权限原则限制每个进程的权限,确保进程只能访问其需要的资源,提高系统的安全性。

线程切换是如何实现的,有哪些需要考虑的因素?

线程切换是从一个任务(线程)切换到另一个的过程。RTOS通过保存和恢复任务的上下文来实现线程切换。需要考虑的因素包括上下文切换的时间开销、任务的优先级、调度算法等。

资源管理和任务调度

任务调度算法可根据优先级和截止时间确保高优先级任务得到及时响应。
资源管理包括分配和释放资源,确保任务有足够的资源执行。

简要解释Rate Monotonic Scheduling(RMS)和Earliest Deadline First(EDF)调度算法

RMS是一种基于任务周期的调度算法,短周期的任务具有更高的优先级。EDF根据任务的截止时间,为最早截止的任务分配最高的优先级。这两种算法都是实时系统中常见的调度策略。

RTOS中的调度算法有哪些常见的选择,各有何优劣?

常见的调度算法包括先来先服务(FCFS)、轮转调度(Round Robin)、优先级调度、最短作业优先(SJF)等。选择调度算法时需根据系统的实时性需求和任务的特性来进行权衡。

RTOS中的调度器是如何工作的?

调度器负责选择下一个要运行的任务。它基于任务的优先级和调度算法来做出决策。在时钟滴答或其他事件触发时,调度器决定切换到合适的任务,实现任务的调度。

RTOS中的阻塞和非阻塞任务有何区别?

阻塞任务在等待某个事件发生时会暂时挂起,而非阻塞任务会继续执行其他操作,定期检查事件状态。阻塞任务通常更有效地利用系统资源,而非阻塞任务更灵活但可能占用处理器时间。

RTOS中的时间片轮转调度算法是如何实现的?有哪些潜在的问题?

时间片轮转调度将任务分配固定时间片,当时间片用完时,调度器将任务移到就绪队列的末尾。潜在问题包括短任务可能等待时间过长,长任务可能被分割成多个时间片。

RTOS中的分布式任务调度是如何实现的?有何优势和挑战?

分布式任务调度涉及多个处理器上的任务协同工作。优势包括更好的系统扩展性和负载平衡,挑战包括任务分布的复杂性和通信开销。

实时性

实时操作系统(RTOS)

RTOS专注于满足任务在确定时间内完成的需求,通过提供硬实时性能来确保任务的可靠执行。
在安全固件中,使用RTOS可以确保对于关键任务的及时响应,例如安全监控或加密模块。

什么是实时操作系统(RTOS)?

实时操作系统是一种专注于确保任务在预定的时间内完成的操作系统。它强调任务的实时性,即任务在规定的时间范围内完成。

硬实时和软实时有什么区别?

硬实时系统对任务的截止时间有严格的要求,任务必须在指定的时间内完成。软实时系统对任务的截止时间有要求,但允许在一些情况下超过截止时间,只是会导致性能下降。

解释抖动(Jitter)在实时系统中的概念

抖动是指任务或事件的发生时间的不确定性。在实时系统中,抖动是一个重要的度量,因为它可能影响系统对任务响应时间的可靠性。

什么是上下文切换,它对实时性有什么影响?

上下文切换是指从一个任务切换到另一个任务时,操作系统保存和恢复任务的状态。上下文切换会引入延迟,对实时性产生影响,因此需要最小化上下文切换的时间。

什么是上下文切换的开销?如何最小化上下文切换的影响?

上下文切换的开销包括保存和恢复寄存器、任务切换时的清理工作等。为最小化开销,可采用优化的上下文切换实现、合理选择调度算法、限制中断嵌套等手段。

如何评估RTOS的性能?

性能评估可以通过测量任务响应时间、抖动、上下文切换时间以及系统中断的处理时间等指标来完成。实时性能的评估是RTOS选择和优化的关键方面。

什么是实时时钟和定时器?

实时时钟提供当前时间信息,用于任务调度和时间敏感操作。定时器是硬件设备,用于在指定时间后触发中断,可用于实现定时任务。

在RTOS中,如何处理定时任务和周期性任务?有何注意事项?

定时任务可以使用定时器触发,而周期性任务可以使用定时器或时钟滴答。注意事项包括确保任务执行时间不超过其调度间隔,以避免任务积压。

RTOS中的时钟是如何实现的?

RTOS中的时钟通常由硬件定时器或实时时钟(RTC)驱动。时钟的作用是跟踪系统运行时间,提供任务调度和时间敏感操作的基础。

如何在RTOS中处理优先级反转问题?

优先级反转是指低优先级任务持有一个高优先级任务所需的资源,导致高优先级任务无法执行。解决方案包括使用优先级继承或优先级屏蔽等技术,确保高优先级任务能够按时完成。

RTOS中的时钟滴答(Tick)是什么?如何与任务调度相关联?

时钟滴答是指RTOS定期产生的中断,用于驱动任务调度。它定义了任务在调度器中运行的基本时间单位。任务调度通常在时钟滴答中断服务例程中触发。

RTOS中的时钟滴答(Tick)间隔是如何选择的?

时钟滴答间隔的选择需要权衡任务响应时间和系统开销。较短的间隔提供更精细的调度,但可能增加系统开销。较长的间隔减少系统开销,但可能导致对实时性要求不足。

RTOS中如何处理优先级反转问题?

优先级反转是指低优先级任务持有高优先级任务所需的资源,导致高优先级任务无法执行。解决方案包括优先级继承、优先级屏蔽和使用互斥锁等机制来避免或减轻优先级反转的影响。

RTOS中的实时时钟(RTC)是如何与任务调度相关联的?有什么应用场景?

实时时钟通常用于生成定期中断,这被用作任务调度的触发器。实时时钟的应用场景包括定时任务、超时操作等,以确保系统在预定的时间内完成关键任务。

RTOS中的优先级继承是如何工作的?请提供一个具体的场景例子

优先级继承是指低优先级任务获得了高优先级任务所需的资源时,会临时提升低优先级任务的优先级,直到资源被释放。例如,高优先级任务等待一个低优先级任务使用的共享资源,低优先级任务在使用资源时被提升到与高优先级任务相同的优先级。

优先级继承和优先级屏蔽

优先级继承和优先级屏蔽是解决实时系统中优先级反转问题的两种关键机制。

  • 优先级继承(Priority Inheritance):
    优先级继承是一种机制,当低优先级任务持有高优先级任务所需的资源时,系统会临时提升低优先级任务的优先级,以防止高优先级任务被阻塞。
    • 例子: 假设高优先级任务 A 需要访问共享资源,但该资源当前被低优先级任务 B 占用。在优先级继承中,系统将提升任务 B 的优先级,使其等于任务 A 的优先级,直到任务 B 释放资源。这确保了高优先级任务的及时执行。
    • 优势: 避免了优先级反转问题,确保高优先级任务不会因低优先级任务的资源占用而被阻塞。
    • 劣势: 可能引入额外的上下文切换和系统开销,因为低优先级任务的优先级发生改变。
  • 优先级屏蔽(Priority Ceiling):
    优先级屏蔽是一种机制,通过限制任务持有资源的最高优先级,防止其他更高优先级的任务被阻塞。
    • 例子: 假设高优先级任务 A 和 B 都需要访问同一个共享资源。在优先级屏蔽中,系统会为该资源分配一个“优先级天花板”,即资源允许的最高优先级。当任务 A 占用资源时,系统将任务 A 的优先级提升到资源的优先级天花板,防止其他更高优先级的任务被阻塞。
    • 优势: 避免了优先级反转问题,减少了上下文切换和系统开销,因为资源的优先级天花板是静态确定的。
    • 劣势: 需要在设计时考虑资源的优先级天花板,可能需要修改系统的资源管理策略。

在选择优先级继承和优先级屏蔽时,取决于系统的实际需求和性能考虑。这两种机制都旨在确保高优先级任务能够按时执行,避免由于低优先级任务的资源占用而导致的优先级反转问题。

在RTOS中,如何处理任务的超时操作?

超时操作通常通过定时器和时钟滴答来实现。任务等待某个事件时,如果在超时时间内未发生,任务将被唤醒,并执行相应的处理。

RTOS中的系统时钟(System Clock)是如何与外部时钟同步的?有何应用场景?

系统时钟通常通过外部时钟源或RTC同步。应用场景包括对系统时序的要求严格,以确保任务按时执行。

临界资源

死锁预防和解决

使用锁的有序获取规则,避免循环等待。
资源分配时考虑预防死锁,可以采用银行家算法等。
定期检测死锁,通过资源剥夺或进程终止来解决。

通信和同步机制

通信机制如信号量、消息队列用于任务间通信。
同步机制如互斥锁、条件变量用于确保共享资源的一致性。
安全固件需确保通信的机密性和完整性,可能需要使用加密算法和数字签名。

什么是信号量,如何在RTOS中使用它们来进行同步?

信号量是一种用于任务同步的机制,可以确保在共享资源的访问上不会发生冲突。在RTOS中,任务通过获取和释放信号量来同步对共享资源的访问。

什么是RTOS中的忙等待(Busy Waiting)?有何替代方法?

忙等待是指任务在等待某个条件满足时一直处于运行状态,浪费处理器资源。替代方法包括使用信号量、消息队列等机制,使任务在等待时处于阻塞状态,有效利用处理器资源。

如何处理RTOS中的任务优先级反转问题?请提供一个具体的例子

优先级反转问题可以通过优先级继承或优先级屏蔽来解决。例如,高优先级任务等待低优先级任务释放资源时,将提升低优先级任务的优先级,直到高优先级任务完成。

在RTOS中,什么是临界区(Critical Section)?如何实现对临界区的保护?

临界区是一段代码,其中对共享资源的访问需要互斥执行,以避免竞争条件。在RTOS中,可以使用互斥锁(Mutex)或禁用中断等机制来实现对临界区的保护。

什么是RTOS中的互斥锁(Mutex)?它是如何工作的?

互斥锁是一种用于保护共享资源免受并发访问的机制。任务在访问共享资源之前必须获取互斥锁,如果锁已被其他任务持有,则任务会阻塞等待。

RTOS中的信号量和消息队列有何区别?在什么情况下选择使用哪种机制?

信号量用于同步和互斥,而消息队列用于任务之间的通信。信号量表示资源的可用性,而消息队列用于传递消息。选择取决于任务之间的通信需求。

在RTOS中,多核处理器上的任务同步和通信有何挑战?

在RTOS中,多核处理器上的任务同步和通信涉及一些特殊的挑战,主要与多核系统的复杂性和并发性相关。以下是一些常见的挑战:

  • 缓存一致性:
    挑战: 多核处理器通常具有分布式缓存架构,每个核心都有自己的缓存。当一个核心修改共享数据时,它可能在本地缓存中保留修改,而其他核心可能不会立即看到这些变化,导致缓存不一致。
    解决方案: 使用特定于硬件的指令(如在ARM架构中的DMB、DSB指令)来确保缓存一致性,或者采用锁和同步原语来同步核心间的缓存。
  • 原子操作和锁竞争:
    挑战: 多核系统中,多个核心可能同时访问共享资源,导致竞争条件。在没有适当同步机制的情况下,可能出现数据损坏或不确定性的结果。
    解决方案: 使用原子操作、自旋锁、互斥锁等同步机制来确保对共享资源的原子性访问,并减少锁竞争。
  • 任务调度和负载平衡:
    挑战: 在多核系统中,任务的调度和负载平衡变得更加复杂。有效地分配任务到各个核心,并确保负载均衡,是一个需要仔细考虑的问题。
    解决方案: 使用合适的调度算法,考虑任务的优先级、执行时间和任务间的依赖关系,以实现负载平衡。
  • 中断和事件处理:
    挑战: 中断服务例程(ISR)和事件处理在多核系统中可能同时发生,需要确保这些事件的处理不会相互干扰。
    解决方案: 使用适当的同步机制,如信号量、事件标志等,确保在多核系统中同步中断和事件的处理。
  • 共享数据结构的设计:
    挑战: 在多核系统中,共享数据结构的设计需要考虑如何最小化锁竞争,以及如何有效地利用各核心的缓存。
    解决方案: 使用无锁数据结构、分离数据结构或者通过合理的锁设计来减小锁竞争的范围。
  • 实时性和响应时间:
    挑战: 在多核系统中,确保任务的实时性和响应时间可能更为复杂,因为任务可能分布在不同的核心上,且核心之间的负载和响应时间可能不同。
    解决方案: 通过合适的调度策略和任务分配来确保实时性,考虑任务之间的依赖关系和调度的时序问题。

在设计多核RTOS系统时,需要综合考虑这些挑战,并选择合适的同步和通信机制,以确保系统的可靠性、性能和实时性。

RTOS中的自旋锁是什么?有什么应用场景和潜在的问题?

自旋锁是一种不阻塞的锁,任务尝试获取锁时会反复忙等待,直到成功获取。适用于锁的持有时间短的情况,潜在问题包括长时间的忙等待可能浪费处理器资源。

在RTOS中,如何处理任务间通信的同步问题?请提供一个实例

可以使用信号量或消息队列等机制实现任务间通信的同步。例如,一个任务等待另一个任务完成某项工作,可以使用信号量来同步它们的执行。

在RTOS中,互斥锁的实现机制是什么?如何防止死锁?

互斥锁的实现通常涉及原子操作和中断屏蔽等机制。为防止死锁,可以采用超时机制、优先级继承或优先级屏蔽等策略。

RTOS中的事件标志是如何工作的?如何确保事件的原子性?

事件标志是一种通过位掩码表示多个事件状态的机制。确保事件的原子性通常需要使用临界区或原子操作,以防止多个任务同时修改事件标志导致竞争条件。

什么是死锁?如何防止和处理死锁?

死锁是指多个进程或线程因争夺资源而相互等待,导致系统无法继续执行。死锁可以通过资源分配策略、避免环路等方法进行预防。处理死锁的方法包括撤销进程、资源预分配、死锁检测与恢复等。

信号量的工作原理是什么

信号量是一种用于实现进程或线程同步和互斥的同步原语。信号量的工作原理基于对计数器的操作和阻塞/唤醒机制。这里简要介绍一下信号量的工作原理:

  • 信号量的计数器:
    信号量包含一个整数计数器,该计数器用于表示可用的资源数量。计数器的初始值由用户设置,通常代表可用的资源数量。
    • P(Wait)操作:
      当一个进程或线程希望使用一个资源时,它执行P操作(也称为Wait或Down操作)。P操作会检查信号量的计数器。如果计数器大于零,表示有可用的资源,进程/线程可以继续执行。如果计数器等于零,表示没有可用资源,进程/线程将被阻塞,直到有资源可用。
    • V(Signal)操作:
      当一个进程或线程使用完资源后,它执行V操作(也称为Signal或Up操作)。V操作会递增信号量的计数器,表示释放了一个资源。如果有其他进程/线程因为等待资源而被阻塞,其中一个将被唤醒。
  • 阻塞和唤醒机制:
    当一个进程或线程执行P操作时,如果信号量计数器的值小于等于零,表示没有可用的资源,进程/线程将被阻塞。当另一个进程/线程执行V操作时,它可能会唤醒一个或多个被阻塞的进程/线程,使它们继续执行。
  • 互斥和同步:
    信号量可以用于实现互斥和同步。当信号量的计数器被设置为1时,它可以用作二进制信号量,实现互斥访问。当计数器被设置为大于1时,可以用于控制多个进程/线程的并发访问。
  • 避免竞争条件:
    通过信号量,可以避免竞争条件,即多个进程/线程试图同时访问共享资源时可能导致的不确定行为。信号量的计数器充当了一个保护共享资源的计数器,确保只有在资源可用时才能访问。
  • 超过计数器范围的处理:
    有些信号量的实现可能提供处理计数器超过其范围的机制,例如循环计数或错误处理。这有助于避免因计数器溢出而导致的问题。

总体而言,信号量是一种灵活且强大的同步原语,它通过计数器和阻塞/唤醒机制有效地管理资源的访问,避免了并发程序中的竞争条件。

死锁是怎么发生的

死锁是在多线程或多进程系统中发生的一种特殊情况,其中两个或多个进程或线程无法继续执行,因为它们被彼此之间的互斥资源锁定。死锁通常涉及多个步骤,其中每个步骤都依赖于前一个步骤持有的资源,从而形成了一个循环等待的结构。死锁的发生通常需要满足以下四个条件,这被称为死锁的必要条件:

  • 互斥条件(Mutual Exclusion):
    至少有一个资源是不能被共享的,一次只能由一个进程或线程使用。如果一个进程或线程持有资源,其他进程或线程就无法同时持有相同的资源。
  • 请求和保持条件(Hold and Wait):
    进程或线程可以请求额外的资源,但在等待时继续持有当前的资源,不释放已经持有的资源。这就导致了进程之间的循环等待。
  • 非抢占条件(No Preemption):
    资源不能被强制性地从持有它的进程或线程中剥夺,只能由持有它的进程或线程主动释放。
  • 循环等待条件(Circular Wait):
    存在一个进程(线程)的资源请求序列,使得每个进程都在等待下一个进程所持有的资源。
    死锁的典型场景如下:

进程1持有资源A,请求资源B。
进程2持有资源B,请求资源C。
进程3持有资源C,请求资源A。
在这种情况下,每个进程都在等待下一个资源,形成了一个循环等待,从而导致死锁。

为了避免死锁,可以采取以下方法:

  • 破坏互斥条件: 尽可能设计共享资源,而不是互斥使用资源。
  • 破坏请求和保持条件: 要求进程在申请资源时释放已经持有的资源,或者使用一种一次性申请所有资源的策略。
  • 破坏非抢占条件: 允许系统强制性地撤销进程的资源,以满足其他进程的请求。
  • 破坏循环等待条件: 给资源编号,并要求进程按照编号递增的顺序申请资源,从而避免循环等待。

死锁的预防和检测是复杂而重要的主题,在设计系统时需要仔细考虑资源的管理和分配策略。

内存

虚拟内存和物理内存

虚拟内存通过页表映射到物理内存,提供了对每个进程独立的地址空间。物理内存是实际RAM硬件。
在嵌入式系统中,虚拟内存可能受限,物理内存直接映射到硬件,因为嵌入式系统通常资源有限。

什么是虚拟内存?为什么需要虚拟内存?

虚拟内存是一种将硬盘空间作为临时存储的技术,它扩展了计算机的内存容量。操作系统通过将部分程序或数据存储在硬盘上,以释放物理内存,从而提高系统的多任务处理能力。

RTOS中的任务堆栈大小如何确定?有何考虑因素?

任务堆栈大小取决于任务的内部变量和函数调用深度。考虑因素包括任务所需的局部变量和函数调用深度,以及RTOS的堆栈管理机制。

RTOS中的动态内存分配是如何处理的?有何潜在的问题?

动态内存分配通常通过堆管理器实现。潜在问题包括内存碎片和分配/释放的开销。在实时系统中,应谨慎使用动态内存分配,以避免不确定的延迟。

RTOS中的内存保护单元(Memory Protection Unit,MPU)是如何工作的?它如何增强系统的安全性?

MPU是一种硬件机制,用于实现内存区域的访问控制。通过配置MPU,可以限制任务对特定内存区域的访问,从而提高系统的安全性,防止越界访问和非法内存操作。

RTOS中的内存池是如何实现的?在什么情况下使用内存池?

内存池是一块预先分配的内存区域,用于管理任务的动态内存分配。在RTOS中,可以使用内存池来避免动态内存分配的碎片问题,并提高内存分配的效率。内存池适用于对内存需求有较好预测的情况。

RTOS中的垃圾回收是如何处理的?有什么潜在的问题和解决方案?

RTOS中的垃圾回收通常较为有限,因为实时性要求限制了在任务运行时进行复杂的内存管理操作。潜在问题包括停顿时间长、系统开销大等。解决方案可能包括静态内存分配、池化内存分配等。

在RTOS中,虚拟内存是如何管理的?有什么优劣势?

虚拟内存在RTOS中通常受限,因为实时性要求限制了处理器的复杂性。优势包括更好的内存隔离和更高的程序灵活性,劣势则包括增加了内存访问的开销。

描述页面置换算法,常见的有哪些?

页面置换算法用于在虚拟内存中选择被替换的页面。常见的算法包括:

先进先出(FIFO):选择最早进入内存的页面进行置换

最佳页面置换(OPT):选择未来最长时间不被访问的页面进行置换。
最近最少使用(LRU):选择最近最少被访问的页面进行置换。
时钟算法:使用类似时钟的数据结构进行页面置换。

free两次会咋样

在C语言中,对于动态分配的内存,使用 free 函数来释放内存是很重要的,但是对同一块内存使用两次 free 是错误的,可能导致程序运行时的不确定行为。当尝试释放已经被释放的内存时,通常会导致以下问题:

  • Double Free错误: 对同一块内存使用两次 free 会触发双重释放(Double Free)错误。这可能导致程序崩溃、数据损坏或其他不稳定行为。
  • 内存破坏: 双重释放可能导致内存管理系统的数据结构被破坏,这可能会对程序的内存管理功能产生不利影响。
  • 安全问题: 双重释放可能被恶意利用,成为安全漏洞的一部分。攻击者可能会通过精心设计的内存分配和释放序列来触发漏洞,从而实施恶意行为,例如执行代码或绕过安全措施。
    为了防止双重释放错误,通常遵循以下最佳实践:

释放后置空指针: 释放内存后,将指针设置为 NULL,以避免在之后无意中再次释放相同的内存块。

free(ptr);
ptr = NULL;

避免重复释放: 在代码中确保一个内存块只被释放一次。跟踪内存的分配和释放,并确保在释放之后不再使用指向已释放内存的指针。
使用动态分析工具: 使用内存检测工具(如Valgrind)等动态分析工具,能够检测并报告双重释放等内存错误。
如果发生了双重释放,程序可能会出现未定义的行为,包括崩溃或产生难以追踪的错误。因此,确保在程序中正确管理动态分配的内存,避免双重释放是良好的编码实践。

堆的数据结构是怎样的

在计算机科学中,堆通常是指堆内存,它是一种动态分配内存的数据结构。不要与堆排序中的堆混淆,它们是不同的概念。

堆内存的数据结构是一块连续的、动态分配的内存空间,由操作系统管理。堆内存的特点是动态分配和释放,程序员可以在运行时根据需要动态地分配或释放内存。以下是一些关于堆内存的基本特性:

  • 动态分配: 堆内存的大小和生命周期不是在编译时确定的,而是在运行时动态分配。程序可以使用像 malloc、calloc、realloc 等函数来请求堆内存。
  • 不连续: 堆内存是不连续的,它不要求存储单元在内存中是连续的。这使得堆能够更灵活地分配和释放内存。
  • 手动管理: 在大多数情况下,程序员需要手动管理堆内存的分配和释放。这意味着程序员负责在适当的时候请求内存,以及在使用完后释放内存,以避免内存泄漏。
  • 动态增长和收缩: 堆内存的大小可以动态增长或收缩。当程序需要更多内存时,可以通过动态分配获得更多内存;当不再需要内存时,可以通过释放内存来减少占用。
  • 无序: 堆内存的分配和释放顺序不受限制,因此内存块可以在堆中以任意顺序分配和释放。
    在堆内存的实际实现中,操作系统通常使用一些数据结构来管理可用和已分配的内存块,以便高效地响应程序的内存请求。这些数据结构可能包括链表、二叉树等。一种常见的堆内存管理策略是使用空闲链表(Free List),其中记录了可用的内存块。

需要注意的是,堆内存的正确使用和管理对程序的稳定性和性能至关重要。不正确的内存管理可能导致内存泄漏、段错误等严重问题。在现代编程语言中,一些语言提供了自动内存管理机制,如垃圾回收,以减轻程序员对堆内存管理的负担。

嵌入式系统中怎么避免内存越界

在嵌入式系统中,避免内存越界(Memory Overflow)是至关重要的,因为内存越界可能导致程序的不稳定性、安全性问题和系统崩溃。以下是一些避免内存越界的常见做法:

  • 使用静态分析工具:
    使用静态分析工具,如静态代码分析器和编译器选项,来检测潜在的内存越界问题。这些工具可以在编译时或构建过程中检查代码,提前发现可能的内存越界情况。
  • 边界检查:
    在代码中实施边界检查,确保对数组和缓冲区的访问都在有效的范围内进行。使用诸如memcpy、strcpy等涉及内存操作的函数时,确保目标缓冲区足够大以容纳源数据。
  • 使用安全的库函数:
    使用标准C库中的安全函数,如strncpy、snprintf、memcpy_s等,这些函数具有边界检查和安全性增强功能,有助于避免缓冲区溢出问题。
  • 动态内存分配和释放的规范:
    在使用动态内存分配时,确保正确计算分配和释放的内存大小。使用malloc、free等函数时,注意释放之前确保不会引起内存越界。
  • 堆栈溢出的预防:
    对于嵌入式系统,堆栈空间可能相对有限。定期检查和评估函数调用的深度,以确保不会发生堆栈溢出。合理规划和设置任务的堆栈大小是避免堆栈溢出的关键。
  • 使用内存保护机制:
    一些嵌入式系统支持硬件或软件的内存保护机制,如内存保护单元(Memory Protection Unit,MPU)或内存管理单元(Memory Management Unit,MMU)。通过配置这些机制,可以防止代码访问无效的内存区域。
  • 代码审查:
    进行定期的代码审查,特别关注涉及内存操作的部分。通过团队内部的审查和合作,可以提高发现和修复潜在内存越界问题的机会。
  • 静态分配:
    在可能的情况下,使用静态分配而不是动态分配内存。静态分配在编译时确定大小,可以更容易地进行边界检查。

通过综合使用这些方法,可以最大程度地减少内存越界问题在嵌入式系统中的发生。在嵌入式环境中,对资源的有效管理和对代码的仔细审查是确保系统稳定性和安全性的关键。

文件系统

文件系统基本原理

文件系统管理文件的存储和组织,通过文件描述符提供对文件的访问。
安全固件可能需考虑文件系统加密、访问控制等安全性问题,确保敏感数据的保护。

什么是文件系统?它的作用是什么?

文件系统是一种用于组织和存储文件的数据结构和管理机制。它的作用包括提供文件的命名和存储、实现对文件的访问控制、支持文件的组织和检索,以及提供文件的共享和保护等功能。

中断

中断处理获得信号量会有什么后果

在一般情况下,中断处理过程中尝试获得信号量可能导致一些问题,因为信号量通常是由多线程或多任务环境中的代码使用的同步机制。在中断处理过程中,通常不应该直接使用可能导致阻塞的同步机制,因为中断服务例程(ISR)需要迅速执行完成,以尽早响应中断请求。

以下是在中断处理中尝试获得信号量可能引发的一些问题:

  • 阻塞和延迟:
    由于中断服务例程应该尽可能快速地执行完毕,如果尝试获得信号量而该信号量当前被其他任务或线程持有,中断服务例程可能会被阻塞,导致中断延迟。
  • 优先级反转:
    如果中断服务例程尝试获得一个由低优先级的任务持有的信号量,而这个任务正在执行,可能导致优先级反转的问题。优先级反转是指低优先级任务持有资源,但由于高优先级中断服务例程请求
中断处理的设计和优化

中断服务例程应最小化执行时间,以防止中断间隔过长。
使用中断优先级和中断屏蔽确保对实时任务的快速响应,避免中断嵌套引起的延迟。

RTOS中的中断服务例程(ISR)有什么特殊之处?

ISR是在响应硬件中断时执行的代码。在RTOS中,ISR需要迅速执行以确保对实时任务的快速响应,通常会限制在ISR中执行的操作,将更多的处理移到低优先级的任务中。

什么是中断服务例程(ISR)?RTOS中如何处理中断?

ISR是在响应硬件中断时执行的代码。RTOS通过中断向量表确定中断类型,执行相应的ISR。ISR通常会完成与中断相关的最小化操作,然后通过信号量、消息队列等机制通知RTOS的其他组件。

异常处理

异常是指执行期间的非正常事件,如除零或访问无效内存。
异常处理通过异常向量表和处理程序来管理,确保系统在面临异常时可以安全地处理,例如记录错误并恢复正常执行。

中断处理和任务调度如何在RTOS中协同工作?

中断处理和任务调度需要协同工作以确保实时性。中断通常在时钟滴答中触发,触发中断时中断服务例程通知调度器,调度器根据优先级和调度算法选择下一个任务执行。中断服务例程需要尽快执行以保证对实时任务的及时响应。

RTOS中的轮询和中断驱动任务有何不同?

轮询任务是在循环中不断检查某个条件是否满足,而中断驱动任务是在事件发生时通过中断机制触发。轮询可能导致系统资源浪费,而中断驱动任务更有效地利用系统资源。

在RTOS中,异常处理机制是如何确保系统的可靠性和安全性的?

异常处理通过异常向量表和处理程序来管理,确保系统在面临异常时可以安全地处理。它提供了对系统错误的快速响应和修复。

在RTOS中,如何处理异步事件和中断的优先级关系?

异步事件和中断可能涉及不同的优先级,需要谨慎处理它们的关系。一般来说,中断的优先级较高,但需要确保异步事件的处理不受中断的阻碍。可通过适当的中断屏蔽和优先级设置来解决。

RTOS中的信号处理机制是如何实现的?有何应用场景和潜在问题?

信号处理机制通常通过中断实现。应用场景包括处理外部事件、异常和系统错误。潜在问题包括信号处理的优先级冲突和竞争条件。

RTOS中的自适应调度算法是如何工作的?有何优劣势?

自适应调度算法根据系统负载和任务特性调整任务的优先级或调度策略。优势包括更好的适应性和系统吞吐量,劣势可能包括实现的复杂性和调试的难度。

RTOS中的任务状态机是什么?如何设计和使用任务状态机来管理任务行为?

任务状态机是一种描述任务状态和状态转换的模型。设计和使用任务状态机可以使任务的行为更清晰,任务的状态转换更可控,有助于实现复杂的任务逻辑。

RT-Linux

描述进程调度的算法,常见的有哪些?

进程调度算法包括:

  • 先来先服务(FCFS):按照进程到达的顺序进行调度。
  • 最短作业优先(SJF):选择剩余执行时间最短的进程。
  • 优先级调度:根据进程的优先级进行调度。
  • 时间片轮转:按照时间片大小轮流调度进程。
  • 多级反馈队列:将进程按照优先级划分成多个队列,每个队列有不同的时间片大小。
什么是系统调用?举例说明系统调用的作用

系统调用是操作系统提供给应用程序的编程接口,允许应用程序请求操作系统提供的服务。例如,文件I/O、进程创建、内存分配等都可以通过系统调用来完成。在Linux中,open()、fork()、malloc()等都是系统调用的例子。

RT-LINUX中断线程化是怎么线程化的

RTLinux是一个基于Linux内核的实时操作系统,它通过在Linux内核中插入一个实时调度器来提供实时性能。RTLinux的中断线程化是通过将实时中断处理函数(Real-Time Interrupt Service Routine,RT-ISR)转化为一个实时线程(RT-Thread)来实现的。这样可以在实时线程上执行中断处理函数,提供更可预测和实时的中断响应。

以下是RTLinux中断线程化的基本过程:

  • 中断注册:
    在RTLinux中,首先需要将中断与相应的实时中断处理函数(RT-ISR)关联。这通常是通过调用rt_request_irq函数完成的。该函数会注册中断处理函数和相关的中断服务例程。
  • 实时中断处理函数(RT-ISR):
    RTLinux的实时中断处理函数(RT-ISR)是一个用户定义的C函数,它负责实际的中断处理。这个函数被设计为在实时线程上运行。
  • 实时线程创建:
    在注册中断时,RTLinux会为每个中断创建一个相应的实时线程。实时线程与实时中断处理函数关联,成为中断处理的执行上下文。这个线程在RTLinux实时调度器中被调度,确保实时性能。
  • 中断处理过程:
    当中断发生时,实时中断处理函数(RT-ISR)被触发。RTLinux的实时调度器会选择相应的实时线程来执行该函数。这个线程在实时环境中运行,优先级高于普通Linux内核线程,以确保及时和可预测的中断响应。
  • 实时线程执行中断处理函数:
    实时线程在执行过程中调用实时中断处理函数,完成中断处理的实际工作。因为在实时线程上执行,中断处理函数的执行是具有实时性的。
  • 中断服务例程完成:
    一旦中断处理函数完成,实时线程将退出,等待下一次中断的触发。这种线程的创建和销毁的开销相对较小,因为实时线程是为中断服务而生的,而不是为了运行一个完整的任务。

通过这种方式,RTLinux实现了将中断处理函数线程化,从而提供了更加可控和实时的中断响应。这种机制使得在RTLinux环境中能够更加灵活地处理实时要求较高的应用场景。

和一般linux的线程化中断有什么区别

在一般的Linux内核中,中断处理是在中断上下文(Interrupt Context)中完成的,而不是通过线程。中断上下文是内核中用于响应硬件中断的一种执行环境。在传统的Linux内核中,中断处理函数是在中断上下文中直接执行的,而没有创建新的线程。

以下是中断上下文和线程化中断的一些关键区别:

  • 执行环境:
    传统中断上下文: 中断处理函数直接在中断上下文中执行,没有明确的用户线程与之关联。
    线程化中断: 中断处理函数被关联到一个实时线程上,在该线程的上下文中执行。
  • 可预测性和实时性:
    传统中断上下文: 中断上下文中的执行是异步的,难以预测。此外,中断处理函数的执行可能被其他中断打断,使得实时性难以保证。
    线程化中断: 通过将中断处理函数线程化,可以更好地控制中断处理的实时性。实时线程有自己的调度优先级,可以更可靠地满足实时性需求。
  • 上下文切换开销:
    传统中断上下文: 中断上下文的执行通常在硬中断(IRQ)上进行,切换到软中断(SoftIRQ)时可能引入额外的上下文切换开销。
    线程化中断: 实时线程在中断服务例程完成后退出,减少了不必要的上下文切换。
  • 任务关联性:
    传统中断上下文: 中断处理函数通常是为了服务特定的硬件设备,与用户任务关联性较弱。
    线程化中断: 实时线程的创建和执行是为了服务特定的中断,与用户任务之间可能存在更强的关联性。
  • 可编程性:
    传统中断上下文: 中断处理函数通常以直接硬件访问和内核调用的方式实现,对用户空间编程的灵活性较低。
    线程化中断: 通过线程化中断,可以更方便地将中断处理函数的实现与用户空间代码结合,提高了可编程性。

总体而言,线程化中断是在实时系统和对实时性要求较高的应用场景中更为常见的做法,它提供了更可控和可预测的中断处理机制。传统中断上下文仍然适用于一般用途的Linux系统,但在线程化中断中,特别是在实时嵌入式系统中,可以更好地满足实时性和可编程性的需求。

Linux怎么track异常的

在Linux中,异常(Exception)是指计算机执行期间发生的一些特殊情况,例如除以零、非法指令等。Linux内核通过中断、陷阱和故障来处理这些异常。异常的跟踪(tracking)通常包括记录异常发生的信息、生成日志以供分析、或采取适当的措施来处理异常。以下是在Linux中跟踪异常的一些方式:

  • 内核日志(Kernel Logging):
    Linux内核使用printk函数将消息输出到内核日志缓冲区,可以使用dmesg命令查看这些消息。异常处理中可以通过printk记录相关信息,例如异常类型、发生地址等。这对于调试和分析异常非常有用。
printk(KERN_ERR "Division by zero at address 0x%p\n", instruction_pointer());
  • 核心转储(Core Dump):
    Linux内核支持生成核心转储,即将当前内存状态和进程映像保存到文件中。可以使用工具如gcore、ulimit和core_pattern配置生成核心转储。在异常发生时,可以通过分析核心转储文件来获取异常信息。
  • Kprobes和Ftrace:
    Kprobes是一种内核动态追踪工具,允许在运行时插入探针。通过在异常处理代码中插入Kprobes探针,可以跟踪异常的发生和处理过程。
    Ftrace是Linux内核的跟踪工具,可用于追踪内核函数的调用和事件。通过配置Ftrace,可以监控异常处理函数的调用情况。
  • SystemTap和eBPF:
    SystemTap是一个用于动态系统跟踪的工具,它允许用户编写脚本来捕获异常和其他事件。eBPF(extended Berkeley Packet Filter)是一种在内核中执行用户定义代码的机制,也可以用于跟踪异常和生成相关信息。
  • 检查内核日志和系统日志:
    使用工具如dmesg和/var/log/syslog可以查看内核日志和系统日志,其中包含了异常信息和相关的警告或错误。
    G- DB调试器:
    GDB是一个强大的调试器,可以用于调试内核和用户空间程序。通过在异常处理函数中设置断点,可以使用GDB捕获异常时的状态,并进行进一步的调试。
  • panic()函数:
    在内核中,panic函数用于触发系统崩溃。在异常处理中,如果发现严重错误,可以调用panic函数,触发系统崩溃,同时生成用于分析的内核转储。

需要注意的是,异常的跟踪通常需要谨慎处理,因为在异常处理过程中引入过多的调试和记录操作可能导致不可预测的行为。在生产环境中,应该采用合适的方式进行异常信息的记录和跟踪。

ftrace的原理

Ftrace是Linux内核中的跟踪工具,用于分析内核中的函数调用和事件。它允许开发者深入了解内核的执行流程和性能状况。以下是Ftrace的基本原理:

  • 函数跟踪:
    Ftrace的主要功能之一是跟踪内核中的函数调用。它通过在内核中的每个函数的入口和出口插入无害的NOP指令(No Operation),形成一个称为ftrace_ops的数据结构链表,其中包含了需要跟踪的函数。当函数执行时,Ftrace通过这些NOP指令记录函数的调用和返回信息。
  • 事件跟踪:
    除了函数调用跟踪,Ftrace还支持跟踪内核中的各种事件,例如中断、系统调用、调度事件等。通过在内核中插入tracepoint,Ftrace能够捕获和记录这些事件的发生。
  • tracefs文件系统:
    Ftrace将跟踪信息暴露给用户空间通过tracefs文件系统。tracefs提供了一组文件和目录,用户可以读取和写入这些文件以控制跟踪的配置和获取跟踪数据。
  • 可插拔的跟踪功能:
    Ftrace支持一系列的跟踪功能,例如函数图谱(Function Graph)、事件追踪、CPU间事件跟踪等。这些功能是可插拔的,用户可以根据需要选择性地启用它们。
  • Ring Buffer:
    Ftrace使用一个环形缓冲区(Ring Buffer)来存储跟踪数据。Ring Buffer具有固定大小,当缓冲区被填满时,新的数据将覆盖旧的数据。这有助于限制跟踪数据的大小,以避免占用过多内存。
  • 用户空间工具:
    Ftrace提供了一组用户空间工具,如trace-cmd,用于配置和控制Ftrace。这些工具可以通过tracefs文件系统访问跟踪配置,读取跟踪数据,并以可读的形式呈现给用户。
  • 动态追踪:
    Ftrace支持动态追踪,允许用户在运行时动态地启用或禁用跟踪功能,改变跟踪的配置,以适应不同的分析需求。

总体而言,Ftrace的原理在于通过插入无害的NOP指令进行函数调用和事件的跟踪,将跟踪数据存储在环形缓冲区中,然后通过tracefs文件系统将数据暴露给用户空间进行分析和调试。这种可插拔的、灵活配置的设计使得Ftrace成为Linux内核性能分析和调试的有力工具。

它通过在内核中的每个函数的入口和出口插入无害的NOP指令(No Operation),这是怎么做到的

Ftrace通过在内核代码中的每个函数的入口和出口处插入无害的NOP指令的方式来实现函数跟踪。这是通过以下步骤来完成的:

  • Instrumentation:
    Ftrace使用一种称为”Function Graph”的功能,它利用GCC编译器的功能,通过插入mcount调用和NOP指令来实现对函数的跟踪。这是通过GCC的-pg编译选项来实现的,该选项会在每个函数的入口和出口处插入mcount调用。
  • mcount调用:
    mcount是一个简单的计数器,它记录函数的调用和返回次数。在每个函数的入口处,mcount被插入,记录函数的调用。在函数的出口处,同样插入mcount,记录函数的返回。
  • NOP指令:
    在mcount调用之后,为了保持原始的函数执行流程,Ftrace在函数的入口和出口处插入NOP指令。NOP指令是一种不执行任何操作的空指令,对程序的实际功能没有影响,只占据一个CPU周期的执行时间。
  • 函数图谱:
    当函数图谱功能启用时,Ftrace将记录函数调用和返回的信息,包括函数的入口地址、调用者的地址、返回地址等。这些信息被存储在内核的Ring Buffer中,供用户分析。

通过这种方式,Ftrace能够在不影响原始函数执行的情况下,实现对函数调用和返回的跟踪。这种机制允许在运行时启用或禁用函数跟踪,而不需要重新编译内核或应用程序。同时,因为插入的NOP指令对程序功能没有实际影响,所以这样的跟踪方式被认为是“无害”的。

mailbox的实现原理是什么?

Mailbox(邮箱)是一种进程间通信(IPC)机制,用于在不同执行上下文之间传递消息或数据。Mailbox的实现原理可以因操作系统、编程语言或通信库的不同而有所变化,以下是一种通用的Mailbox实现原理:

  • 数据结构:
    Mailbox通常基于特定的数据结构实现。这个数据结构用于存储消息队列,保持消息的顺序和管理消息的接收和发送。消息队列可以是一个简单的缓冲区或一个更复杂的数据结构,具体实现取决于系统设计。
  • 发送和接收操作:
    发送者通过将消息写入Mailbox来发送消息,接收者则通过从Mailbox中读取消息来接收消息。这两个操作是原子的,以防止竞态条件和数据不一致。
  • 互斥和同步:
    Mailbox的实现通常会使用互斥锁或其他同步机制,以确保在多个发送者或接收者之间正确地同步消息的访问。这是为了避免竞争条件和确保消息的完整性。
  • 阻塞和非阻塞:
    Mailbox的实现可以支持阻塞和非阻塞的操作。在阻塞模式下,如果Mailbox为空(对于接收者)或已满(对于发送者),操作将阻塞,直到有足够的空间或消息可用。在非阻塞模式下,如果无法执行发送或接收操作,系统将立即返回,而不会等待。
  • 优先级和调度:
    一些Mailbox的实现支持消息的优先级和调度。这意味着可以为每个消息分配优先级,并确保高优先级的消息优先处理。
  • 通知机制:
    Mailbox的实现通常包含一种通知机制,以便接收者能够及时知道新消息的到达。这可以通过信号、事件或其他机制来实现。
  • 清空和销毁:
    Mailbox的实现可能提供清空Mailbox的操作,以移除其中的所有消息。此外,也可能提供销毁Mailbox的操作,释放相关资源。
  • 持续性:
    有些Mailbox实现是持久的,即使接收者或发送者退出,Mailbox中的消息仍然保持存在。另一些实现可能是短暂的,当接收者或发送者退出时,Mailbox中的消息被销毁。

总体而言,Mailbox的实现原理基于特定的需求和上下文,可以在不同的系统和编程环境中采用不同的设计。这使得Mailbox成为一种灵活且可适应多种场景的通信机制。

不同位的处理器怎么知道位宽, long和int在不同处理器下长度

不同位宽的处理器,如32位处理器和64位处理器,有不同的数据宽度。在C和C++等语言中,数据类型的大小是由编译器和目标体系结构共同决定的。因此,同一个数据类型在不同位宽的处理器上可能具有不同的大小。

在C和C++中,使用 sizeof 运算符可以获取特定数据类型在当前编译环境中的大小。例如:

# include <stdio.h>

int main() {
    printf("Size of int: %zu bytes\n", sizeof(int));
    printf("Size of long: %zu bytes\n", sizeof(long));
    return 0;
}

这段代码会打印 int 和 long 类型在当前环境下的字节数。在不同的编译器和处理器下,这些大小可能会有所不同。

具体到不同位宽的处理器:

  • 32位处理器: int 通常是4字节,而 long 通常也是4字节。
  • 64位处理器: int 通常是4字节,而 long 通常是8字节。

在64位处理器上,由于需要处理更大的内存空间,因此通常会采用更长的数据类型来表示地址和某些数据。这导致了 long 在64位处理器上变成了8字节。

需要注意的是,上述规则并非绝对,具体的大小可能会受到编译器、操作系统和编译选项等因素的影响。因此,使用 sizeof 是一种可靠的方法来了解当前环境下数据类型的大小。

函数声明未定义报错发生在哪个阶段

在C和C++中,函数声明未定义的错误通常发生在链接阶段。让我们详细解释一下整个过程:

  • 预处理阶段(Preprocessing):
    在这个阶段,预处理器处理源代码,执行诸如 #include、#define 等指令。预处理器会将头文件的内容插入到源文件中,展开宏等。但在这个阶段,并没有进行函数的实际编译或链接。
  • 编译阶段(Compilation):
    编译器将预处理后的源代码翻译成汇编代码或机器代码。在这个阶段,编译器只关心当前编译单元(源文件)的内容,而不关心其他文件中的函数实现。
  • 链接阶段(Linking):
    在链接阶段,编译器将各个编译单元产生的目标文件链接在一起,生成最终的可执行文件。在这个阶段,编译器需要确定所有函数的实际地址,并建立函数之间的引用关系。
  • 函数声明未定义错误:
    如果在链接阶段,编译器找不到某个函数的实际定义(实现),但却在某个地方有对该函数的声明,就会导致函数声明未定义的错误。这通常是由于遗漏了某个源文件中的函数实现或者未链接相应的目标文件造成的。
    在C和C++中,函数声明的声明和定义是两个不同的概念。声明只是告诉编译器函数的原型,而不提供实际的实现。定义提供了实际的实现。如果在某个地方对某个函数进行了声明,但是在链接阶段找不到对应的定义,就会导致函数未定义的错误。

为了解决这个问题,确保在所有引用某个函数的地方都有对该函数的正确声明,并且确保链接阶段可以找到该函数的实际定义。

mailbox的原理是什么

Mailbox(邮箱)是一种进程间通信(IPC)机制,用于在不同执行上下文之间传递消息或数据。Mailbox的原理可以因操作系统、编程语言或通信库的不同而有所变化,以下是一种通用的Mailbox原理:

  • 数据结构:
    Mailbox通常基于特定的数据结构实现。这个数据结构用于存储消息队列,保持消息的顺序和管理消息的接收和发送。消息队列可以是一个简单的缓冲区或一个更复杂的数据结构,具体实现取决于系统设计。
  • 发送和接收操作:
    发送者通过将消息写入Mailbox来发送消息,接收者则通过从Mailbox中读取消息来接收消息。这两个操作是原子的,以防止竞态条件和数据不一致。
  • 互斥和同步:
    Mailbox的实现通常会使用互斥锁或其他同步机制,以确保在多个发送者或接收者之间正确地同步消息的访问。这是为了避免竞争条件和确保消息的完整性。
  • 阻塞和非阻塞:
    Mailbox的实现可以支持阻塞和非阻塞的操作。在阻塞模式下,如果Mailbox为空(对于接收者)或已满(对于发送者),操作将阻塞,直到有足够的空间或消息可用。在非阻塞模式下,如果无法执行发送或接收操作,系统将立即返回,而不会等待。
  • 优先级和调度:
    一些Mailbox的实现支持消息的优先级和调度。这意味着可以为每个消息分配优先级,并确保高优先级的消息优先处理。
  • 通知机制:
    Mailbox的实现通常包含一种通知机制,以便接收者能够及时知道新消息的到达。这可以通过信号、事件或其他机制来实现。
  • 清空和销毁:
    Mailbox的实现可能提供清空Mailbox的操作,以移除其中的所有消息。此外,也可能提供销毁Mailbox的操作,释放相关资源。
  • 持续性:
    有些Mailbox实现是持久的,即使接收者或发送者退出,Mailbox中的消息仍然保持存在。另一些实现可能是短暂的,当接收者或发送者退出时,Mailbox中的消息被销毁。

Mailbox通过上述机制提供了一种有效且可靠的方式,让不同执行上下文之间进行通信。这种通信方式通常是线程安全的,能够支持并发操作,使得不同部分的程序能够协调工作。

用C语言模拟一段mailbox的代码实现

在C语言中,模拟一个简单的Mailbox可以使用互斥锁(mutex)和条件变量(condition variable)来实现。下面是一个简单的例子,演示了一个具有基本功能的Mailbox,包括消息的发送和接收。这里使用了线程和标准库的线程相关函数。

# include <stdio.h>
# include <stdlib.h>
# include <pthread.h>
# define MAX_MESSAGES 10

typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;
    int buffer[MAX_MESSAGES];
    int in;  // index to write
    int out; // index to read
} Mailbox;

void init_mailbox(Mailbox *mb) {
    pthread_mutex_init(&mb->mutex, NULL);
    pthread_cond_init(&mb->not_empty, NULL);
    pthread_cond_init(&mb->not_full, NULL);
    mb->in = 0;
    mb->out = 0;
}

void send_message(Mailbox *mb, int message) {
    pthread_mutex_lock(&mb->mutex);

    while ((mb->in + 1) % MAX_MESSAGES == mb->out) {
        // Mailbox is full, wait for it to not be full
        pthread_cond_wait(&mb->not_full, &mb->mutex);
    }

    mb->buffer[mb->in] = message;
    mb->in = (mb->in + 1) % MAX_MESSAGES;

    // Signal that the mailbox is not empty
    pthread_cond_signal(&mb->not_empty);

    pthread_mutex_unlock(&mb->mutex);
}

int receive_message(Mailbox *mb) {
    pthread_mutex_lock(&mb->mutex);

    while (mb->in == mb->out) {
        // Mailbox is empty, wait for it to not be empty
        pthread_cond_wait(&mb->not_empty, &mb->mutex);
    }

    int message = mb->buffer[mb->out];
    mb->out = (mb->out + 1) % MAX_MESSAGES;

    // Signal that the mailbox is not full
    pthread_cond_signal(&mb->not_full);

    pthread_mutex_unlock(&mb->mutex);

    return message;
}

void destroy_mailbox(Mailbox *mb) {
    pthread_mutex_destroy(&mb->mutex);
    pthread_cond_destroy(&mb->not_empty);
    pthread_cond_destroy(&mb->not_full);
}

// Example usage:
int main() {
    Mailbox mailbox;
    init_mailbox(&mailbox);

    // Sender thread
    pthread_t sender_thread;
    pthread_create(&sender_thread, NULL, (void *(*)(void *))send_messages, &mailbox);

    // Receiver thread
    pthread_t receiver_thread;
    pthread_create(&receiver_thread, NULL, (void *(*)(void *))receive_messages, &mailbox);

    // Wait for threads to finish
    pthread_join(sender_thread, NULL);
    pthread_join(receiver_thread, NULL);

    destroy_mailbox(&mailbox);

    return 0;
}

请注意,这只是一个简单的示例,实际生产环境中可能需要更多的功能和错误处理。此代码使用互斥锁和条件变量确保对Mailbox的并发访问是线程安全的。send_messages 和 receive_messages 函数是线程函数的示例,你可以在其中编写模拟实际应用场景的代码。

双核通信的mailbox是怎么实现的

在双核(或多核)系统中,双核通信的 Mailbox 实现通常需要考虑两个核心之间的同步和互斥问题。以下是一个简单的双核通信 Mailbox 的实现示例,假设存在两个核心,分别为核心 A 和核心 B:

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

# define MAX_MESSAGES 10

typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;
    int buffer[MAX_MESSAGES];
    int in_A;  // index to write for Core A
    int out_A; // index to read for Core A
    int in_B;  // index to write for Core B
    int out_B; // index to read for Core B
} DualCoreMailbox;

void init_dual_core_mailbox(DualCoreMailbox *mb) {
    pthread_mutex_init(&mb->mutex, NULL);
    pthread_cond_init(&mb->not_empty, NULL);
    pthread_cond_init(&mb->not_full, NULL);
    mb->in_A = 0;
    mb->out_A = 0;
    mb->in_B = 0;
    mb->out_B = 0;
}

void send_message(DualCoreMailbox *mb, int message, int core_id) {
    pthread_mutex_lock(&mb->mutex);

    // Determine which core is sending the message
    int *in = (core_id == 0) ? &mb->in_A : &mb->in_B;
    int *out = (core_id == 0) ? &mb->out_A : &mb->out_B;
    pthread_cond_t *not_full = (core_id == 0) ? &mb->not_full : &mb->not_full;

    while ((*in + 1) % MAX_MESSAGES == *out) {
        // Mailbox is full, wait for it to not be full
        pthread_cond_wait(not_full, &mb->mutex);
    }

    mb->buffer[*in] = message;
    *in = (*in + 1) % MAX_MESSAGES;

    // Signal that the mailbox is not empty
    pthread_cond_signal(&mb->not_empty);

    pthread_mutex_unlock(&mb->mutex);
}

int receive_message(DualCoreMailbox *mb, int core_id) {
    pthread_mutex_lock(&mb->mutex);

    // Determine which core is receiving the message
    int *in = (core_id == 0) ? &mb->in_B : &mb->in_A;
    int *out = (core_id == 0) ? &mb->out_B : &mb->out_A;
    pthread_cond_t *not_empty = (core_id == 0) ? &mb->not_empty : &mb->not_empty;

    while (*in == *out) {
        // Mailbox is empty, wait for it to not be empty
        pthread_cond_wait(not_empty, &mb->mutex);
    }

    int message = mb->buffer[*out];
    *out = (*out + 1) % MAX_MESSAGES;

    // Signal that the mailbox is not full
    pthread_cond_signal(&mb->not_full);

    pthread_mutex_unlock(&mb->mutex);

    return message;
}

void destroy_dual_core_mailbox(DualCoreMailbox *mb) {
    pthread_mutex_destroy(&mb->mutex);
    pthread_cond_destroy(&mb->not_empty);
    pthread_cond_destroy(&mb->not_full);
}

// Example usage:
int main() {
    DualCoreMailbox mailbox;
    init_dual_core_mailbox(&mailbox);

    // Sender thread on Core A
    pthread_t sender_A_thread;
    pthread_create(&sender_A_thread, NULL, (void *(*)(void *))send_messages, &mailbox, 0);

    // Receiver thread on Core B
    pthread_t receiver_B_thread;
    pthread_create(&receiver_B_thread, NULL, (void *(*)(void *))receive_messages, &mailbox, 1);

    // Wait for threads to finish
    pthread_join(sender_A_thread, NULL);
    pthread_join(receiver_B_thread, NULL);

    destroy_dual_core_mailbox(&mailbox);

    return 0;
}

在这个示例中,DualCoreMailbox 结构体包含两个核心的读写索引、条件变量等,通过参数 core_id 来区分不同的核心。send_message 和 receive_message 函数用于在不同核心之间发送和接收消息。注意,这只是一个简化的实例,实际上,双核通信可能涉及更复杂的同步和通信机制,具体取决于底层硬件和操作系统的支持。

中断为什么会降低性能

中断是计算机系统中一种常见的硬件与软件交互机制。当发生某些特定的事件(如硬件设备发生状态变化、定时器到达时间等)时,中断机制可以中止正在执行的程序,转而执行与事件相关的中断服务例程。虽然中断是实现实时响应和异步事件处理的重要机制,但它也可能带来性能的降低,主要原因包括:

  • 上下文切换开销: 中断处理会引发上下文切换,即由当前正在执行的任务切换到中断服务例程。上下文切换本身涉及保存当前任务的上下文信息、加载中断服务例程的上下文信息等操作,这些操作的开销较大。
  • 中断频率高: 如果系统中的中断频率非常高,系统可能会在处理中断的过程中花费大量的时间。频繁的中断可能导致系统在中断处理和任务执行之间频繁切换,从而影响整体性能。
  • 竞争和争用: 多个硬件设备可能产生同时发生的中断请求,而且某些中断可能具有不同的优先级。这可能导致中断之间的竞争和争用,需要适当的中断控制和优先级管理,以确保关键任务得到及时处理。
  • 缓存效应: 中断处理可能导致缓存失效,因为它打断了正常的程序执行流程。当执行中断服务例程时,缓存中可能包含的是与中断服务无关的数据,而这些数据在中断处理之前可能已经被清除。
  • 响应时间和延迟: 中断是为了实现系统对异步事件的快速响应,但过多的中断处理可能会导致系统对一些关键任务的响应时间增加,从而影响系统的实时性能。

虽然中断会带来一些性能上的挑战,但在很多应用中,中断机制是必不可少的,因为它提供了对异步事件的有效处理。优化中断性能的关键在于合理规划中断处理的优先级、降低中断频率、减小上下文切换开销等。一些专门的硬件和操作系统功能也可以用于减轻中断引起的性能问题。

为什么这个程序等于0
long long foo(){
    long long i, x = 1;
    for (i = 2; i != 1; i++){
        x = x * i;
    }

    return x;
}

在你提供的循环中,long long 类型的整数溢出是导致 x 最终等于 0 的原因。当 long long 类型的变量溢出时,它会发生数值截断,即变量的值会回绕到该类型的表示范围内,而不是继续增加。

具体分析你提供的输出:

当 i 达到 65 时,x 的值变为 -9223372036854775808,这是 long long 类型的最小值。
随后,i 不断增加,但由于 x 已经是 long long 类型的最小值,无论 x 乘以多少都不会改变它的值。
由于 long long 类型的最小值是一个负数,所以不断乘以正整数的结果仍然是负数,但由于 x 已经是最小值,再次溢出时会截断为 0。
因此,当 i 达到 65 时,x 的值就一直为 0 了。这是由于溢出的数值截断特性导致的。在实际应用中,为了避免这种溢出带来的问题,需要进行适当的溢出检查和处理。

什么是Linux内核?

Linux内核是操作系统的核心组件,负责管理系统资源、调度任务、提供系统调用接口、处理中断和异常、管理设备驱动程序等。它是与硬件直接交互的部分,同时也提供了用户空间和内核空间之间的接口。

进程调度算法在Linux内核中是如何实现的?

Linux内核采用了完全公平调度(CFS)算法。CFS通过维护进程的虚拟运行时间(virtual runtime)来进行调度决策,以确保每个进程都能在一段时间内获得相同的CPU时间份额。这通过动态地调整进程的优先级来实现。

Linux内核中的中断处理机制是怎样的?

Linux内核使用中断描述符表(IDT)来管理中断向量,中断服务例程(ISR)负责具体的中断处理。当硬件触发中断时,CPU会跳转到相应的中断向量,执行相应的ISR。Linux中断处理的核心是中断上下文切换,包括保存被中断进程的上下文、执行中断服务例程、恢复原进程的上下文等。

什么是内核模块?如何加载和卸载内核模块?

内核模块是可以在运行时加载到内核中的可执行代码,用于扩展内核功能。insmod命令用于加载内核模块,rmmod命令用于卸载内核模块。加载模块时,内核会调用模块的初始化函数;卸载模块时,内核会调用模块的清理函数。

虚拟内存是如何在Linux内核中实现的?

Linux内核使用分页机制来实现虚拟内存。每个进程都有独立的虚拟地址空间,通过页表将虚拟地址映射到物理地址。页面置换算法(如LRU)用于管理物理内存,当需要更多内存时,可以通过将不再使用的页面移至磁盘来释放物理内存。

Linux内核中的进程通信方式有哪些?

Linux内核提供了多种进程通信(IPC)机制,包括共享内存、消息队列、信号、管道、套接字等。这些机制允许进程之间进行数据传输、同步和通信。

Linux内核中的文件系统是如何组织的?

Linux内核中的文件系统采用虚拟文件系统(VFS)层,将不同的文件系统抽象为统一的接口。VFS定义了文件系统的基本操作,例如读、写、打开、关闭等。常见的文件系统如EXT4、XFS、Btrfs等都通过VFS接口与Linux内核进行交互。

描述Linux内核中的内存管理机制

Linux内核中的内存管理包括物理内存管理和虚拟内存管理。物理内存管理负责分配和释放物理内存页,而虚拟内存管理包括页表、页面置换、进程地址空间等。通过页表,Linux将进程的虚拟地址映射到物理地址,实现了虚拟内存的抽象和隔离。

什么是内核态和用户态?Linux内核是如何切换这两种态的?

内核态和用户态是CPU运行状态的两种模式。在用户态,进程只能访问受限资源,而在内核态,进程可以执行所有指令并访问系统资源。Linux通过系统调用(syscall)实现用户态到内核态的切换。当进程执行系统调用时,会触发一个中断,CPU切换到内核态执行相应的内核代码,完成系统调用后再返回用户态。

Linux内核中的调度器是如何工作的?

Linux内核采用完全公平调度(CFS)算法,通过维护进程的虚拟运行时间来进行调度。CFS尽量保证每个进程在一段时间内获得相同的CPU时间份额,实现公平调度。调度器通过动态调整进程的优先级来实现,具有较低优先级的进程在调度时被更频繁地执行。

Secure Boot

RTOS启动过程是怎样的?

RTOS启动过程通常包括硬件初始化、RTOS内核初始化、任务创建、中断初始化等步骤。硬件初始化包括处理器和外设的初始化,RTOS内核初始化涉及内存分配、任务调度器的启动等。

什么是安全启动(Secure Boot)?它在RTOS中有什么作用?

安全启动是一种确保系统启动过程中软件的完整性和信任的机制。在RTOS中,安全启动可以用于验证引导加载程序和RTOS内核的完整性,防止恶意软件或未经授权的代码加载到系统中。

BootROM是什么?它在系统启动中的角色是什么?

BootROM(启动只读存储器)是嵌入式系统中的固化存储器,通常包含引导加载程序的初始代码。在系统启动时,BootROM是第一个执行的代码,负责加载引导加载程序,并启动系统的启动过程。

在RTOS中,如何实现安全启动过程?

实现安全启动通常涉及数字签名、哈希校验、固件验证等技术。引导加载程序和RTOS内核的镜像可能会被数字签名,BootROM负责验证这些签名,确保代码的完整性和真实性。

介绍一下数字签名在RTOS中的应用

数字签名用于验证软件的身份和完整性。在RTOS中,引导加载程序和RTOS内核的二进制文件可以使用数字签名进行签名,BootROM在启动时验证这些签名,以确保加载的代码是合法的、未被篡改的。

RTOS系统中的引导加载程序有何特点?

引导加载程序是在系统启动时负责加载RTOS内核的程序。它通常较小巧,专注于引导过程,可能包括初始化硬件、加载RTOS内核、设置堆栈等。引导加载程序的安全性和可靠性对系统的整体安全性至关重要。

在RTOS的安全启动中,哈希校验的作用是什么?

哈希校验用于验证软件的完整性。在安全启动过程中,引导加载程序和RTOS内核的镜像可能会被计算哈希值,并与预先存储的哈希值进行比较。一致性表明软件未被篡改。

介绍一下安全启动中的信任链(Chain of Trust)的概念

信任链(Chain of Trust)是计算机安全领域中一种确保系统启动过程中各个环节可信的关键概念。在安全启动中,信任链是建立在各个启动阶段之间的信任关系,以确保整个系统的完整性和可信性。以下是信任链的基本概念:

  • 信任链的起始点: 信任链的起始点通常是硬件的固化存储器中存储的启动代码,通常是BootROM。这部分代码是系统启动的第一个执行的代码,负责加载引导加载程序。
  • BootROM的角色: BootROM是整个信任链的初始信任点。它负责验证引导加载程序的完整性和真实性,以确保引导加载程序未被篡改。BootROM通常具有固化存储的不可修改性,因此可以作为启动过程的初始信任。
  • 引导加载程序: 引导加载程序是信任链中的下一个环节。BootROM通过验证引导加载程序的数字签名、哈希校验或其他安全机制,建立对引导加载程序的信任。引导加载程序负责加载操作系统内核。
  • 操作系统内核: 引导加载程序在验证通过后,将控制权传递给操作系统内核。操作系统内核需要通过验证其完整性,以确保其可信任性。这可能涉及到对内核二进制文件的数字签名、哈希校验等操作。
  • 操作系统启动: 一旦操作系统内核验证通过,系统就会继续启动操作系统。在此阶段,操作系统可以建立对其他组件(如驱动程序、服务)的信任,以确保系统在整个启动过程中保持安全和完整。
  • 信任链的延伸: 信任链可以继续延伸到更高层次,包括应用程序、安全服务和其他系统组件。每个环节都需要验证其信任基础,并建立对下一个环节的信任关系。
  • 关键特征: 信任链的关键特征是每个环节都能够验证下一个环节的可信性
在RTOS启动过程中,异常处理是如何考虑的?

在RTOS启动过程中,异常处理涉及到处理硬件初始化时可能发生的异常,确保异常能够被合适地捕获、记录和处理。这也包括了在异常处理中维护系统的安全性和可靠性。

在RTOS系统中,BootROM的固件更新是如何处理的?

BootROM的固件更新通常是一个敏感的操作,可能涉及到多级引导加载。更新过程需要确保固件的完整性和信任,通常采用类似于安全启动的机制,包括数字签名和哈希校验等。

Cortex-A

Arm Cortex-A系列处理器的启动过程是怎样的?

启动过程通常包括复位、加载BootROM、执行BootROM中的初始化代码、加载引导加载程序(Bootloader),最终将控制权交给操作系统内核。

Cortex-A系列处理器的复位流程是怎样的?

当处理器上电或复位时,硬件会执行一系列初始化操作,包括设置默认状态、清零寄存器、加载初始堆栈指针等。处理器会从复位向量处开始执行。

引导加载程序在Cortex-A处理器中的作用是什么?

引导加载程序负责加载操作系统内核,通常从存储设备(如闪存、SD卡)中读取引导加载程序代码,并将控制权传递给操作系统内核。

Cortex-A处理器的TrustZone技术在启动过程中有何作用?

TrustZone技术是一种由Arm推出的硬件级别的安全技术,用于提供处理器内部的安全和非安全执行环境的隔离。在Cortex-A处理器的启动过程中,TrustZone技术发挥着关键的作用,确保系统在安全性和可信度方面有较高的保障。以下是TrustZone在启动过程中的主要作用:

  • 安全引导过程:
    作用: TrustZone技术允许系统在启动过程中建立安全引导链。这确保引导加载程序、固件以及其他关键组件在被加载和执行之前经过了安全验证。只有经过验证的代码才能在TrustZone的安全环境中执行。
  • Roots of Trust建立:
    作用: TrustZone在启动时有助于建立Roots of Trust,即系统的根信任点。这些信任点包括硬件的TrustZone状态配置、启动加载程序、BootROM等。建立Roots of Trust是确保系统整个生命周期内的安全性的关键。
  • 安全状态的切换:
    作用: TrustZone技术允许处理器在安全和非安全状态之间进行切换。在启动过程中,处理器在安全状态下执行安全的初始化和验证任务,然后切换到非安全状态继续执行其他任务。
  • Secure Monitor的启动:
    作用: Secure Monitor是运行在TrustZone安全状态下的软件组件,用于管理安全和非安全世界之间的切换。在启动过程中,确保Secure Monitor的正确启动和配置是TrustZone的关键任务。
  • 关键信息的保护:
    作用: TrustZone技术帮助保护关键信息,如加密密钥、敏感数据等,防止其在非安全环境中被未经授权的应用程序或组件访问。这有助于确保系统的机密性。
  • 安全的引导加载程序验证:
    作用: TrustZone技术可以用于验证引导加载程序的完整性和真实性。在启动过程中,通过对引导加载程序进行数字签名验证等安全机制,确保引导加载程序没有被篡改。
  • 安全的应用程序容器:
    作用: TrustZone技术允许创建安全的应用程序容器,将敏感的应用程序逻辑运行在安全环境中。这有助于保护用户数据和应用程序的安全性。
  • 安全的运行时环境:
    作用: TrustZone技术在启动过程中确保安全运行时环境的建立,提供安全的执行环境,以防止恶意软件或攻击对系统的危害。

总体而言,TrustZone技术在Cortex-A处理器的启动过程中起到了关键的安全作用,通过硬件隔离实现了安全和非安全环境之间的划分,为系统提供了强大的安全基础。

Secure Monitor在Cortex-A系列中的角色是什么?

Secure Monitor是一个在TrustZone环境中运行的软件组件,负责管理安全和非安全的世界之间的切换。在启动过程中,Secure Monitor可能参与安全引导和TrustZone状态的设置。

Cortex-A系列处理器中的MMU(Memory Management Unit)在启动过程中有何作用?

在Cortex-A系列处理器中,Memory Management Unit(MMU)是一个关键的硬件组件,它在启动过程中具有重要的作用。以下是MMU在启动过程中的主要作用:

  • 地址映射:
    作用: MMU负责将虚拟地址映射到物理地址。在启动过程中,MMU的初始化确保正确的地址映射关系,建立虚拟地址空间到物理内存的对应关系。
  • 虚拟内存的启用:
    作用: MMU使得操作系统能够使用虚拟内存,为每个进程提供独立的地址空间。在启动过程中,MMU的设置允许操作系统使用虚拟内存机制,提高系统的灵活性和安全性。
  • 内存保护和权限控制:
    作用: MMU允许对内存区域进行权限控制,包括读、写、执行等权限。在启动过程中,MMU的配置确保正确的内存保护,防止对受保护区域的非法访问。
  • 缓存一致性:
    作用: MMU有助于确保虚拟地址空间中的数据与物理内存中的数据保持一致。在启动过程中,MMU的设置有助于处理缓存一致性问题,避免数据不一致性引起的错误。
  • 多级页表的设置:
    作用: MMU支持多级页表机制,将虚拟地址空间划分为多个级别的页表,以便更有效地管理大型内存。在启动过程中,MMU的初始化包括设置多级页表结构,以便系统能够高效地进行地址映射。
  • 异常处理:
    作用: MMU监视对虚拟地址空间的访问,并在发现违反访问权限或无效地址映射的情况下引发异常。在启动过程中,确保MMU的异常处理机制能够及时地响应并报告任何内存访问问题。
  • 支持虚拟内存管理策略:
    作用: MMU的设置可以支持各种虚拟内存管理策略,如分页、分段等。在启动过程中,对MMU的配置可以根据系统需求选择适当的虚拟内存管理方案。
  • 启用TLB(Translation Lookaside Buffer):
    作用: MMU使用TLB来缓存地址映射信息,提高地址转换的速度。在启动过程中,确保TLB的初始化以及合适的TLB管理,以获得更好的性能。

总体而言,MMU在Cortex-A系列处理器中的启动过程中扮演着关键的角色,它通过地址映射、权限控制、缓存一致性等功能,确保系统能够有效地管理内存并提供对虚拟内存的支持。

在Cortex-A系列处理器中,异常向量表是如何处理的?

异常向量表包含了处理器在发生异常时跳转的地址。在启动过程中,异常向量表的设置是确保异常处理能够正确执行的关键步骤。

在Cortex-A系列处理器的启动过程中,如何配置中断控制器(如GIC)?

中断控制器负责管理中断,包括配置中断优先级、使能中断等。在启动过程中,配置中断控制器是确保系统能够正确响应中断的关键一步。

Cortex-A系列处理器在启动时如何加载异常处理程序?

异常处理程序是处理器在发生异常时执行的代码段。在启动过程中,异常处理程序通常被设置为在异常向量表中的相应入口,以确保在异常发生时能够得到适当的处理。

在Cortex-A系列处理器中,如何处理Bootloader与操作系统内核之间的传递?

通常,Bootloader加载操作系统内核的镜像并将控制权传递给内核的入口点。这可以通过函数调用、跳转指令或其他机制实现,具体取决于使用的Bootloader和内核。

bootloader Uboot

u-boot是干什么的

U-Boot(Universal Boot Loader)是一个开源的引导加载程序,主要用于嵌入式系统中。其功能包括加载操作系统内核、引导 Linux 内核、执行硬件初始化和配置,以及提供用户交互的命令行界面等。

主要特点和功能:

  • 引导加载: U-Boot负责从启动设备(如闪存、SD卡、网络等)加载操作系统内核到系统内存中,启动嵌入式系统。
  • 硬件初始化: U-Boot可以初始化嵌入式系统中的各种硬件设备,包括处理器、内存、外设等,确保这些硬件设备处于合适的状态。
  • 引导 Linux 内核: U-Boot通常用于引导 Linux 操作系统,将 Linux 内核加载到内存并启动。
  • 支持多种启动设备: U-Boot支持从各种存储设备(如 NAND Flash、NOR Flash、SD卡、USB 存储设备)以及网络引导等启动。
  • 可扩展性: U-Boot提供了丰富的命令行接口,允许用户在引导时进行交互式的操作,例如修改启动参数、更新固件等。
  • 开源和跨平台: U-Boot是开源软件,可以在多种处理器架构和平台上运行,例如 ARM、PowerPC、x86 等。
  • 支持自定义脚本和配置: 用户可以通过 U-Boot 的脚本功能定义启动脚本,从而实现灵活的启动配置。
  • 嵌入式系统维护和调试: U-Boot提供了一些用于嵌入式系统的调试和维护功能,例如内存测试、设备烧写、固件更新等。

总体而言,U-Boot在嵌入式系统中扮演着一个关键的角色,它为嵌入式系统的引导和初始化提供了可靠的解决方案,并具有广泛的应用。

u-boot的启动流程是怎么样的

U-Boot的启动流程可以简要概括为以下几个步骤:

  • 处理器上电初始化: 当处理器上电或复位时,首先执行一些硬件初始化代码。这可能涉及一些处理器或芯片特定的初始化步骤。
  • 引导加载程序加载: 处理器会从预定义的启动设备(如 Flash 存储器、SD卡、网络等)加载引导加载程序。引导加载程序通常是 U-Boot SPL(Secondary Program Loader)。
  • U-Boot SPL执行: 引导加载程序(如 U-Boot SPL)负责进一步的初始化,包括处理器和设备的初始化。它还会尝试从特定的存储设备加载更大的 U-Boot 主程序。
  • U-Boot主程序加载: U-Boot SPL加载 U-Boot 主程序,通常是 u-boot.bin。U-Boot主程序进一步初始化系统,包括初始化内存、设置环境变量、配置设备等。
  • 启动内核: U-Boot主程序最终负责加载操作系统内核。它可以从存储设备(如 Flash、SD卡)或通过网络加载 Linux 内核到内存中。
  • 传递控制给内核: U-Boot将控制传递给加载的内核,启动操作系统的正常执行流程。

U-Boot的启动流程可能会根据具体的硬件平台和配置而有所不同。U-Boot提供了灵活的配置选项,允许开发人员根据嵌入式系统的需求进行定制。在整个启动过程中,U-Boot提供了交互式命令行界面,使用户可以进行调试、配置环境变量以及执行其他自定义操作。

U-Boot的主要功能是什么?

U-Boot的主要功能包括从存储设备加载操作系统内核,配置硬件参数,支持网络引导,提供命令行界面(CLI),允许用户与系统进行交互,以及支持固件更新和调试功能。

如何配置U-Boot进行特定平台的启动?

U-Boot的配置通常通过一个名为config或defconfig的脚本文件进行。用户可以使用交叉编译工具链和该配置文件生成适用于目标平台的U-Boot二进制文件。

如何在U-Boot中进行网络引导?

U-Boot支持通过网络引导,可以使用TFTP(Trivial File Transfer Protocol)从远程服务器下载内核镜像。用户需要配置U-Boot以指定网络服务器的IP地址、文件路径等信息。

在U-Boot中如何更新固件?

U-Boot允许通过不同的方式更新固件,如从存储设备、通过网络下载等。用户可以使用U-Boot的命令行接口执行相关命令,例如update或loady等。

U-Boot如何支持多种存储设备?

U-Boot通过提供通用的块设备接口来支持多种存储设备,包括闪存(NAND、NOR)、SD卡、eMMC、硬盘等。用户需要配置U-Boot以匹配目标系统的存储设备。

U-Boot中的环境变量是什么?如何使用?

U-Boot中的环境变量是用于存储配置信息的变量集合,如IP地址、启动命令等。用户可以通过命令行界面或脚本设置和读取环境变量,以配置系统的启动参数。

如何在U-Boot中进行调试?

U-Boot支持多种调试方式,包括通过串口输出调试信息、使用GDB进行源代码级别的调试,以及使用U-Boot自身提供的一些调试命令。

U-Boot如何支持不同的体系结构和处理器?

U-Boot提供了可配置的体系结构和处理器支持,用户可以通过配置文件选择适用于目标体系结构和处理器的U-Boot版本。支持的体系结构包括ARM、x86、PowerPC等。

TrustZone架构

什么是TrustZone技术?

TrustZone是一种由Arm提供的硬件级别的安全技术,旨在提供硬件隔离,将处理器的执行环境划分为安全(Secure)和非安全(Non-secure)两个世界,以增强系统的安全性。

TrustZone的基本原理是什么?

TrustZone使用硬件隔离来创建两个不同的执行环境:安全世界和非安全世界。在安全世界中,可以执行安全的任务,而在非安全世界中,可以执行普通的非安全任务。这两个世界共享处理器核心,但它们的内存和外设是相互隔离的。

TrustZone中的安全和非安全世界如何进行切换?

TrustZone允许在安全和非安全世界之间进行动态切换。这个切换是通过特定的指令或中断触发的,引导加载程序或安全操作系统的初始化代码通常负责配置和切换到安全世界。

TrustZone的硬件组件是什么?

TrustZone的硬件组件包括:
Security Extensions(SecEx): 处理器核心中的硬件扩展,支持TrustZone功能。
TrustZone Controller: 用于控制安全和非安全世界之间的切换。
Secure Memory Space: 专门为安全世界分配的内存空间。

TrustZone如何保护安全世界中的代码和数据?

TrustZone通过硬件隔离确保安全世界中的代码和数据无法被非安全世界访问。安全世界的代码和数据存储在Secure Memory Space中,只有通过安全的通道才能访问。

TrustZone在安全引导中的作用是什么?

TrustZone在安全引导中的主要作用是确保引导加载程序和操作系统内核的完整性和真实性。安全引导过程中,TrustZone可用于验证引导加载程序的签名,确保加载的代码是可信任的。

TrustZone是否支持多处理器系统?

是的,TrustZone可以支持多处理器系统。每个处理器核心都可以拥有自己的TrustZone状态,并在安全和非安全世界之间切换。这为对称多处理(SMP)系统提供了灵活的安全性。

TrustZone技术如何支持安全的通信和密钥管理?

TrustZone提供了一些安全的通信机制,如Secure Monitor调用和SVC指令,以确保安全世界和非安全世界之间的安全通信。此外,TrustZone可以支持安全存储和管理密钥的功能。

在TrustZone中,安全和非安全世界之间的通信如何进行?

通信通常通过安全监视器(Secure Monitor)进行,它允许非安全世界通过指定的接口请求安全服务。这可以包括安全世界提供的安全API、密钥管理、安全存储等服务。

TrustZone如何应用于移动设备的安全性?

在移动设备中,TrustZone可用于保护敏感的用户数据,处理生物识别信息,支持安全支付等。它还可用于安全引导,确保设备的操作系统和引导加载程序是受信任的。

Wi-Fi芯片

什么是Wi-Fi?

Wi-Fi(无线保真)是一种无线局域网技术,允许设备通过无线信号进行互联。它是根据IEEE 802.11标准制定的。

什么是MIMO技术?

MIMO(Multiple Input Multiple Output)是一种通过使用多个天线进行无线通信的技术。它可以提高数据传输速率和系统性能,通过在发送和接收端使用多个天线实现多条数据流的同时传输。

解释Wi-Fi中的频段和频道

Wi-Fi操作在不同的频段,如2.4GHz和5GHz。每个频段分为多个不重叠的频道,用于避免干扰。在2.4GHz频段中,常见的频道包括1、6、11等。在5GHz频段中有更多的频道可用。

Wi-Fi中的Beamforming是什么?

Beamforming是一种技术,通过调整天线的相位和方向,集中Wi-Fi信号的能量,以提高无线连接的稳定性和性能。它可以通过动态调整信号的传播方向来优化信号的传输。

解释Wi-Fi中的Roaming

Roaming是设备在移动过程中从一个接入点切换到另一个接入点的过程。为了实现平滑的漫游,设备需要能够无缝地在不同的接入点之间切换,确保持续的连接。

什么是Wi-Fi协议栈?

Wi-Fi协议栈是一系列协议层,用于支持无线局域网(Wi-Fi)通信。它定义了在无线网络中进行通信所需的各个协议和层级。Wi-Fi协议栈通常遵循OSI(开放系统互连)模型,包括物理层、数据链路层、网络层、传输层和应用层。

下面是Wi-Fi协议栈的主要组成部分:

  • 物理层(PHY):
    物理层是Wi-Fi协议栈的底层,负责将数字数据转换为无线信号以在空中传输。它定义了使用的频率、调制解调方式、天线配置等参数。
  • 射频前端(RF):
    射频前端负责对物理层信号进行前端处理,包括射频信号的放大、滤波等操作。它与天线直接相连,影响了信号的传输和接收质量。
  • 基带处理器(Baseband Processor):
    基带处理器位于物理层之上,负责数字信号的处理和调制解调。它执行诸如信号解调、解码、差错检测和纠正等任务。
  • 射频收发器(RF Transceiver):
    射频收发器是射频前端和基带处理器之间的接口,负责将数字信号转换为射频信号,并从射频信号中提取数字信号。
  • 数据链路层(Link Layer):
    数据链路层负责将数据帧从一个节点传输到另一个节点,并提供点对点和点对多点的通信。它通常分为两个子层:逻辑链路控制(Logical Link Control,LLC)和介质访问控制(Media Access Control,MAC)。
  • 网络层(Network Layer):
    网络层处理数据的路由和转发,确保数据在网络中正确地传递。它使用IP(Internet Protocol)协议进行寻址和路由。
  • 传输层(Transport Layer):
    传输层负责端到端的通信,包括数据的可靠传输和流量控制。在Wi-Fi协议栈中,通常使用TCP(Transmission Control Protocol)或UDP(User Datagram Protocol)。
  • 应用层(Application Layer):
    应用层提供与用户应用程序的接口,负责处理高级协议、安全性、用户认证等任务。常见的应用层协议包括HTTP、FTP、SMTP等。

Wi-Fi协议栈允许设备通过无线信号进行通信,支持从物理层到应用层的多个协议层,确保在无线网络中可靠、高效地进行数据传输。在不同的Wi-Fi标准中,协议栈的具体实现可能会有所不同,但基本的层级结构和功能依然存在。

请解释Wi-Fi中的CSMA/CA协议

CSMA/CA(Carrier Sense Multiple Access with Collision Avoidance)是Wi-Fi网络中的一种访问控制协议,用于管理多个设备之间的共享无线信道。这种协议旨在避免数据碰撞,并通过采取一些机制来优化通信的可靠性。以下是CSMA/CA协议的主要原理和步骤:

  • 监听信道(Carrier Sense):
    在发送数据之前,设备首先监听无线信道,检查是否有其他设备正在进行通信。这个过程称为“监听信道”,设备需要确保信道空闲,以避免碰撞。
  • 空闲等待时间(Interframe Space):
    在监听到信道空闲后,设备需要等待一段固定的时间,称为“空闲等待时间”或“间隙时间”(Interframe Space)。这个等待时间允许其他设备有机会检测到空闲信道并进行相应的操作。
  • 发送帧前的随机等待时间(Random Backoff):
    在空闲等待时间之后,设备引入一个随机的等待时间,称为“发送帧前的随机等待时间”或“随机后退时间”(Random Backoff)。这个时间是随机选择的,目的是使不同设备避免同时发送数据帧,从而减少碰撞的可能性。
  • 发送数据帧(Transmitting Data Frame):
    一旦随机等待时间结束,设备可以安全地发送数据帧。在发送数据时,设备会再次监听信道,确保发送过程中没有其他设备开始通信。
  • 等待ACK(Acknowledgment):
    如果接收方成功接收到数据帧,它会发送一个确认帧(ACK)回给发送方,表示数据成功接收。发送方在一定时间内等待ACK的到达,如果没有收到,则认为数据帧发送失败,可能由于碰撞或其他问题。
  • 碰撞检测与避免(Collision Detection and Avoidance):
    如果设备在发送数据时检测到信道上有其他设备正在进行通信,它会立即中止数据传输,并采取一些机制来避免未来的碰撞。这包括增加随机等待时间,以使不同设备能够在不同的时间尝试发送。

总体而言,CSMA/CA协议通过监听信道、引入随机等待时间和采用ACK确认的机制,有效地避免了在共享无线信道上发生数据碰撞。这种协议对于无线网络中多个设备之间的协同工作至关重要,确保数据的可靠传输和通信效率。

Wi-Fi的安全性措施

Wi-Fi的安全性措施是为了保护无线网络中的数据传输和通信安全,防止未经授权的访问和数据泄露。以下是一些常见的Wi-Fi安全性措施:

  • WEP(Wired Equivalent Privacy):
    WEP是Wi-Fi最早期的安全协议之一,使用静态密钥对数据进行加密。然而,WEP已经被证明容易受到攻击,因此不再被推荐使用。
  • WPA(Wi-Fi Protected Access):
    WPA是WEP的替代品,引入了更强大的加密算法,如TKIP(Temporal Key Integrity Protocol)。WPA通过动态生成会话密钥,提高了数据的安全性。
    • WPA2:
      WPA2是WPA的进一步改进,采用更安全的加密算法,如CCMP(Counter Cipher Mode with Block Chaining Message Authentication Code Protocol)。它是目前最常用的Wi-Fi安全协议。
    • WPA3:
      WPA3是WPA2的最新版本,引入了更强大的加密和认证机制。它提供更强的保护,包括对公共Wi-Fi网络的增强保护和更强大的密码学算法。
  • Wi-Fi密码(Pre-Shared Key):
    使用Wi-Fi密码是一种基本的安全措施,要求用户在连接到Wi-Fi网络时输入预先设置的密码。这有助于防止未经授权的用户访问网络。
  • MAC地址过滤(MAC Address Filtering):
    MAC地址过滤允许网络管理员配置仅允许特定设备的MAC地址连接到Wi-Fi网络。尽管不是完美的安全解决方案,但可以增加访问网络的门槛。
  • 隐藏SSID(Hidden SSID):
    隐藏SSID是一种将Wi-Fi网络名称隐藏的方法,使其在扫描时不可见。尽管这并不是真正的安全措施,但可以增加网络的不可见性。
  • EAP(Extensible Authentication Protocol):
    EAP是一种灵活的认证协议,用于提供更强大的用户身份验证。在Wi-Fi网络中,EAP通常与WPA2或WPA3一起使用,以提供更高级别的安全性。
  • VPN(Virtual Private Network):
    使用VPN可以在连接到Wi-Fi网络时加密通信,提供额外的隐私和安全性。VPN通过在用户设备和网络之间建立安全的通道来保护数据传输。
  • 双因素身份验证(Two-Factor Authentication):
    双因素身份验证要求用户提供两种以上的身份验证因素,如密码和手机验证码。这增加了对网络的访问控制。

这些安全性措施通常结合使用,以提供综合的保护,确保Wi-Fi网络的数据传输安全,并降低未经授权访问的风险。在实际应用中,根据网络的需求和安全要求,可以选择合适的安全性措施组合。

解释Wi-Fi中的Roaming和实现方法

Wi-Fi中的Roaming指的是设备在移动过程中从一个接入点切换到另一个接入点的过程,以保持持续的网络连接。Roaming的目标是在设备在覆盖范围内移动时,无缝切换到信号更强或更可靠的接入点,而不会中断网络连接。这对于移动设备(如智能手机、笔记本电脑等)在无线网络中的稳定性和连通性至关重要。

Roaming的实现方法涉及以下关键步骤:

  • 扫描附近的接入点:
    移动设备定期执行扫描操作,以检测附近的Wi-Fi接入点。这可以是被动扫描,即设备监听信道上的广播帧,或者主动扫描,即设备发送主动的探测请求。
  • 选择最佳接入点:
    设备通过评估附近接入点的信号强度、质量和其他性能指标来选择最佳的接入点。通常,设备会选择信号更强、质量更好的接入点,以确保更稳定的连接。
  • 发起关联过程:
    一旦设备确定要切换到新的接入点,它会发起与新接入点的关联过程。这包括建立新的认证和关联,获取新的IP地址等步骤。在这个过程中,设备与当前接入点保持连接,以确保无缝切换。
  • 同步密钥和上下文信息:
    在切换到新接入点之前,设备需要与当前接入点同步关键信息,如加密密钥和网络上下文。这确保了在切换过程中的安全性和连续性。
  • 切换到新接入点:
    一旦与新接入点建立了关联,并且所有必要的信息已同步,设备就可以安全地切换到新接入点。这通常包括断开与当前接入点的连接,并建立与新接入点的连接。
  • 无缝切换:
    为了实现无缝切换,切换过程应该是平滑的,减少网络中断的时间。这可能涉及到在切换过程中缓存数据包、快速关联过程、快速IP地址获取等优化。
  • 传递上下文信息:
    在某些情况下,为了确保在切换过程中保持应用程序或服务的持续连接,设备可能需要传递上下文信息。这可以包括IP地址、端口号等信息。

Roaming的实现方法需要在Wi-Fi协议栈、驱动程序和设备操作系统中进行协同工作。在标准的Wi-Fi协议中,如IEEE 802.11,已经定义了一些机制来支持Roaming,但具体的实现方法可能会因厂商和设备而异。无论如何,实现Roaming是确保在移动设备在Wi-Fi网络中平滑漫游的关键,提供了用户体验和网络连接的可靠性。

请描述Wi-Fi芯片的功耗管理策略

Wi-Fi芯片的功耗管理策略是为了在提供稳定性和性能的同时,尽可能降低设备的功耗,延长电池寿命。以下是一些常见的Wi-Fi芯片功耗管理策略:

  • 休眠模式(Sleep Mode):
    Wi-Fi芯片可以在不使用时进入休眠模式,以降低功耗。在休眠模式下,芯片关闭无关的电路,只保留必要的部分以维持基本的系统状态。当设备需要重新使用Wi-Fi时,芯片可以快速从休眠模式唤醒。
  • 低功耗模式(Low Power Mode):
    Wi-Fi芯片通常提供多个功耗模式,根据使用情景动态调整。例如,可以降低射频发射功率、降低时钟频率或禁用某些模块,以减少整体功耗。
  • 定时唤醒(Scheduled Wakeup):
    设备可以设定定时器,周期性地唤醒Wi-Fi芯片以执行必要的任务,然后再返回休眠模式。这有助于在需要时保持对网络的连接,同时在不需要时最大程度地减少功耗。
  • Adaptive Radio Management:
    一些Wi-Fi芯片具有自适应射频管理功能,可以根据当前网络环境调整射频功率。这可以在确保连接稳定性的同时最大限度地减少功耗。
  • 传输功率控制(Transmit Power Control,TPC):
    Wi-Fi芯片可以根据设备之间的距离和通信需求动态调整传输功率。通过在短距离通信时降低功率,可以降低功耗。
  • 动态休眠(Dynamic Sleep):
    动态休眠是一种根据实时网络活动调整休眠时间的方法。如果在一段时间内没有网络活动,Wi-Fi芯片可以进入深度休眠模式,而在有活动发生时迅速唤醒。
  • 快速唤醒和关联:
    设备可以优化Wi-Fi芯片的唤醒和关联过程,以减少从休眠模式到完全激活的时间。这有助于在需要时快速建立连接,尽快完成通信任务。
  • 无线接口自动关闭:
    在没有网络活动时,Wi-Fi芯片可以自动关闭无线接口,以降低功耗。当需要时再重新激活接口。
  • 合理使用监听窗口:
    在Wi-Fi通信中,设备需要定期监听信道以检测网络活动。通过合理设置监听窗口的时长和频率,可以平衡连接稳定性和功耗效率。
  • 智能选择传输速率:
    Wi-Fi芯片可以根据信号强度和稳定性自动选择最佳的传输速率,以优化性能并降低功耗。

这些策略通常通过硬件和软件的结合实现,以提供最佳的功耗管理性能。在移动设备等对电池寿命敏感的场景中,有效的功耗管理是确保设备长时间使用的重要因素。

Wi-Fi芯片的主要组成部分是什么?

Wi-Fi芯片是一个集成电路,包含多个关键组成部分,以支持无线通信。以下是Wi-Fi芯片的主要组成部分:

  • 射频前端(RF Front-End):
    射频前端是Wi-Fi芯片的物理层组成部分,负责处理无线信号的发射和接收。它包括射频收发器、天线开关、功率放大器等元件,用于调制和解调无线信号。
  • 基带处理器(Baseband Processor):
    基带处理器是Wi-Fi芯片的核心部分,负责数字信号的处理。它执行诸如解调、解码、差错检测和纠正等任务,以及对数据的调制和编码。
  • 射频收发器(RF Transceiver):
    射频收发器是射频前端的核心组件,负责将数字信号转换为射频信号以进行传输,并将接收到的射频信号转换为数字信号以进行处理。
  • 天线(Antenna):
    天线是Wi-Fi芯片的关键组成部分,用于发射和接收无线信号。Wi-Fi芯片通常包括一个或多个天线,其设计和配置对于信号质量和覆盖范围至关重要。
  • MAC(Media Access Control)层:
    MAC层是Wi-Fi芯片的数据链路层组成部分,负责处理帧的发送和接收、访问控制、碰撞检测和重传等任务。MAC层协议通常由硬件和固件实现。
  • PHY(Physical Layer):
    PHY层是Wi-Fi芯片的物理层组成部分,包括基带处理器和射频前端。PHY层负责将数字数据转换为无线信号,并处理从无线信号中提取的数据。
  • 射频链路(RF Link):
    射频链路是将射频前端、射频收发器和天线连接在一起的物理电路。它负责确保信号的传输和接收。
  • 电源管理单元(Power Management Unit):
    电源管理单元监控芯片的电源消耗,并实施功耗管理策略。它可以调整不同组件的供电,使芯片在不同的工作模式下能够最优化地使用电力。
  • 时钟管理单元(Clock Management Unit):
    时钟管理单元负责生成和分配芯片内各个组件的时钟信号,以同步不同部分的工作,并确保数据在正确的时间传输。
  • 接口和控制逻辑:
    Wi-Fi芯片包括与主机系统通信的接口,例如PCIe、USB、SDIO等。控制逻辑负责管理各个组件的协同工作,实现Wi-Fi功能。

这些组成部分协同工作,使Wi-Fi芯片能够实现无线通信,并支持数据的可靠传输和连接的稳定性。不同厂商和型号的Wi-Fi芯片可能在具体实现上有所不同,但这些基本组件通常都包含在Wi-Fi芯片的设计中。

wifi固件开发

  • 驱动程序开发:
    我有经验开发Wi-Fi芯片的底层驱动程序,包括与操作系统的交互、中断处理、资源分配等方面。
  • 固件架构和设计:
    我设计Wi-Fi固件时通常采用分层架构,将PHY、MAC和应用层逻辑进行模块化设计,以确保灵活性和可维护性。
  • Wi-Fi协议支持:
    我的固件支持多个Wi-Fi标准,包括802.11a/b/g/n/ac/ax,以及Wi-Fi Direct、Mesh网络等特殊功能。
  • 安全性:
    我采用了WPA3等最新的安全标准,实现了加密、认证和密钥管理等安全措施,确保数据的机密性和完整性。
  • 功耗优化:
    通过实施休眠模式、定时唤醒、动态调整射频功率等策略,我成功降低了Wi-Fi固件的功耗,延长了设备的电池寿命。
  • Roaming支持:
    我实现了Roaming优化算法,通过快速且平滑的切换,确保设备在移动时无缝切换到信号更好的接入点。
  • QoS支持:
    我的固件支持QoS,通过流量分类、队列管理和优先级调度等机制,确保对实时通信和流媒体的优化。
  • 调试和性能分析:
    我使用调试工具和性能分析器,例如Wireshark、专业的Wi-Fi分析仪和自定义的调试日志,以便及时发现和解决问题。
  • OTA升级:
    我的Wi-Fi固件支持远程OTA升级,使用安全升级通道和校验机制,确保升级的完整性和设备的安全性。
  • 协议栈实现:
    我使用了行业标准的Wi-Fi协议栈,并根据需要进行了一些定制化,确保协议栈的正确性和兼容性。
  • 合规性和认证:
    我确保Wi-Fi固件符合Wi-Fi联盟和其他相关法规标准,进行了相关认证以保障产品合法合规。
  • 跨平台支持:
    我的Wi-Fi固件设计为可在不同硬件平台上运行,并实现了适配层,以支持多种硬件体系结构。

这些答案仅为示例,实际的回答应该根据具体的项目、经验和技术要求进行调整。在面试过程中,关键是展示你对Wi-Fi固件开发的深入理解和解决问题的能力。

什么事QoS支持,如何实现的

QoS(Quality of Service,服务质量)支持是一种网络技术,旨在优化网络中不同类型流量的传输效果,以确保对实时性要求较高的应用(如VoIP、视频流)获得更好的性能。在Wi-Fi固件中,实现QoS支持涉及到一系列策略和机制,以下是其中一些关键点:

  • 流量分类:
    首先,Wi-Fi固件需要对流经网络的数据进行分类,以区分不同类型的流量。常见的分类包括实时流量(如VoIP、视频)、优先数据(如在线游戏)、常规数据等。
  • 队列管理:
    为了给不同类型的流量分配不同的优先级,Wi-Fi固件使用队列管理来维护多个传输队列。每个队列对应一种流量类型,具有不同的优先级。
  • 优先级调度:
    使用队列管理的基础上,Wi-Fi固件通过调度算法来确定下一步要传输的数据包。通常,实时流量被赋予更高的优先级,以确保在网络拥塞时能够更快地传输。
  • 分配带宽:
    QoS支持还涉及到分配带宽的问题。Wi-Fi固件需要确保高优先级的流量能够获得更大的带宽份额,以保证其更快地通过网络传输。
  • WMM(Wi-Fi Multimedia):
    WMM是一种用于Wi-Fi网络的QoS标准,它在802.11e标准中定义。WMM定义了四个优先级(Voice、Video、Best Effort、Background)和相应的队列,用于管理不同类型的流量。
  • EDCA(Enhanced Distributed Channel Access):
    EDCA是WMM的一部分,它定义了一种通过在传输机会上引入优先级来实现QoS的机制。通过在竞争信道上引入不同的优先级,Wi-Fi固件可以更有效地管理不同类型的流量。
  • TSPEC(Traffic Specification):
    TSPEC是WMM中一项用于描述流量特性的机制。通过TSPEC,设备可以告知网络有关其流量特性(如带宽需求、延迟要求等)的信息,以便网络更好地适应其需求。
  • 帧标记和标签:
    为了在Wi-Fi帧中标识不同类型的流量,Wi-Fi固件可能使用特殊的帧标记或标签。这些标记通常在MAC帧头中,用于指示帧的优先级。

通过实现上述机制,Wi-Fi固件可以有效地支持QoS,确保对实时性要求高的应用获得更佳的性能和用户体验。这对于保障VoIP通话质量、实时视频传输等应用至关重要。

wifi固件的OTA升级流程

Wi-Fi固件的OTA(Over-the-Air)升级是通过无线网络实现的远程升级过程,允许设备在不需要物理连接的情况下更新其固件。以下是一般的Wi-Fi固件OTA升级流程:

  • 版本检测:
    设备在启动时或定期地检查是否有新的固件版本可用。这可以通过连接到升级服务器查询版本信息或接收推送通知来完成。
  • 升级服务器通知:
    升级服务器在检测到新版本可用时,向设备发送通知。这可以通过推送通知、轮询或其他方式实现。
  • 固件下载:
    设备收到升级通知后,通过Wi-Fi连接到升级服务器并下载新的固件。下载的固件通常是以二进制文件或类似格式提供的。
  • 固件验证:
    下载完成后,设备会对固件进行验证,确保其完整性和正确性。通常采用哈希值或数字签名等机制进行验证。
  • 备份当前固件:
    在开始升级之前,设备通常会备份当前的固件,以防升级失败时可以回滚到之前的版本。
  • 固件更新:
    验证通过后,设备开始执行固件更新。这可能涉及将新固件写入设备的存储器,并在下一次启动时应用。
  • 重启设备:
    完成固件更新后,设备会自动重新启动,以加载新的固件。在这个过程中,可能会有一些额外的初始化步骤。
  • 版本确认:
    设备启动后,检查新的固件版本是否成功应用。如果成功,设备通常会向升级服务器发送成功的通知。
  • 回滚机制:
    在升级失败或设备发现新固件存在问题时,可以通过回滚机制返回到之前备份的固件版本,以确保设备的可靠性。
  • 升级日志记录:
    在整个OTA升级过程中,设备可能会记录升级日志,包括升级时间、版本信息、成功或失败状态等信息,以便后续排查问题或统计升级情况。
  • 用户通知:
    在升级过程中,设备可以通过用户界面或其他方式向用户显示升级进度和结果,以提供透明的用户体验。

实现OTA升级流程时,需要考虑到网络连接的稳定性、升级过程中断的处理、固件兼容性等方面。此外,为确保安全性,采用合适的加密和认证机制,以防止固件被篡改或下载到未经授权的设备。

riscv

RISC-V的全称是什么?

RISC-V的全称是Reduced Instruction Set Computing - V。

RISC-V的设计宗旨是什么?与传统的CISC架构有什么不同之处?

RISC-V的设计宗旨是提供一种简化、模块化、可扩展的指令集架构,使其更容易实现和定制。与传统的CISC(Complex Instruction Set Computing)架构不同,RISC-V采用了精简的指令集,每个指令都执行简单而快速,旨在提高执行效率。

RISC-V指令集架构有几个基本特点?

RISC-V的基本特点包括固定大小的指令长度、精简的指令集、统一的寄存器文件、流水线友好的设计、延迟槽的消除等。

RISC-V指令集架构中,寄存器的数量是多少?

RISC-V的基本整数寄存器(X寄存器)数量是32个。

RISC-V中的ISA是什么?

ISA是指令集架构,RISC-V采用的是开放的ISA,即开放指令集架构。

简要解释RISC-V的两种主要ISA扩展,即I和M扩展

I扩展是基本整数指令集,M扩展包含用于整数乘法和除法的指令。

RISC-V的指令宽度是多少位?

RISC-V的指令宽度可以是32位或64位,取决于具体的实现。

什么是RISC-V中的立即数(immediate)?

立即数是指直接嵌入在指令中的常数值,用于进行指令的操作或者偏移量的计算。

解释一下RISC-V中的load和store指令

Load指令用于从内存中加载数据到寄存器,而Store指令用于将寄存器中的数据存储到内存中。

RISC-V中的跳转指令有哪些?

RISC-V中的跳转指令包括Branch和Jump指令,用于实现条件和无条件跳转。

RISC-V中的中断和异常处理是如何设计的?

RISC-V使用中断和异常来处理外部事件和错误,通过中断控制寄存器(CSR)进行中断的配置和处理。

RISC-V的多核处理支持是如何实现的?

RISC-V的多核处理支持通过在同一片芯片上集成多个处理核心,每个核心具有独立的寄存器和执行单元。

RISC-V中的CSR寄存器是用来做什么的?举例说明一个CSR寄存器

CSR寄存器(Control and Status Register)用于配置和控制处理器的各种操作。例如,mstatus寄存器用于存储处理器的运行状态和控制中断。

RISC-V处理器的架构是什么样的?请描述一下RISC-V的基本组成部分

RISC-V是一个精简指令集计算机(RISC)架构,采用了经典的五级流水线架构,包括取指、译码、执行、访存和写回。它有32个通用寄存器(X0到X31),并支持标准整数和浮点运算。

解释一下RISC-V的编程模型。RISC-V是一个精简指令集(RISC)架构,这对软件开发有何优势?

RISC-V编程模型简单明了,具有规范的标准寄存器组织。程序员可以更容易地理解和优化代码。由于指令集较小,实现起来相对简单,可以在多种应用领域进行定制和优化。

在RISC-V固件开发中,什么是Platform-Level Interrupt Controller(PLIC)?它的作用是什么?

PLIC是RISC-V中用于处理中断的控制器。它负责管理各个外设产生的中断,并为处理器分配优先级,确保高优先级中断能够及时被处理。

RISC-V中的指令集架构有几个标准扩展?请列举一些常见的扩展及其功能

RISC-V包括多个标准扩展,如I(基本整数指令集)、M(整数乘法和除法)、A(原子指令)、F(单精度浮点数)、D(双精度浮点数)等。这些扩展提供了不同的指令集功能,允许根据需求定制处理器。

在RISC-V固件开发中,什么是Device Tree(设备树)?它的作用是什么?

设备树是一种描述硬件设备信息的数据结构,用于在不同平台上配置硬件。在RISC-V固件中,设备树用于描述系统的硬件组件、中断控制器、内存布局等信息,使操作系统能够适配各种硬件平台。

解释一下RISC-V的异常处理机制。在固件开发中,异常处理有何重要性?

RISC-V使用异常来响应不同的事件,如中断、故障和系统调用。异常处理是确保系统稳定性和安全性的关键,通过合理处理异常,系统能够从非正常状态中恢复。

RISC-V固件开发中,如何进行对齐(alignment)操作?为什么对齐是重要的?

对齐操作确保数据存储在内存中的地址是其大小的整数倍。在RISC-V中,一些指令对内存的访问可能要求数据是对齐的,否则可能导致性能损失或错误。对齐有助于提高存取速度和系统稳定性。

在RISC-V中,如何进行中断处理?请描述一下中断处理的基本流程

中断处理涉及中断控制器(如PLIC)、中断服务例程(ISR)和上下文切换。当中断发生时,处理器会保存当前上下文,跳转到ISR执行相应的处理,处理完毕后恢复上下文继续执行原程序。

RISC-V固件开发中,如何实现简单的输入输出(I/O)操作?

I/O操作通常涉及到特定的外设寄存器。通过编写固件代码,可以直接读写这些寄存器来进行简单的输入输出操作。

解释一下RISC-V中的Trap和Interrupt的区别

Trap是由指令引起的异常,通常是通过软件触发的,而Interrupt是由外部事件引起的异常,通常是由硬件设备发起的。

在RISC-V固件开发中,如何进行调试?有哪些常见的调试工具和方法?

RISC-V支持JTAG调试接口,可以通过调试器(如GDB)、仿真器和逻辑分析仪等工具进行调试。使用调试符号信息和printf等输出工具也是常见的调试方法。

RISC-V处理器通常支持哪种存储器访问模型?

RISC-V通常支持松散一致性内存模型,这允许对内存操作进行重排序以提高性能。

RISC-V中的Cache机制对固件开发有何影响?

Cache机制可以提高存储器访问速度,但需要注意一致性和缓存一致性问题。在固件开发中,需要谨慎处理缓存的使能和失效,以确保正确的内存访问。

RISC-V和Arm的指令集架构有何不同?

RISC-V采用了开放指令集架构,具有可定制性,用户可以根据需求选择不同的指令集扩展。而Arm的指令集则是闭源的,用户一般使用Arm提供的指令集。

RISC-V和Arm的中断处理机制有何异同?

RISC-V和Arm都使用中断控制器来处理中断,但具体的中断控制器和处理方式可能有所不同。Arm通常使用GIC(Generic Interrupt Controller),而RISC-V使用PLIC(Platform-Level Interrupt Controller)。

RISC-V和Arm的寄存器架构有何异同?

RISC-V和Arm都有通用寄存器和特殊寄存器,但具体的寄存器数量和用途可能不同。例如,RISC-V有32个通用整数寄存器(X0-X31),而Arm一般也有32个寄存器。

RISC-V和Arm在安全性方面有何异同?

Arm的TrustZone技术为安全性提供了硬件支持,通过分区划分安全和非安全环境。RISC-V通常依赖于软件或特定实现来实现安全性功能,例如通过MMU和特权级别进行隔离。

RISC-V和Arm的体系结构在多核处理器支持方面有何异同?

RISC-V和Arm都支持多核处理器,但实际的多核实现可能有所不同。Arm通常使用SMP(Symmetric Multiprocessing)或者更高级的big.LITTLE架构,而RISC-V可以通过硬件多核心或软件多线程来支持。

RISC-V和Arm在嵌入式系统中的应用有何异同?

Arm一直以来都在嵌入式系统中占有主导地位,广泛应用于手机、物联网设备等。RISC-V则在嵌入式系统领域逐渐崭露头角,其开放的特性使得它适用于定制化的需求,例如自定义处理器核心。

RISC-V和Arm的生态系统有何异同?

Arm拥有庞大的生态系统,包括众多的芯片厂商、工具提供商和开发社区。RISC-V作为一个相对较新的架构,生态系统在不断壮大,但规模可能还不如Arm庞大。

ISC-V和Arm的指令集扩展机制有何异同?

RISC-V的指令集扩展是开放的,可以根据需求选择添加不同的扩展。Arm也提供了一些扩展,但一般是由Arm公司定义和控制,对用户的定制性较差。

RISC-V和Arm在开源社区中的参与度有何异同?

RISC-V是一种开源指令集架构,其规范和实现都是公开的,因此吸引了大量开源社区的参与。Arm在某些项目中也有开源的倡议,但整体上相对于RISC-V而言,其社区的开放性可能较低。

RISC-V和Arm在内存模型上有何异同?

RISC-V和Arm在内存模型上有一些相似之处,同时也存在一些差异。以下是它们之间内存模型的一些异同点:

  • 相似之处:
    • 松散一致性内存模型:
      RISC-V和Arm都支持松散一致性内存模型。这意味着处理器和编译器在执行和优化内存操作时有一定的自由度,可以进行指令重排和缓存重排,以提高性能。
    • 内存屏障(Memory Barriers):
      两者都提供了内存屏障或内存栅栏指令,用于强制内存操作的顺序,确保特定点的内存访问顺序符合程序员的期望。
  • 差异之处:
    • 一致性模型的实现:
      Arm的一致性模型通常是通过使用层次化一致性协议(如MESI)来实现的,确保多个处理器之间的数据一致性。而RISC-V在规范中并未定义具体的一致性协议,允许不同的实现选择适合其需求的协议。
    • 访存顺序(Load-Store Ordering):
      RISC-V对访存顺序的规定相对较为灵活,具体取决于实现和配置。一些RISC-V实现支持强制的访存顺序,但这并非是必须的。Arm一般采用强一致性模型,对于内存访问有更为严格的顺序保证。
    • 指令重排的程度:
      在某些情况下,RISC-V可能允许更多的指令重排,以提高执行效率。这使得程序员需要更加注意编写正确的同步代码。相比之下,Arm在一些情况下可能对指令重排有更为限制性的要求。
    • 内存栅栏的类型:
      RISC-V提供了多种内存栅栏类型,例如Acquire/Release、Sequentially Consistent等,用于精确控制内存操作的顺序。Arm同样提供内存栅栏指令,但其类型可能略有不同,如DMB(Data Memory Barrier)和DSB(Data Synchronization Barrier)。

总体而言,虽然RISC-V和Arm都遵循松散一致性的基本理念,但由于各自的设计哲学和规范的差异,它们的内存模型在一些细节上可能存在不同。在实际编程中,程序员需要根据具体的平台和需求,理解并正确地使用相关的内存同步机制。

RISC-V有多少个特权级别?简要描述各个特权级别的区别

RISC-V定义了三个特权级别,分别是 User Mode(U模式)、Supervisor Mode(S模式)、和 Machine Mode(M模式)。这些特权级别用于划分处理器的运行环境和权限,各个级别有不同的访问权限和能力。

  • User Mode(U模式):
    U模式是最低特权级别,用于运行普通用户应用程序。在这个级别下,程序只能访问用户空间的寄存器和内存,没有直接访问特权寄存器或执行特权指令的权限。这是最受限制的特权级别。
    vSupervisor Mode(S模式):
    S模式是中间特权级别,用于运行操作系统的内核代码。在S模式下,操作系统可以访问更多的寄存器和有限的特权指令,以执行操作系统管理任务。S模式也可以用于处理一些异常,如中断和系统调用。
  • Machine Mode(M模式):
    M模式是最高特权级别,用于运行处理器的管理和控制代码,例如引导加载程序(bootloader)和超级监控程序。在M模式下,可以访问所有寄存器和执行所有特权指令,具有最高的系统权限。

这三个特权级别的划分使得RISC-V处理器能够实现多层次的保护和隔离。用户程序在U模式下运行,操作系统内核在S模式下执行,而系统级代码(例如引导加载程序)则在M模式下运行。这样的层次结构有助于提高系统的安全性和可靠性,同时确保不同层次的代码之间有明确的权限划分。

RISC-V支持的内存模型是什么?

RISC-V支持的内存模型是弱一致性内存模型。弱一致性内存模型允许对内存操作进行重排序,以提高性能,但在多线程环境中可能引入一些复杂性。以下是RISC-V内存模型的一些关键特点:

  • 内存顺序(Memory Order):
    RISC-V的内存模型允许对内存访问进行一些重排序,以便更好地利用处理器和内存系统的性能。这包括对指令进行乱序执行和对内存访问进行重排。然而,这些重排的行为受到一些限制,以确保多线程程序的正确性。
  • 内存屏障(Memory Barriers):
    为了控制指令的执行顺序,RISC-V提供了内存屏障或内存栅栏指令,例如fence指令。内存栅栏用于强制内存访问的顺序,以满足程序员的顺序要求。
  • 原子指令:
    RISC-V支持原子指令,这些指令允许在一个原子操作中完成读-修改-写的操作,以确保在多线程环境中对共享数据的操作是原子的。
  • 同步指令:
    RISC-V提供了一些同步指令,如amo(原子操作)指令,用于实现更复杂的同步操作,确保在多线程环境中正确地处理共享数据。
  • 弱一致性:
    总体而言,RISC-V的内存模型被定义为弱一致性模型,这意味着程序员需要谨慎处理并发访问共享数据的情况。在编写多线程程序时,需要使用适当的同步机制来确保正确的执行顺序。

总的来说,RISC-V的内存模型通过提供原子操作、内存屏障和同步指令,为程序员提供了一些工具来控制内存访问的顺序,同时允许一定程度的重排序,以提高系统性能。程序员在编写多线程程序时需要注意这些特性,以确保程序的正确性和可靠性。

RISC-V中的Memory Management Unit(MMU)是用来做什么的?它在固件开发中有何作用?

Memory Management Unit(MMU)是计算机体系结构中的一个硬件组件,用于管理和映射虚拟地址空间到物理地址空间的转换。在RISC-V中,MMU的作用主要包括以下几个方面:

  • 虚拟地址到物理地址的映射:
    MMU负责将应用程序中使用的虚拟地址映射到实际的物理地址。这种映射使得应用程序可以使用逻辑地址,而不需要关心物理内存的实际位置,从而实现了对物理内存的抽象。
  • 地址空间隔离:
    MMU允许不同的应用程序拥有各自独立的虚拟地址空间,这样它们之间的内存访问不会相互干扰。这种隔离提高了系统的安全性和稳定性。
  • 虚拟内存:
    MMU支持虚拟内存技术,允许将部分程序的数据和代码存储在硬盘或其他非易失性存储器中,而不是全部加载到物理内存。这样可以有效利用有限的物理内存,同时提供了更大的可用地址空间。
  • 内存保护:
    MMU通过设置访问权限和页面属性,提供对内存的保护机制。这包括只读、读写、执行等权限的设置,以及页面级别的保护机制,防止非法访问和修改。

在固件开发中,MMU的作用尤为重要:

  • 操作系统的运行: 操作系统通常依赖于MMU来实现多任务、地址空间隔离和虚拟内存管理。MMU使得操作系统能够有效地管理多个任务的内存,提高系统的整体性能。
  • 异常处理: 当发生页面错误(Page Fault)等异常时,MMU能够协助操作系统进行异常处理,例如从磁盘加载缺失的页面到内存。
  • 安全性和稳定性: MMU的存在有助于提高系统的安全性,通过地址空间隔离防止不同任务之间的干扰,同时提高系统的稳定性,使得应用程序和操作系统更容易维护和调试。
  • 总体而言,MMU在RISC-V的固件开发中扮演着关键的角色,为系统提供了内存管理和保护的基础,使得操作系统和应用程序能够在虚拟化的环境中运行。

硬件

I2C

I2C(Inter-Integrated Circuit)是一种用于连接微控制器和外部设备的串行通信协议。它由Philips公司(现在的NXP Semiconductors)在1982年首次引入,并成为一种广泛应用于嵌入式系统中的通信标准。以下是一些可能与I2C相关的面试问题以及相应的答案:

什么是I2C?

答案: I2C是一种串行通信协议,用于在微控制器和外部设备之间传输数据。它是由Philips公司引入的,并以Inter-Integrated Circuit的缩写命名。I2C使用两根线(一根用于数据传输,另一根用于时钟同步)来连接多个设备。

I2C有哪两根基本线?

答案: I2C使用两根基本线:
SDA(Serial Data Line):传输数据的线。
SCL(Serial Clock Line):时钟同步线,用于同步数据传输。

I2C主从模式是什么意思?

答案: I2C支持主从模式。在主从模式中,一个设备充当主设备,负责启动和控制总线上的通信,而其他设备则充当从设备,等待主设备的命令并响应。

I2C的时钟频率是如何确定的?

答案: I2C的时钟频率是由主设备控制的。主设备在通信开始时发送时钟脉冲,时钟频率在整个通信周期内保持不变。通常,时钟频率可以在初始化阶段通过配置寄存器进行设置。

I2C的地址是如何分配的?

答案: I2C设备的地址通常由设备制造商分配。每个I2C设备都有一个唯一的7位或10位地址,用于在总线上识别和寻址设备。

I2C有哪些应用场景?

答案: I2C在许多应用场景中都得到了广泛应用,包括但不限于:
连接传感器,例如温度传感器、湿度传感器等。
连接存储器设备,如EEPROM。
控制显示设备,例如液晶屏幕。
连接各种外围设备,例如电源管理芯片、扩展IO芯片等。

I2C和SPI有什么区别?

答案: I2C和SPI(Serial Peripheral Interface)都是串行通信协议,但它们之间有一些区别。主要区别包括通信线数量(I2C使用两根线,SPI通常使用4根或更多线)、时钟同步方式、设备连接方式等。

I2C的缺点是什么?

答案: I2C的一些缺点包括:
速度较慢:相比于一些其他串行通信协议,I2C的时钟频率较低。
设备数量受限:由于地址空间的限制,I2C总线上可连接的设备数量有限。

上拉电阻

I2C(Inter-Integrated Circuit)通信协议通常需要使用上拉电阻(Pull-Up Resistor)来确保总线上的信号电平正确。下面是一些关于I2C和上拉电阻的问题以及相应的答案:

为什么在I2C总线上需要上拉电阻?

答案: I2C总线上的上拉电阻用于将数据线(SDA)和时钟线(SCL)拉高到高电平,以确保这两根线在无数据传输时维持在高电平状态。这有助于防止总线冲突和确保正常的信号传输。

上拉电阻的作用是什么?

答案: 上拉电阻在I2C总线上的作用是提供默认的高电平状态。由于I2C总线是开漏结构,设备在释放总线时会将数据线拉低,但在设备之间无数据传输时,上拉电阻将确保数据线和时钟线保持在高电平状态。

上拉电阻的典型数值是多少?

答案: 上拉电阻的数值通常在1 kΩ到10 kΩ之间。常见的典型值为4.7 kΩ。选择合适的上拉电阻数值取决于总线的电容负载和通信速率,以确保快速上升时间和稳定的信号。

如果没有上拉电阻会发生什么?

答案: 如果没有上拉电阻,当总线上没有数据传输时,数据线和时钟线可能会漂浮在未定义的电平上,导致信号不稳定。这可能导致总线上的噪声,影响I2C设备之间的正常通信。

上拉电阻的位置在哪里放置?

答案: 上拉电阻应该连接到总线上的SDA和SCL线上。通常,上拉电阻会连接到VCC(正电源)和SDA/SCL之间,确保在无数据传输时SDA和SCL维持在高电平。

I2C总线上的上拉电阻与设备上的上拉电阻有什么区别?

答案: I2C总线上的上拉电阻通常是外部连接到总线上的电阻,而设备上的上拉电阻是一些I2C设备内部集成的电阻。外部上拉电阻通常由系统设计者添加,而设备上的上拉电阻是设备制造商在设计时添加的。

如何选择SPI和I2C

选择使用SPI(Serial Peripheral Interface)或I2C(Inter-Integrated Circuit)通信协议取决于具体的应用需求和系统设计考虑。以下是一些指导原则,可帮助你在SPI和I2C之间做出选择:

  • 带宽和速率需求:
    SPI: 通常具有较高的带宽,适用于高速数据传输。SPI的时钟频率可以很高,因此适用于对速率有较高要求的应用。
    I2C: I2C通常具有较低的带宽,适用于低速应用。其时钟频率较低,适用于相对较慢的设备之间的通信。
  • 设备数量和地址空间:
    SPI: SPI通常适用于连接较少数量的设备。每个设备都需要一个独立的片选线(Chip Select),因此连接大量设备时,SPI线数可能增加,从而增加系统复杂性。
    I2C: I2C使用地址进行设备寻址,支持连接多个设备到同一总线上。这使得I2C适用于连接大量设备的场景。
  • 电缆长度和物理层:
    SPI: 由于SPI信号线对电缆长度和物理层要求较为敏感,适用于相对短距离的连接,通常在同一个板上。
    I2C: I2C通信协议具有更好的抗干扰性,适用于较长的电缆连接,使其适用于跨越PCB、模块和设备的连接。
  • 硬件复杂性和引脚数量:
    SPI: SPI通常需要更多的引脚,因为每个设备需要一个片选线,且需要主设备负责时钟信号。
    I2C: I2C通常需要较少的引脚,因为它共享了时钟线,并且允许多个设备连接到同一总线上,减少了引脚的使用。
  • 功耗考虑:
    SPI: SPI在传输数据时可能会产生更高的功耗,因为通常需要主设备保持活跃并产生时钟信号。
    I2C: I2C通常在设备之间的传输期间较为节能,因为主设备不需要保持活跃,而设备只在需要传输时唤醒。
  • 设备支持:
    SPI: 某些设备可能更常见地支持SPI,特别是一些高性能的外围设备。
    I2C: I2C被广泛应用于连接传感器、存储器和其他外设,许多设备支持I2C通信。
  • 软件开发复杂性:
    SPI: SPI通常在软件实现上较为直观,易于理解和配置。
    I2C: I2C在设备寻址和协议方面可能稍微复杂一些,但在某些情况下提供了更多的灵活性。
    根据具体的项目需求和系统设计,选择SPI或I2C通信协议,考虑到带宽、设备数量、物理层、功耗和软件开发等方面的因素。很多情况下,这两者可以在不同的部分或设备上共存,以满足不同的通信需求。