无题
无题
Joshua使用u-boot+linux kernel+busybox实现一个嵌入式小系统
嵌入式作业,要求我们实现
- 使用QEMU模拟一个嵌入式开发板;
- 使用交叉编译器,为虚拟平台编译u-boot,并进入下载模式熟悉简单命令;
- 使用交叉编译器,为虚拟平台编译内核kernel;
- 为虚拟平台创建根文件系统(借助busybox),并启动平台系统,编写简单的hello world程序,传输到嵌入式平台(可放入根文件系统),运行获得结果。
- 完成虚拟字符驱动的编写,并加载到内核,获得实验结果;
接下来的实验中,我选择模拟vexpress_ca9x4开发板。
编译和安装
arm编译器
首先要想跨平台安装,就需要安装对应平台的编译链工具。在这里我们选择使用最广的arm架构下的gcc进行编译。
在arch平台下使用下面命令进行安装,一定要按下面的顺序安装
yay -Sy arm-linux-gnueabihf-gcc-stage1 |
其它平台自行搜索对应wiki。
这里提醒一点,出于安全角度考虑,下面的所有手动编译过程中,都不建议使用root身份进行。
u-boot
首先去github搜索u-boot,用git把它拉到本地
git clone --depth=1 https://github.com/u-boot/u-boot.git |
进入u-boot的源码目录下面,执行命令
make vexpress_ca9x4_defconfig |
vexpress_ca9x4_defconfig是u-boot专门为Qemu模拟vexpress_ca9x4提供的编译配置,make命令执行完以后,会在当前目录下生产.config文件,这个文件包含了构建u-boot所需要的所有配置信息。
接着开始正式编译u-boot,在此我们要指定我们是跨平台编译,并指定编译工具链。下面命令中的nproc
会返回当前电脑最多可用的线程数,配合-j
参数可以让编译器利用当前机器最多的线程来编译,否则默认是启动单线程。在我的电脑上将会启动8个线程一起来编译。
export ARCH=arm |
编译完成后,目录下会出现二进制文件u-boot
Linux内核编译
Linux内核方面我们选择今年最新最热的lts版本linux6.6,首先去github上面把6.6的源码下载下来并将其解压
wget -O linux-kernel-6.6.tar.gz 'https://github.com/torvalds/linux/archive/refs/tags/v6.6.tar.gz' && tar -xzvf linux-kernel-6.6.tar.gz && rm linux-kernel-6.6.tar.gz |
接下来进入目录,进行编译前的配置
我们先设置两个环境变量,使得在当前shell中的所有make操作,make能自动将这两个参数在运行时设置,而不需要每次make都要特别指定。
export ARCH=arm |
向上面u-boot中一样,我们使用下面的命令生成一个包含默认配置的.config
文件给下面的正式编译使用
make vexpress_defconfig |
make vexpress_defconfig
命令在Linux内核编译过程中用于生成默认的内核配置文件.config
。这个命令会根据指定的架构(通过ARCH
环境变量)和目标平台,从内核源代码中的arch/<architecture>/configs/
目录下拷贝一个默认的配置文件到源代码的根目录,并重命名为.config
。例如,如果执行的是
make vexpress_defconfig
那么这个命令会将arch/arm/configs/verxpress_defconfig
文件拷贝到源代码的根目录,并重命名为.config
。
.config
文件包含了大量的配置选项,这些选项定义了内核的功能和行为。每个选项都有一个默认的值,这个默认的值是为了适应大多数的使用情况而设置的。通过make defconfig
生成的.config
文件就包含了这些默认的值。如果需要修改这些配置选项,可以使用
make menuconfig
进入一个图形界面进行配置,或者直接编辑.config
文件。
在这里我们没有特殊的需求,所以直接使用默认的.config
文件。
配置完配置文件,我们就可以编译内核了,由于嵌入式设备的空间一般比较紧张,我们这里编译一个使用gzip压缩的版本。和上文一样直接使用最多的线程来编译
make -j$(nproc) zImage |
编译好的文件为linux-6.6/arch/arm/boot/zImage
,接着再编译一些固件
make -j$(nproc) modules |
由于我们模拟的是嵌入式设备,不像PC可以自动检测硬件设备,所以我们还要编译对应平台的DdeviceTreeBlob (DTB 设备树)来向内核标识可用的设备。
make -j$(nproc) dtbs |
我们将要模拟的开发板的dtb在编译好之后,文件位于linux-6.6/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dtb
busybox编译
与上面的过程没有什么区别,但需要注意的是编译的时候需要额外配置静态编译,因为嵌入式系统环境中的动态库可能不像PC那么全。
- 输入
make defconfig
生成一个默认的配置文件 - 输入
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig
进入配置页面 - 在
Settings
->--- Build Options
选择Build static binary (no shard libs)
- 退出保存
然后就是开始编译。
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc) |
最后我们把所有编译好之后的所有有用文件汇聚一下,方便我们下面制作根目录
make install |
最终我们能在busybox源文件目录下的__install文件夹中得到这些文件
Qemu的安装
在arch系安装qemu也像debian系中一样简单,使用pacman即可一件安装所有qemu
sudo pacman -Sy qemu-full |
使用busybox制作根文件系统
这里我们打算用一个文件来模拟磁盘,并将其格式化为ext4格式。
我们先使用dd命令创建一个叫rootfs.ext4的256MB大小的文件来当磁盘
dd if=/dev/zero of=rootfs.ext4 bs=1M count=256 |
然后使用mkfs.ext4命令让它成为一个真正的ext4格式文件
mkfs.ext4 rootfs.ext4 |
接下来把这个新建的文件挂载到一个目录下,方面我们对其进行操作
mkdir rootfs |
现在进入这个目录,我们把刚刚__install中的文件拷贝到新建的根目录下
cp ../busybox-1.36.1/_install/* . |
我们再把c程序需要的一些必要的动态库拷过来,这些在我们刚刚下载arm的gcc编译器的时候已经下过来了,我们也只需要复制一下就行
sudo mkdir rootfs/lib && sudo cp -r /usr/arm-linux-gnueabihf/lib/* rootfs/lib |
接下来我们挂载一下我们的根目录和刚刚编译的内核,测试一下到目前为止是否一切都正常。
sudo qemu-system-arm \ |
回车之后,输出如下
抛开报错、没法输入命令不说,你就说启没启动吧
报错找不到rcS是因为init之后一般来说就是拉起一个启动脚本来执行一些初始化了,比如说挂载dev、proc目录,初始环境等。
至于linux内核一直尝试打开tty,则是因为它想启动tty交互shell但是又找不到可用的tty设备,其实只要我们挂载了dev目录,tty设备自然会被检测到。
所以现在我们就只需要写一个rcS脚本放在/etc/init.d/rcS
下面,支持一些初始化操作,包括打印logo(私货),挂载/dev、/proc、/sys,(/tmp嵌入式系统应该不需要这个东西,还是不建了)这些文件系统。
我们可以在rcS中输入下面的内容,并给它加上执行权限
|
然后指定qemu的参数为:模拟板子类型为vexpress-a9,内存512MB,kernel和dtb在宿主机中的位置,不要启动图形界面,传递给kernel的启动参数’root=/dev/mmcblk0 rw console=ttyAMA0’(root指示kernel应该挂载哪个设备为根目录,mmcblk一般为sd卡。console指示应该将信息输出到哪个串口中,在vexpress-a9中固定为ttyAMA0),把rootfs.ext4文件模拟为sd卡设备
即命令
sudo qemu-system-arm \ |
运行
很好哇,挂载成功,也能成功关机。
说明到目前为止,还是很成功的。
使用tftp+u-boot启动
我们可以先使用下面的命令,启动一下我们刚刚编译的u-boot,看看能不能正常启动
sudo qemu-system-arm \ |
一切正常
接下来我们需要把我的编译好的内核和dtb打包成uImage格式
uImage
是U-Boot引导加载器使用的一种特殊的二进制格式,它包含了一些额外的头信息,这些信息被U-Boot用来正确地加载和启动内核。
uImage
文件的格式如下:
- 一个64字节的头部,包含了一些元数据,如镜像类型(例如,是否为内核镜像或RAM磁盘镜像)、操作系统类型(例如,是否为Linux或其他操作系统)、体系结构类型(例如,是否为ARM或x86)、镜像的加载地址和入口点地址、镜像的生成时间、镜像的大小等。
- 镜像的实际数据,这通常是内核的二进制数据或RAM磁盘的数据。
- 可选的,一个或多个CRC32校验和,用于验证镜像的完整性。
当U-Boot加载一个
uImage
文件时,它首先读取头部信息,然后将镜像数据加载到指定的加载地址,最后跳转到入口点地址开始执行。
辛运的是,linux内核的makefile自带了生成uImage的功能,我们只要给出loadaddr就行,其它过程makefile会我们搞定。这个loadaddr,就是指定把uImage加载到内核中的哪个位置。但问题来了,我们应该如何设置loadaddr呢?
在Vincent Sanders的《Booting ARM Linux》中提到:
这里提到的zImage是一种格式,uImage的实际格式就是由64(0x40)B的头部+zImage
Although the zImage may be located anywhere, care should be taken. Starting a compressed kernel requires additional memory for the image to be uncompressed into. This space has certain constraints.
The zImage decompression code will ensure it is not going to overwrite the compressed data. If the kernel detects such a conflict it will uncompress the image immediately after the compressed zImage data and relocate the kernel after decompression. This obviously has the impact that the memory region the zImage is loaded into must have up to 4Megabytes of space after it (the maximum uncompressed kernel size), i.e. placing the zImage in the same 4Megabyte bank as its ZRELADDR would probably not work as expected.
Despite the ability to place zImage anywhere within memory, convention has it that it is loaded at the base of physical RAM plus an offset of 0x8000 (32K). This leaves space for the parameter block usually placed at offset 0x100, zero page exception vectors and page tables. This convention is very common.
所以这里我们可以遵照惯例,使用物理内存+0x8000的偏移来放zImage,但是我们这里用的是uImage格式,uImage格式实际上就是64(0x40)Byte大小的头部+zImage,所以实际的zImage的位置是物理内存+0x8040,uImage的位置可以放在0x8000(其实你可以直接把zImage的位置设置带0x8000的上,只不过u-boot会帮你偏移40Byte,但这应该会影响性能毕竟要向左移动40?(猜测))。vexpress-a9的物理内存开始地址可以使用命令bdinfo
查看
可以看到内存的其实地址为0x60000000,所以我们的loadaddr为0x60008040.
此外在你还需要安装u-boot的官方工具mkimage来让makefile来调用。在arch系中可以用sudo pacman -S uboot-tools
来安装。
接下来就可以编译了
make LOADADDR=0x60008040 -j$(nproc) uImage |
编译结果如下
编译好的文件位于arch/arm/boot下。
tftp的配置
为了遍于变动内核,嵌入式开发板常常会使用tftp来远程将内核加载到内存中,所以我们这里也建立一个这样的环境。
关于tftp的服务端,我们使用tftp-hpa ,可以用pacman非常容易的装上
sudo pacman -S tftp-hpa |
安装后,可以进入/etc/conf.d/tftpd配置文件设置tftp。其实默认设置就足够我们的实验,但是我们可以打开该文件,发现默认tftp把/srv/tftp文件当成共享文件夹,等会我们把我们的文件拷进来就行。
使用命令
systemctl start tftp-hpad.service |
启动tftp服务
然后我们就可以把linux-6.6/arch/arm/boot/uImag
和linux-6.6/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dtb
拷贝到/srv/tftp下
qemu网络设置
原生的qemu并不带有开箱即用的网络,我们必须设置一下。在这里我将会设置一个vmware中常用的host-only模式,即多个虚拟机和宿主机互相之间可以交换数据。
这里我们需要建立一个在宿主机上建立一个网桥和tap设备,并把tap设备加入网桥。网桥用来实现多个虚拟机之间交互数据,tap设备实现虚拟机的网络能传递到宿主机上。
sudo ip link add virbr0 type bridge |
并把网桥地址设置为192.168.1.1并开启网桥,以让虚拟机们找到宿主机上的服务
sudo ip addr add 192.168.1.1/24 dev virbr0 |
接着在启动qemu时添加一行参数,告诉qemu建立一个网卡,并且和主机joshuaos-tap绑定
-net nic -net tap,ifname=joshuaos-tap,script=no,downscript=no |
手动加载启动
在我们把启动的环境编译进代码之前,我们先手敲一遍命令,看看有没有问题。
用下面的命令启动qemu,这里的命令除了把启动的kernel换成了u-boot,和启动linux时并没有多大区别。
sudo qemu-system-arm \ |
下面我们将当前地址设置为192.168.1.2/24,服务器地址设置为192.168.1.1
setenv ipaddr 192.168.1.2 |
把我们的uImage加载到0x60008000的位置上来
tftp 0x60008000 uImage |
加载vexpress-v2p-ca9.dtb到一个足够高的位。以防止覆盖内核
tftp 0x60700000 vexpress-v2p-ca9.dtb |
然后设置一下linux内核的启动参数
setenv bootargs 'root=/dev/mmcblk0 rw console=ttyAMA0' |
然后就是启动!
bootm 0x60008000 - 0x60700000 |
启动成功
固化配置
接下来就十分的简单了,把我们的配置写进代码,进行编译就行
我们需要把我的持久化配置写进头文件u-boot/include/configs/vexpress_common.h中
/* Basic environment settings */ |
然后再次编译
make -j$(nproc) |
再次按之前的命令启动。
这下什么都不需要按即可自动进入系统。
使用tftp+u-boot+nfs方式启动
每次写文件都要挂载一次rootfs.ext4,这似乎有一点麻烦,我们可以直接使用nfs文件系统来通过网络使宿主机和虚拟机共享一个目录。
安装nfs
在arch上,由于官方打包好了nfs组建,安装也是只需要一条命令:
sudo pacman -S nfs-utils |
就可以安装上nfs client和nfsv3 server和nfsv4 server
配置nfs
nfs的配置文件默认在/etc/exports和/etc/exports.d中,我们这里修改/etc/exports
vim /etc/exports |
在该文件中,添加要共享的目录及其权限设置。我们打算将/srv/nfs文件夹暴露给192.168.1.0/24,可以使用下面的配置
/srv/nfs 192.168.1.0/24(rw,sync,no_subtree_check) |
在此示例中,“192.168.1.0/24”表示该目录仅共享给192.168.1.0/24网段的计算机,“rw”表示该目录可读写,“sync”表示写操作将同步到磁盘上,“no_subtree_check”表示不进行子目录检查。保存并关闭该文件。
并把我们之前制作的根文件系统里的东西都移动过来。
mv roofs /srv/nfs |
接下来就可以使用命令启动NFS服务器
sudo systemctl start nfs-server.service |
修改u-boot启动参数
现在我们需要修改boot args,来告诉linux内核如何连接我们的nfs服务器。
至于具体的参数,在 Linux 内核源码里面有相应的文档讲解如何设置,文档为 Documentation/filesystems/nfs/nfsroot.txt
,格式如下:
root=/dev/nfs nfsroot=[<server-ip>:]<root-dir>[,<nfs-options>] ip=<client-ip>:<server-ip>:<gwip>:<netmask>:<hostname>:<device>:<autoconf>:<dns0-ip>:<dns1-ip> |
各参数含义如下:
- [server-ip]:服务器 IP 地址,也就是存放根文件系统主机的 IP 地址,那就是宿主机的 IP地址,比如我的 IP 地址为 192.168.1.1
- [root-dir]:根文件系统的存放路径,比如我的就是/srv/nfs
- [nfs-options]:NFS 的其他可选选项,一般不设置
- [client-ip]:客户端 IP 地址,也就是我们开发板的 IP 地址, Linux 内核启动以后就会使用此 IP 地址来配置开发板。此地址一定要和宿主机在同一个网段内,并且没有被其他的设备使用,在 Ubuntu 中使用 ping 命令 ping 一下就知道要设置的 IP 地址有没有被使用,如果不能ping 通就说明没有被使用,那么就可以设置为开发板的 IP 地址,比如我就可以设置为192.168.1.2
- [server-ip]:服务器 IP 地址,前面已经说了
- [gw-ip]:网关地址,我的就是宿主机 192.168.1.1
- [netmask]:子网掩码,我的就是 255.255.255.0,即/24
- [hostname]:客户机的名字,一般不设置,此值可以空着
- [device]:设备名,也就是网卡名,一般是 eth0, eth1….如果你的电脑只有一个网卡,那么基本只能是 eth0。
- [autoconf]:自动配置,一般不使用,所以设置为 off
- [dns0-ip]:DNS0 服务器 IP 地址,不使用
- [dns1-ip]:DNS1 服务器 IP 地址,不使用
根据上面的格式 bootargs 环境变量的 root 值如下:
root=/dev/nfs nfsroot=192.168.1.1:/srv/nfs,proto=tcp rwip=192.168.1.1:192.168.1.2:192.168.1.1:255.255.255.0::eth0:off |
proto=tcp
表示使用 TCP 协议,rw
表示 nfs 挂载的根文件系统为可读可写。
替换u-boot中源码配置文件中CONFIG_BOOTCOMMAND 的bootargs部分,并重新编译u-boot。
现在便可以通过命令,直接启动并进入系统
sudo qemu-system-arm \ |
编写程序
编写Hello Word
这个非常简单,代码如下
|
编译
arm-linux-gnueabihf-gcc hello.c -o hello |
复制进nfs目录中,即虚拟机
sudo cp program/hello/hello /srv/nfs |
在虚拟机中运行
/hello |
运行结果
编写虚拟字符驱动
我们随便编写一个字符设备。
- 不妨让其设备名为"joshua_char_device"。
- 实现了打开、关闭、读取和写入操作,写入的消息可以暂时保存在一个数组中,读取的时候可以读取该数组。
- 可以通过
mknod
命令创建一个对应的设备节点来访问这个设备。 - 设备的主设备号由
register_chrdev
函数动态分配。
代码如下,保存在joshua_char_device.c文件中
|
接下来再写一个Makefile来方便编译
CC=arm-linux-gnueabihf-gcc |
然后make
然后我们把joshua_char_device.ko放进我们虚拟机根目录中
使用下面命令来加载
insmod /joshua_char_device.ko |
可以使用下面命令卸载(现在还别卸载,操作完所有再卸)
rmmod /joshua_char_device.ko |
结果如下
接下来我们就使用mknod来创建设备,尝试与驱动进行交流
mknod /dev/joshua_char_device c 248 0 |
输入下面几个测试事例
echo "hello world" > /dev/joshua_char_device |
测试过程截图
可以看到,测试结果达到了我们的预期。