给单片机编操作系统——第0篇

· · 科技·工程

:::align{right} 能给单片机编那么多东西,编操作系统也不过分吧?

第0篇——概述~和瞎扯~ :::

0.前言

0.前言

:::info{open} 将文章下载至本地,以获取最佳阅读体验:WinterOS(Password:0324)

作者:glen:

BiliBili:glengmr

E-mail:[email protected] :::

1.正文

对,你没看错。这是前言的正文,那既然你没看错,我们就开始吧。

先说说我是怎么有这个想法的:

2023 年的一个冬天,我发现基本所有能用单片机(整文特指 Arduino 及其系列产品)做到的东西都已经被做出来了,实在没有什么新奇的东西,于是我便想到了一个基本不可能用单片机做到的事——跑操作系统。

声明:本人在单片机方面见识短浅,文章中难免会有不足和错误之处,请各位大佬多多指教。

2.后记

对,你又没看错。这是前言的后记。

既然要做这么一个“项目”,那么就给她取个名字吧。就叫“Winter OS”吧,图标我还要再设计一段时间。

然后来说一下这个项目的大概内容:

Winter OS
├───Winter Kernel
│   ├───Winter Kernel for Arduino for Main_Controller
│   ├───Winter Kernel for Arduino for Graphics_Processor
│   ├───Winter Kernel for Arduino for Audio_Processor
│   ├───Winter Kernel for Arduino for Device_Manager
│   └───Winter Kernel for Arduino for Keyboard_Processor
└───Winter System
    └───Winter System for Arduino

也许你会问:为什么要搞这么多东西?

看完这篇文章,你一定会明白。

废话不多说,我们开始正文吧。

1.架构

1.软件

众所周知,操作系统(后文称“OS”)的启动是由 Bootloader 或 Bios/EFI 引导磁盘中 OS 文件,然后将整设备的硬件控制权限移交给 OS。但是单片机的 Bootloader 只会引导固件中的代码,并不能直接引导 SD(或外部储存器,如磁盘)中的 OS 文件,而且不管是固件代码还是外部储存文件,都不能直接控制整个单片机的 CPU、内存、电源等重要硬件。那么问题来了,要怎么才能使 Bootloader 引导 SD 中的文件并且把权限移交出来呢?

答案是:不要引导和权限了。我们用单片机的固件代码(后文称“内核”或“Kernel”)来处理 OS,如图 1.1-0:

我们来举一个最简单的 Kernel 的例子:

void setup () {
  pinMode ( 0 , OUTPUT ) ;
}
void loop () {
  digitalWrite ( 0 , HIGH ) ;
}

这个 Kernel 让第 0 个数字引脚设置成了高电平,是不是很简单?

那我们来个稍微难一点的(有亿点长):

先是 Kernel:

//Kernel(本段代码部分由AI生成):
#include <SPI.h>
#include <SD.h>
#include <String.h>
const int chipSelect = 4;  // SD卡模块的片选引脚
void setup() {
  Serial.begin(9600);
  Serial.println("Initializing SD card...");
  if (!SD.begin(chipSelect)) {
    Serial.println("SD card initialization failed!");
    return;
  }
  Serial.println("SD card initialized.");
  // 打开文件"command.pgm"
  File dataFile = SD.open("command.pgm");
  if (dataFile) {
    while (dataFile.available()) {
      // 读取一行直到遇到换行符
      String line = dataFile.readStringUntil('\n');
      // 打印行内容到串口
      Serial.print("Line: ");
      Serial.println(line);
      processLine(line);
    }
    dataFile.close();  // 关闭文件
  } else {
    Serial.println("File Error");
  }
}
void loop() {   // 空置(只需读取并执行一次)    }
// 行处理函数的示例
void processLine(String line) {
  if (line.startsWith("pinset")) {
    // 引脚控制指令
    int pin=(line[7]-'0');
    String str=line[7]+line[8]+line[9];
    for (int i = 0; i < str.length(); i++) {    //一个可控制0~999的引脚,绝对够用
        pin = pin * 10 + (str.charAt(i) - '0');
    }
    char mode=line[11];
    switch (mode){
      case 'L':
        pinMode(pin, OUTPUT);         //'L'命令为低电平,'H'命令为高电平
        digitalWrite(pin,LOW);
        break;
      case 'H':
        pinMode(pin, OUTPUT);
        digitalWrite(pin,HIGH);
        break;
    }
    return;
  }else if (line.startsWith(";")){      //';'是注释符
    return;
  }else {
    Serial.println("Unknown command");
  }
}

然后是 command.pgm:

; command.pgm:
; 这是”command.pgm”,在刚刚Kernel的行处理函数(void processLine(String line))中,我们已经定义了分号(';')为注释符

pinset 001 L;   ;1号引脚设为低电平
pinset 002 H;   ;2号引脚设为高电平
pinset 009 L;   ;9号引脚设为低电平

这段就是让 Kernel 执行 command.pgm 这个程序中的命令来控制引脚的模式。

由于我家里没有现成的 SD 模块,没办法实战。检查了代码,看起来应该没有问题。如果有问题的话请在评论区中指出。

是不是也很简单?我们还可以给它再嵌套亿层,大概理论如下:

Kernel 负责引导、控制、处理 OS(指处理 OS 所要进行的文件读写、计算、打印等),OS 来查找、处理程序(“查找”指的是按照文件后缀来选择合适的程序,“处理”指处理程序所要进行的文件读写、计算、打印等),程序来处理文件(指根据程序的功能来读写文件、识别文件内容等)。

完美无缺吧?

由于UP时间太少了,暑假还要上课,上完课还想玩原神,所以这里只写一个示例(图 1.1-1):

这张流程图是什么意思呢?

我们拿出亿些数据来举个栗子:

先说一说这个《图片》文件:

图片文件的第 1 行,即文件头:~IMG#2#2$

其中'~'表示这一行有数据;"IMG"表示这是一个图片文件;')'代表括号前的字符是系统定义符,如'\n''#'是数据分割符,表示前后是两个数据且数据类型(指数据的种类和意义)不同;两个'2'分别表示长 2 像素,宽 2 像素;'$'表示该行结束且下一行与该行数据类型(指数据的种类和意义)不同。

2 行,就是文件主数据:~255*255*255%0*0*0&

不难看出,这是图片第一行的两个像素的颜色,按R,G,B的顺序排列;其中'*'代表前一个数据与后一个数据是一组数据;'%'表示前一个(一组)数据和后一个(一组)数据的类型(指数据的种类和意义)相同;'&'表示该行结束,下一行与该行数据类型(指数据的种类和意义)相同。

3 行与第 2 行一样。

4 行,"FED"表示文件结束。

再来说说这个“程序”是如何处理文件的:

首先,程序读到了文件头~IMG#2#2$,判断这是一个图片,分辨率为 2\times2

然后,程序读到了文件主数据,即~255*255*255%0*0*0& \n ~127*127*127%155*155*155$,知道了这个图片文件的所有像素,那么程序现在就要向操作系统发出屏幕打印的请求了。按照文件给出的顺序和屏幕打印函数的格式,程序依次向操作系统发送了以下数据,直到读到"FED"

pgm.print(lotx=0,loty=0,type=pt,r=255,g=255,b=255);
pgm.print(lotx=0,loty=1,type=pt,r=0,g=0,b=0);
pgm.print(lotx=1,loty=0,type=pt,r=127,g=127,b=127);
pgm.print(lotx=1,loty=1,type=pt,r=155,g=155,b=155);

我们规定,"pgm.print"代表程序打印,即程序申请在允许的范围内打印;"lotx""loty"代表打印内容的坐标;"type"是打印内容类型,可以是"pt"(像素),也可以是"ic"(内置图标),或"tx"(文本)等等;后面的'r','g','b'是像素的颜色;如果要打印内置图标或文本的话,还要再后面加上icon=…text=…

然后,OS 读到了程序的申请,于是处理了程序发来的数据,再发给 Kernel。

通过程序的申请,OS 知道了每个像素的相对程序原点的位置和颜色。而程序原点 x 位置就是屏幕原点的位置加上程序的左边框宽度,程序原点 y 位置就是屏幕原点的位置加上程序的上边框宽度。我们现在的程序强制最大化,要不然处理起来比较麻烦。我们在这里规定:程序的左边框宽度 2 像素,上边框宽 10 像素,所以 OS 能得到图片所有像素的绝对位置为 (2,10),(3,10),(2,11),(3,11)。那么 OS 可以给 Kernel 发数据了。

我们规定:向 Kernel 发送的屏幕打印指令如下:

Function_Name:scrprt

Print something on the screen.This Command is sent to KERNEL.

Usage:
scrprt $(Value_Type) *(Value_Name) &(Value_Value) #(Value_Function);

Value_Type:[Available,There MUST BE VALUE(S)],
Allowed value(s):
spt,
sic,
stx;

Value_Name:[Disable,There must be "~NULL@)"];

Vaule_Value:[Available,There MUST BE A SET OF FORMATTED VALUES HERE],
The format of the value(s):
  ~@^[pos_x]@^[pos_y]@^[R]@^[G]@^[B]@^[icon]@^[text]@)
  Note:The "@^[icon]" and "@^[text]" MUST BE OMITTED when not in use.
Value_Function:[Disable,There must be "~NULL@)"];

\~插叙始\~

如果不出意外的话(我是说不出意外啊!),以后向 Kernel 的发送指令都将采用以下格式:

(Function_Name) $(Value_Type) *(Value_Name) &(Value_Value) #(Value_Function);

"Function_Name"必须是六个字符。

多余的我就不解释了,应该懂得都懂,看不懂的可以把上面那段放到翻译软件里。

顺带提一下:因为程序的代码、OS 的代码和 Kernel 对 OS 的兼容层有亿点复杂,所以这些东西我们下篇再写吧。

说一下我写“屏幕打印指令说明”时为什么要用英文。

可以看出来,这个“说明”的格式很“规整”,那么也不难猜到,这个“说明”就是—— 要放在系统里的说明

我们可以看到,在现在的操作系统中,“终端”里的命令都有说明,打开你的终端,输入exit /?(Windows)或apt --help(Linux)都能看到这些说明。

那么,我们的操作系统的“终端”里的命令也要有说明。

而且,用 Arduino 在屏幕上打印中文是一件很难的事,所以,我们干脆用英文得了。

对了,你没听错。在我们的操作系统中,这些给 OS 用于发送给 Kernel 的命令,也可以直接用在“终端”里。

好了,多的不说,我们继续。

\~插叙终\~

现在,Kernel 接收到了 OS 的指令,那么 Kernel 如何把这些指令让 Arduino 执行呢?

很简单,直接对要给 Arduino 烧录的 Kernel 加几句话不就对了:

//Kernel(本段代码全部由UP所写,故有可能有错误,望谅解):

#include <SPI.h>
#include <U8g2_for_Adafruit_GFX.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
{...}    //其他
Adafruit_ST7735 tft = Adafruit_ST7735(9, 10, 8);
U8G2_FOR_ADAFRUIT_GFX u8g2_for_adafruit_gfx;
void setup(){  //初始化函数
   {...}    //一些其他代码
   tft.initR(INITR_18GREENTAB);
   tft.fillScreen(ST7735_BLACK);
   u8g2_for_adafruit_gfx.begin(tft);
   {...}    //一些其他代码
}

void loop(){   //主函数
   {...}    //一些其他代码
   readCommand();
   {...}    //一些其他代码
}

{...}    //一些其他代码

void readCommand(){
   {...}    //读取指令的其他代码,其中读到的指令的变量名为:"command",类型为String。
   processCommand(command);
   {...}    //其他代码
}

void processCommand(String c){
   //处理指令:
   String name=c.substr(0,6);
   c.erase(0,7);  //删除像"scrprt "这类的字符串,注意此次删除了空格,后文也有这种情况,不再说明
   switch (name){
       case "scrprt":
       //读取数值:
           c.erase(0,1);  //删除'$'字符
           String type=c.substr(0,3);
           c.erase(0,4);  //删除"spt "或"sic ","stx "字符串
           c.erase(0,9);  //删除"*~NULL@) "
           //处理屏幕打印核心部分,参考  ~@^[pos_x]@^[pos_y]@^[R]@^[G]@^[B]@^[icon]@^[text]@)
           int tmp=c.find_first_of(' ');
           String value=c.substr(0,tmp);
           int pos=1;  //用来标记现在读的值的意义,如[pos_x]对应的值为1,[G]对应的值为4,如上
           int px,py,r,g,b; String tx;    //所有的值
           int ic;    //现在先不对内置图标做处理,放到以后再说
           value.erase(0,1)   //删除开头的'~'
           while (pos<8){
               value.erase(0,2);  //删除分隔符"@^"
               int p=c.find_first_of('@');
               String tmp2=c.substr(0,p);
               c.erase(0,p);
               switch (pos){
                   case 1:
                       int num=0;
                       for (int i=0;i<tmp2.length();i++){    //我知道把"for"放进"case"有些啰嗦,但是基本不影响时间复杂度,甚至在"case 6"和"case 7"里还能省时间
                           num=num*10+(tmp2.charAt(i)-'0');
                       }
                       px=num;
                       pos++;
                       break;
                   case 2:
                       int num=0;
                       for (int i=0;i<tmp2.length();i++){
                           num=num*10+(tmp2.charAt(i)-'0');
                       }
                       py=num;
                       pos++;
                       break;
                   case 3:
                       int num=0;
                       for (int i=0;i<tmp2.length();i++){
                           num=num*10+(tmp2.charAt(i)-'0');
                       }
                       r=num;
                       pos++;
                       break;
                   case 4:
                       int num=0;
                       for (int i=0;i<tmp2.length();i++){
                           num=num*10+(tmp2.charAt(i)-'0');
                       }
                       g=num;
                       pos++;
                       break;
                   case 5:
                       int num=0;
                       for (int i=0;i<tmp2.length();i++){
                           num=num*10+(tmp2.charAt(i)-'0');
                       }
                       b=num;
                       pos++;
                       break;
                   case 6:
                       ic=0;
                       pos++;
                       break;
                   case 7:
                       tx=tmp2;
                       pos++;
                       break;
                   default:   break;
               }
           }
           //判断并打印:
           if (type=="spt"){
               tft.drawPixel(px,py,(r*256+g*8+b/8));  //画点,其中颜色的值的计算公式如下:
                              //int COLOR=(int(RED)*256)+(int(GREEN)*8)+int(int(BULE)/8);
                                              //我也不知道为什么要这样算,很抽象的算法
           }
           if (type=="stx"){
               u8g2_for_adafruit_gfx.setFont(u8g2_font_timR08_tf);//设置英文字体为TimesNewRoman
               u8g2_for_adafruit_gfx.setCursor(px, py);           //设置光标位置
               u8g2_for_adafruit_gfx.setForegroundColor((r*256+g*8+b/8));//设置颜色
               u8g2_for_adafruit_gfx.print(tx);               //打印文本
           }
           if (type=="sic"){
               //现在不处理内置图标的打印任务
           }
           break;
       //处理来自OS的打印请求的部分结束
       case {...}  //处理其他来自OS的请求
       {...}
   }  //最外面的"switch"结束
   {...}  // "processCommand"函数的其他内容
}  //"processCommand"函数结束

{...}  //Kernel的其他部分

//Kernel结束

你看,这样我们就成功的做出了 Winter OS 的大概软件架构。

当然,我知道这里 Kernel 的处理“scrprt”的方式带过于慢了,所以后面会尽量会将参数改成二进制,但格式基本不变。

现在你知道文章前言里的“Winter OS的内容”为何要分“Winter Kernel”和“Winter System”了吧。

可以说,Winter Kernel 是真正执行命令的地方,程序则是发起命令的地方,而 Winter System 是 Winter Kernel 与程序之间的“转译层”,是将一个复杂命令转化成多条简单命令的地方。

我们举完例子了,相信大家都能理解。如果这部分有任何问题,请毫不保留的在评论区~

哇……软件架构是真的难写,我从开始写这篇文章到现在写到这里已经快一个月过去了。(这小段抱怨是我在本地写的时候抱怨的。我是先在本地写文章,然后存成 PDF 放在蓝奏云上,再复制到 B 站和你谷上,但是在你谷还要再整一遍 MarkDown 和 LaTeX。MarkDown 是真难整啊啊啊服了 QwQ)

终于能到下一段了啊啊啊啊啊啊啊啊……

2. 硬件

软件架构说完了,我们来简单聊聊硬件架构。

我自己想了半学期,觉得如果一个 Arduino 想要担起处理整个硬件需求的重担,那是肯定不行的。因为 Arduino 自身本来就没有那么多硬件,没有显卡,没有声卡,没有电源管理器,它除了有 CPU、内存和储存外,其他什么也没有,就连它自身的那点储存还只能用来存内核代码,放不了操作系统文件。

所以,硬件架构不可能只有一块 Arduino,而是把多个 Arduino 拼起来,组成一个计算机。

那么该如何“拼”呢?

来看图 1.2-0:

这张图很好的解释了我们的硬件架构,其中,立方体代表该部件至少需要一个 Arduino,连接线代表数据的传输方向。

这些东西我们放在下一篇文章,因为东西实在有亿点多。

其中我们得说一下电源控制器的结构,如图 1.2-1:

说明一下,这张电路图中的 GC1 和 GC2 分别是 Main_Controller(以后简称“MNCL”)和 Device_Manager(以后简称“DMR”),它们在此的作用是保持 Q3 的基极为高电平,以确保整个计算机通电, Q3 是控制整个计算机电源的重要控件,它的基极为高电平时,整个计算机才可能被通电。

那么,如何让 Q3 的基极由低电平变成高电平呢?

这里采用的方法是——按钮。用户按下 S2 后,来自 DC(前提是 SPDT1 的开关拨到上面)或 C1(前提是 SPDT1 的开关拨到下面)的电流会流入 Q3 基极,而且 Q3 集电极已经有来自 DC 或 C1 的电流了,所以 Q3 发射极有电流,然后 GC1(MNCL)和 GC2(DMR)被通电,DMR 被通电后干的第一件事就是给 Q3 基极持续通高电平,这样就能维持计算机的开机状态。如果想关机,那么 MNCL 给 DMR 一个信号,然后 DMR 停止 Q3 基极的高电平,整个计算机就断电了。

但是,有个问题:万一 DMR 有延迟怎么办?

很简单,在电路中加一个法拉电容,用几个三极管控制它,使它在 S2 按下时放电给 DMR ,直到放完电时锁止它,使它充电,直到下一次 S2 按下。

还有,DC 是充电器,C1 是内置电池,D6,D7,D8 是LED灯,用来指示当前电源模式,电源模式有两个,第一个是充电器只给计算机供电,不给电池供电,对应 SPDT1 拨到上面;第二个是充电器给计算机供电,又给电池供电,对应 SPDT1 拨到下面,但是,如果想真正更改电源模式,必须接入充电器。

完美啊~

好了,硬件架构部分我们先聊到这,其他的下一篇我们来详细说。

2.后记

这篇是我们“给单片机编操作系统”的开篇,别看这短短几页,它浓缩了我在校两个学期,至少一半对于这个“项目”的思考、学习、探索和内耗。就如前言中说的,我从 2022 年冬就有这个想法,我于 2023 年 6 月小学毕业,于 8 月升入初中,然后在 2023 年冬决定正式开始的搞这个东西,在纸上和脑子里构建了第一个框架,但是,过了半年,我发现当时想的方案和方向根本不可行,于是改版,就有了你们现在在前文“架构”里看到的东西。这个东西写出来基本不费时间和精力,但是要把它想出来,基本是比登天还难。自从我改版以来,就不断的看现代计算机的各种构造和设计,来触发我的灵感。要想想出来这种东西,有时候真的只能靠“灵感”了,我有时几天都想不出来的东西,一天早上骑车去学校时突然就想到了解决方案,就是你们看到的那个“向 Kernel 的发送指令的格式”,这东西是真的搞人啊,比喻一下,如果我想这些东西时,每想出来一个“灵感点”,就要薅掉一缕头发,那么我的头现在已经能当反光镜了。而且,这个“项目”只有我一人在搞,你们看到的封面图片、各种配色、图片的内容设计、代码和伪代码、流程图等等,除了有极少一部分的简弹代码由 AI 写,其他全是我写、想或者画的,这不仅考验一个人的思维,还有他的审美、文笔、耐心、专业知识等等,对了,还有打字速度和视力。我觉得我能去干这个事,只有一点:热爱。

最后,附几张我的“手稿”吧

好了,以上就是本篇的全部内容了,如果你对这个“项目”感兴趣,不妨在 B 站给我留下你的三连和关注,我们下一篇再见吧~