rpi4-osdev-学习开发日志-2

前言

大伙大概还没忘掉我之前说的话吧?有些那种一眼就知道怎么一回事或者那种只是单纯的接个线配置点东西看点成果的我一概不会理会的,也不会去写,没那个必要。

那么,把代码编译一下吧。

但是问题在于你用什么编译?怎么个编译法子?
原文上来就介绍makefile是个啥以及教你写makefile,我们暂且不用理会,先来解决一个要紧的问题:拿什么编译。

当然是编译成arm平台可以理解的东西了。所以你用电脑上的gcc大概是不太ok的,而且就算编译出来了,搞出来一堆elf也是linux形状的,很有可能树莓派是不能够理解的。一般来说他们会搞出一堆神奇的ELF,这是为了在其他OS上运行该段代码,那如果我们没有OS,那么有些信息我们自然就需要删去。objcopy就可以帮我们实现这一点。

arm架构官网提供了编译工具链,下载并解压至wsl中你熟悉的位置即可。

接着我们来看一下makefile。至于如何去理解makefile,我推荐如下的一些材料,在这里我觉得没有任何必要做细致的讲解。

于仕琪老师的makefile简介:https://www.bilibili.com/video/BV188411L7d2
阮一峰blog中的一期(里边也有参考文档,可以顺着看下去):http://ruanyifeng.com/blog/2015/02/make.html

项目所给出的makefile文件为:

CFILES = $(wildcard *.c)
OFILES = $(CFILES:.c=.o)
GCCFLAGS = -Wall -O2 -ffreestanding -nostdinc -nostdlib -nostartfiles
GCCPATH = ../../gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/bin

# 先说好,这里的GCCPATH要明确的指向你所解压的地方,这是这个文档中唯一一个需要修改的地方。

all: clean kernel8.img

boot.o: boot.S
	$(GCCPATH)/aarch64-none-elf-gcc $(GCCFLAGS) -c boot.S -o boot.o

%.o: %.c
	$(GCCPATH)/aarch64-none-elf-gcc $(GCCFLAGS) -c $< -o $@

kernel8.img: boot.o $(OFILES)
	$(GCCPATH)/aarch64-none-elf-ld -nostdlib boot.o $(OFILES) -T link.ld -o kernel8.elf
	$(GCCPATH)/aarch64-none-elf-objcopy -O binary kernel8.elf kernel8.img

clean:
	/bin/rm kernel8.elf *.o *.img > /dev/null 2> /dev/null || true

我觉得makefile最为神奇的设计就是依据一些meta数据来推算何种指令需要重新运算。当任何依赖文件的修改时间都发生了变动,使得其比目标文件更新时,则重新执行相应的某些指令。当然啦,这个思想自然是非常的简单,无论是理解还是实现起来都相当的简单,但我在思考makefile如何实现的时候第一时间竟然没有往这个方面想,这实在是利用最简单的方法与最简单的路径实现了一个相当神奇的功能。

然后你将会获得一个kernel8.img的神奇文件。你的SD卡如果之前已经装入了raspberry Pi OS的话,那么如果使用读卡器将这张SD卡读入你的开发机中,恭喜你你会发现有一个无法打开的盘和一个只有两百多兆的小盘。前者其实是已经被写为其他格式的文件系统了,所以正常来说NTFS是打不开这玩意的;后者则是可以正常读写的设置盘。你只需要把你写的这个kernel8.img复制粘贴到这个设置盘里就可以了。但是。。。。

奇怪。。。?

按道理来说如果我们彻底手搓一个os的话,我们的SD卡应该是完全干净的,里边啥也没有才对。但为什么教程中提及到“不要动树莓派官方os的任何文件,除了kernel8.img”呢?

事实上我们的存储卡在这里已经做了初始化操作,这里就开始分bootloader和bootstrap这两者的区别了。前者更加本源,且如果真的要写的话还要翻查树莓派官网相关信息去写,得不偿失,不如直接用官方给我们的。这点知识点不如自己事后看看图一乐比较好。而后者我们确确实实需要自己手动写一写。

当然,貌似在某些操作系统开发的过程中是不怎么区分bootloader和bootstrap的,故这里只针对arm平台的linux系统。
bootloader和bootstrap的区别:https://blog.csdn.net/gaoxuelin/article/details/9624677

树莓派bootloader以及上电之后干什么的:https://blog.csdn.net/suo_guang/article/details/81395410

好了,该上电了!

哈哈,啥也没有。你只会发现突然出现了一个大树莓,检查了一堆玩意之后你就进入了黑屏,啥也没有。这就是我刚刚说的bootloader和bootstrap是分开的,前面运行bootloader,输出一堆玩意,之后就交给bootstrap,完事之后进入系统,就可惜发现系统啥也没干,就在那while (1);了。

起码也得有个hello world吧。

ok,那么我们来写一下屏幕驱动吧!
开玩笑的。这玩意要等到后边搞明白很多东西之后再搞明白的玩意。
原文的说法就是,我们先考虑让树莓派给dev machine发个信号(这里指的是发一个字符串“hello world”)的方式,来告诉大伙我们的系统至少第一步没问题。

我们要通过UART来通信。这玩意具体是干什么的大可以不用管,可以理解成为一种通信方式,很古老的那种。然后通过一根线传到电脑上,然后解析出字符串出来。

注意,这里就需要我们在上一章所需要的那些东西了,就是那个usb转ttl的线了。这玩意在淘宝上是可以轻松搜到的,大概十几块钱一根,直接搜树莓派usb转ttl即可。记着要买那种兼容win10的(假设你的系统是win10的),因为官方给的链接最高只支持到win8。

理论上来说是不需要安装驱动的。当然若有任何问题我也不好说(至少我买的这根线插上之后就没有出现需要手动安装驱动的情况)。

之后就是喜闻乐见的查看COM端口的环节了。去控制面板 -> 设备控制器,找找有没有什么叫端口啥的的选项卡,如果有则记下Prolific USB-to-Serial Comm Port的COM号是多少,如果没有,点击选项卡中的显示选项,把那些隐藏的也拖出来,看看是不是藏在那里了。

接下来就是接线部分。这一部分也请各位自行前往https://github.com/isometimes/rpi4-osdev/tree/master/part3-helloworld 自行查看并接线,并把该改的东西改好。

我们接下来着重要讲的,是最为重要的玩意:io.cio.h

IO代码的编写

这一块足以让我单开一个h2标题来讲述内部的细节。原文中需要新建的那几个文件以及相关的修改啥的我在这里并不准备赘述一番,我主要想理解一下这个io.c这个代码的含义为何。

当然,在理解代码之前自然可以先把代码啥的全部抄一遍然后编译一遍,并用上一次我们提到的那个方法把kernel8.img给替换掉,然后重启一下机器(记着先连接电脑与putty哦!)。不出意外的话电脑这边是可以接受到hello world的。

原文其实只对其中一部分代码进行了简单的介绍,在part4中其实还补了一些代码,做了功能的扩充,即开发机也可以向树莓派发送一些信息,但这一块代码并没有给出解释,原文的说法是:“我希望这个不是一篇解释文档,而是一份真正的教程”。那没办法了,这里我会给出我的理解的。

Memory-Mapped I/O

还记得你们在学微机原理 || 计算机组成原理 || 操作系统或是别的某些课上学的那个叫什么I/O端口及其编址嘛?没错,Memory-Mapped I/O就是这玩意。有可能你会觉得这玩意和书上写的不太一样,但这不重要。我们看看wiki咋写的吧。(中文版本)

摘自:https://zh.wikipedia.org/wiki/存储器映射输入输出

内存映射输入输出(英语:Memory-mapped I/O, MMI/O,简称为内存映射I/O),以及端口映射输入输出(port-mapped I/O, PMI/O,也叫作独立输入输出(isolated I/O),是PC机在中央处理器(CPU)和外部设备之间执行输入输出操作的两种方法,这两种方法互为补充。除此之外,执行输入输出操作也可以使用专用输入输出处理器(dedicated I/O processors)——这通常是指大型机上的通道输入输出(Channel I/O),这些专用处理器执行自有的指令集。
内存映射I/O(不要和内存映射文件的输入输出混淆)使用相同的地址总线来寻址内存和输入输出设备(简称I/O设备),前提是I/O设备上的设备内存和寄存器都已经被映射到内存空间的某个地址。这样当CPU访问某个地址的时候,可能是要访问某一部分物理内存,也可能是要访问I/O设备上的内存。因此,设备内存也可以通过内存访问指令来完成读写。每个I/O设备监测CPU的地址总线,并且在发现CPU访问被分配到本设备的地址区域的时候做出响应,创建数据总线和相应设备寄存器之间的连接。为了实现CPU对MMI/O设备的访问,相应的地址空间必须给这些设备保留, 并且不能再分配给系统物理内存。这可以是永久保留,也可以是暂时性的保留。通常来说X86架构都是永久保留的,而在Commodore 64中,由于采用了I/O设备和普通内存之间的堆交换技术(bank switching),可以做到暂时性保留。
PMI/O通常使用一组专门为I/O设计的CPU指令来执行I/O操作。比如在基于x86和x86-64架构的微处理器中使用in/out指令。这两条指令有一些不同的形式,分别用来在CPU的EAX寄存器(或高16位/低16位/高8位/低8位)和I/O设备的某个端口之间完成对单字节/双字节/四字节数据的操作(比如对out指令,分别有outb, outw和outl) 。I/O设备有一个和内存地址空间相互独立的I/O地址空间。I/O设备通过专用I/O针脚或者专用的总线和CPU相连。因为这个I/O地址空间和内存地址空间相互独立,所以有时候称为独立I/O.

差不多就是,我们只要把指令按照手册内给出的要求把数据放在相应的内存映射区,相关的外设自然会“感应到”这个,并做出回应。至于原文剩下的那一小块我认为是更为细节的部分,其实大可不必现在就要搞得一清二白。具体的话可以翻阅手册:https://www.raspberrypi.org/documentation/hardware/raspberrypi/bcm2711/rpi_DATA_2711_1p0.pdf

请注意上述链接会下载一个pdf文件,放心下载即可。

看看相关代码:

void mmio_write(long reg, unsigned int val) { *(volatile unsigned int *)reg = val; } // 这里volatile就保证了reg地址不会被优化掉。相当于先把long数字改成地址类型,并对其进行解引用。下边那个也是同理。还是很好理解的对吧?
unsigned int mmio_read(long reg) { return *(volatile unsigned int *)reg; }

GPIO

那么拥有了上述知识之后就可以愉快的去做剩下的事情了。原文给出的说法是让我们自行去探索原文档,那我就代劳了,我来摘抄下来一些这个项目需要的点。

整个GPIO有特别多的寄存器,而且任何寄存器都是32位的,由于访问GPIO的这些寄存器也是基于Memory-Mapped I/O,所以我们要给出一个基址:0x7e200000。

我们要做的就是把我们需要设置的寄存器给好好的设置一下即可。

官方文档说明的是GPIO有58个引脚,我稍微查了点资料,看到的基本都是在讨论40个引脚的,实在不清楚到底是啥意思。不过我猜网上说的是引脚数,而文档里实际指的是包括所谓“虚拟的”或是板载的别的接口的所有接口加起来58个,我猜大概不会影响接下来的理解。

问了一下Bing的AI,给出的答案看起来八九不离十:根据网络搜索结果,树莓派4B的GPIO口有40个,其中28个是可编程的GPIO,其余12个是电源类。GPIO口的编号有不同的规则,比如BCM编号和BOARD编号。你想知道为什么有58个GPIO口吗?可能是因为你看到了BCM编号中的GPIO58,但这并不代表实际的物理引脚,而是一种逻辑编号。
参考文献:
https://blog.csdn.net/Naisu_kun/article/details/105053222
https://blog.csdn.net/qq_34598061/article/details/123152651
https://blog.csdn.net/ksjz123/article/details/111641485

那么接下来就是大概的解读一下每个GPIO寄存器的含义为何,其中着重介绍一下待会会用到的GPFSEL0, GPSET0, GPCLR0 以及 GPPUPPDN0

注意:基本方法论就是对32位的某几位定义一下其含义为何,就跟课上讲的什么中断寄存器什么的一样。

GPFSEL0~GPFSEL5

相当于设置了一下每个引脚具体的工作是什么。每个引脚可以有如下功能:作为输入,作为输出,以及承担六个功能的其中一种。相当于一个引脚其实可以有八个含义,具体可以拿来干什么可以查阅相关章节,以及参考你需要实现的功能。
注意到文档中写明了这些部分是可读可写的。

GPSET0 & GPSET1 、GPCLR0 & GPCLR1

非常的浅显易懂。不过,这里的0与1和上边那个类似,是指的第一种SET指令与第二种SET指令,而并非设置为0或者设置为1。由于只有三十二位,很自然的可以想到SET0指的就是前三十二个引脚置为1,SET1就是将32~57位置为1,CLR作为clear的缩写与上述一致,只是这次意思是置为0。
请注意,这一次文档中明确说了这玩意只写。那么如何读每个引脚的状态呢?

GPLEV0 & GPLEV1

这俩寄存器就干这件事的。

由于大部分都是带上结尾处标号的,我们接下来的指令除非特殊声明,都是只包含两个且0对应前32位,1对应其余的。

GPEDS & GPREN & GPFEN & GPHEN & GPAREN & GPLEN & GPAFEN

这些都是检测用的寄存器,没太看懂不过这次也确实没用到。

GPIO_PUP_PDN_CNTRL_REG1~4

设置每个引脚的状态。有四个状态,分别对应00:无状态;01:上拉电阻(即无输入时保持高电平,有输入时正常输入即可);10:下拉电阻(同上,不过是反过来的),以及一个保留状态11。
由于每一位占了两个bit,那自然是有四个寄存器来解决这一切。

写个函数设置一下吧!

unsigned int gpio_call(unsigned int pin_number, unsigned int value, unsigned int base, unsigned int field_size, unsigned int field_max) {
    unsigned int field_mask = (1 << field_size) - 1;
    if (pin_number > field_max) return 0;
    if (value > field_mask) return 0;
    unsigned int num_fields = 32 / field_size;
    unsigned int reg = base + ((pin_number / num_fields) * 4);
    unsigned int shift = (pin_number % num_fields) * field_size; 
    unsigned int curval = mmio_read(reg);
    curval &= ~(field_mask << shift);
    curval |= value << shift;
    mmio_write(reg, curval);
    return 1;
}

这个是基函数。也就是说我们先把设置register的函数写好,然后根据我们的需要写对应的设置函数。

返回的大概是是否成功的结果,我猜的。但具体实现方式有点迷,说实在的,不过按我的理解就是把相应的register给写成我们所需要的,这样引脚就可以按我们的理解工作了。

剩下的几个函数倒没什么可以讲解的。

UART

这部分我个人的理解就是和USB传输有点像。当然具体工作原理我们依旧不需要知道太多,只需要知道我们现在的确可以通过某些方式让引脚做好准备,传送出我们的第一批数据。

我相信大伙如果已经搞明白了GPIO的那套玩意之后,UART这部分的文档可以说也是差不多的。观看文档的那一部分即可。接下来就是简单的代码讲解环节。

void uart_init() {
    mmio_write(AUX_ENABLES, 1); //enable UART1
    mmio_write(AUX_MU_IER_REG, 0);
    mmio_write(AUX_MU_CNTL_REG, 0);
    mmio_write(AUX_MU_LCR_REG, 3); //8 bits
    mmio_write(AUX_MU_MCR_REG, 0);
    mmio_write(AUX_MU_IER_REG, 0);
    mmio_write(AUX_MU_IIR_REG, 0xC6); //disable interrupts
    mmio_write(AUX_MU_BAUD_REG, AUX_MU_BAUD(115200));
    gpio_useAsAlt5(14);
    gpio_useAsAlt5(15);
    mmio_write(AUX_MU_CNTL_REG, 3); //enable RX/TX
}//按照官方文档的说法去配置好UART部分,并把引脚的工作模式给搞好。
unsigned int uart_isWriteByteReady() { return mmio_read(AUX_MU_LSR_REG) & 0x20; }

void uart_writeByteBlockingActual(unsigned char ch) {
	//单纯的做单一字符传送。
    while (!uart_isWriteByteReady());
    mmio_write(AUX_MU_IO_REG, (unsigned int)ch);
}
void uart_writeText(char *buffer) {
	//这种串行通信自然考虑单个字符单个字符传过去。
    while (*buffer) {
       if (*buffer == '\n') uart_writeByteBlockingActual('\r');
       uart_writeByteBlockingActual(*buffer++);
    }
}

extra code

没错,在part4的文件中,其实是有一部分未被讲解与列出的代码的。这一块确实涉及到了UART里更多的内容,但依旧是那句话,基本上如果按照我读GPIO的思路去读UART部分的文档的话,其实是非常简单的。

实际上extra code对于上一个版本的那个只能输出字符串的代码改动的还是非常大的。首先这一次就不在考虑直接使用RAW OUTPUT的方法来处理输入问题了,这一点其实还是合理的:因为这次既有输入也有输出了。你不知道到底会接受多少字符串,所以一个不错的思路自然是开辟一块缓冲区,然后面向缓冲区处理UART问题。
(我倒是突然想知道我写裸机代码,SoC怎么知道我想干啥之类的,突然想到有个玩意叫链接器和ld script,他帮忙把这块的活给干了)

我其实有点不太理解AUX_MU_LSR_REG第五位和第六位的区别在哪。发送器闲置空转和至少可以发送1Byte的区别在哪?具体相关疑问也许未来会做做实验。

接下来会按照我个人的理解来解读这些代码:

缓冲区长啥样?

当然是队列啦。稍微机灵一点的话大伙肯定会自然地想到这玩意肯定是循环队列,要不然不就完犊子了嘛。

学完408或者应试版本的数据结构的大伙肯定非常的激动。哇,那肯定要有判空和判满条件吧!事实上判满我觉得很没必要:因为这是字符输出,不是什么很要紧的事情(或者换句话说,这玩意实现出来可以但很没必要),唯一需要搞到手的就是判空条件,即head == rear。extra code 中的确就是这么干的:

unsigned int uart_isOutputQueueEmpty(){
    return uart_output_queue_read == uart_output_queue_write;
}

那么如何搞循环下标这玩意呢?一定会有人搞出所谓取余的做法。但个人认为原文这种做法更快更巧妙:

uart_output_queue_read = (uart_output_queue_read + 1) & (UART_MAX_QUEUE - 1);
unsigned int next = (uart_output_queue_write + 1) & (UART_MAX_QUEUE - 1);

同样起到效果。

RAW Input & output

说白了就是真的去写入或读出相关寄存器内的值。

unsigned char uart_readByte(){
    while(!uart_isReadByteReady());
    return (unsigned char) mmio_read(AUX_MU_IO_REG);
}

void uart_writeByteBlockingActual(unsigned char ch) {
    while (!uart_isWriteByteReady());
    mmio_write(AUX_MU_IO_REG, (unsigned int)ch);
}

当然没啥问题。这也是我们输入输出的最后一步。

面向缓冲区的IO代码

首当其冲便是清空缓冲区内的待输出字符:

void uart_loadOutputFifo(){
    while (!uart_isOutputQueueEmpty() && uart_isWriteByteReady()) {
        uart_writeByteBlockingActual(uart_output_queue[uart_output_queue_read]);
        uart_output_queue_read = (uart_output_queue_read + 1) & (UART_MAX_QUEUE - 1); // Don't overrun
    }
}

好像不合理啊?为啥只是简单的判断了一下是否两个指针是否相等就想都不想开始输出呢?甚至没有管到底哪个是头哪个是尾。
我个人理解就是,这块代码并不负责行为含义,仅负责把这个行为执行下去。所以只需简单的判断一下边界情况就直接执行了,这个类似于中间层

其次就是接受函数。这里的接收函数就很明显不是那种不负责行为含义的代码了。他要保证每次写入新东西的时候要先把缓冲区给清理干净。这里也就相当于定义了数据结构的头与尾,write为尾,read为头。
具体代码为:

void uart_writeByteBlocking(unsigned char ch){
    unsigned int next = (uart_output_queue_write + 1) & (UART_MAX_QUEUE - 1);
    while(next == uart_output_queue_read) uart_loadOutputFifo();
    uart_output_queue[uart_output_queue_write] = ch;
    uart_output_queue_write = next;
}

貌似采用的是和c++里cin一个思路的方法。清干净缓冲区,然后往里写数据。

之后就是对于原版writeText的修改,也是最顶层的应用函数:

void uart_writeText(char *buffer) {
    while (*buffer) {
       if (*buffer == '\n') uart_writeByteBlocking('\r');
       uart_writeByteBlocking(*buffer++);
    }
}

接着便是uart刷新函数了。这个所谓uart_update函数其实也是应用层函数,所以你想干啥也是自己掂量掂量做的。原文就单纯把你输入的玩意全给打回去,自然是可以的啦。

这个uart_drainOutputQueue真的确定不是无用函数?我估计是原文作者写完之后也不知道这玩意能干啥又不想删就干脆放在这以备未来不时之需。

自此,IO部分的最简单的代码终于学习完毕。

posted @ 2023-04-05 15:53  Levia_than_www  阅读(127)  评论(0)    收藏  举报