无题

使用u-boot+linux kernel+busybox实现一个嵌入式小系统

嵌入式作业,要求我们实现

  1. 使用QEMU模拟一个嵌入式开发板;
  2. 使用交叉编译器,为虚拟平台编译u-boot,并进入下载模式熟悉简单命令;
  3. 使用交叉编译器,为虚拟平台编译内核kernel;
  4. 为虚拟平台创建根文件系统(借助busybox),并启动平台系统,编写简单的hello world程序,传输到嵌入式平台(可放入根文件系统),运行获得结果。
  5. 完成虚拟字符驱动的编写,并加载到内核,获得实验结果;

接下来的实验中,我选择模拟vexpress_ca9x4开发板。

编译和安装

arm编译器

首先要想跨平台安装,就需要安装对应平台的编译链工具。在这里我们选择使用最广的arm架构下的gcc进行编译。

在arch平台下使用下面命令进行安装,一定要按下面的顺序安装

yay -Sy arm-linux-gnueabihf-gcc-stage1
yay -S arm-linux-gnueabihf-gcc-stage2
yay -S arm-linux-gnueabihf-gcc

其它平台自行搜索对应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
export CROSS_COMPILE=arm-linux-gnueabihf-
make -j$(nproc)

编译完成后,目录下会出现二进制文件u-boot

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
export CROSS_COMPILE=arm-linux-gnueabihf-

向上面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那么全。

  1. 输入make defconfig生成一个默认的配置文件
  2. 输入make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig进入配置页面
  3. Settings->--- Build Options选择Build static binary (no shard libs)
  4. 退出保存

然后就是开始编译。

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc)

最后我们把所有编译好之后的所有有用文件汇聚一下,方便我们下面制作根目录

make install 

最终我们能在busybox源文件目录下的__install文件夹中得到这些文件

busybox编译全家福

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
sudo mount rootfs.ext4 rootfs -o loop,rw -t ext4

现在进入这个目录,我们把刚刚__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 \
-M vexpress-a9 \
-m 512M \
-kernel linux-6.6/arch/arm/boot/zImage \
-dtb linux-6.6/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dtb \
-nographic \
-append "root=/dev/mmcblk0 rw console=ttyAMA0" \
-drive file=rootfs.ext4,format=raw,if=sd

回车之后,输出如下

启动后输出

抛开报错、没法输入命令不说,你就说启没启动吧

报错找不到rcS是因为init之后一般来说就是拉起一个启动脚本来执行一些初始化了,比如说挂载dev、proc目录,初始环境等。

至于linux内核一直尝试打开tty,则是因为它想启动tty交互shell但是又找不到可用的tty设备,其实只要我们挂载了dev目录,tty设备自然会被检测到。

所以现在我们就只需要写一个rcS脚本放在/etc/init.d/rcS下面,支持一些初始化操作,包括打印logo(私货),挂载/dev、/proc、/sys,(/tmp嵌入式系统应该不需要这个东西,还是不建了)这些文件系统。

我们可以在rcS中输入下面的内容,并给它加上执行权限

#!/bin/sh

# 打印logo
echo '======================================================'
echo ' _ _ ___ ____'
echo ' | | ___ ___| |__ _ _ __ _ / _ \/ ___|'
echo ' _ | |/ _ \/ __| '\''_ \| | | |/ _` | | | | \___ \'
echo '| |_| | (_) \__ \ | | | |_| | (_| | | |_| |___) |'
echo ' \___/ \___/|___/_| |_|\__,_|\__,_| \___/|____/'
echo '======================================================'

# 挂载一些必要的文件系统
echo '> Mounting filesystem'

echo '>> Mounting /dev'
# 若不存在dev目录,则创建
if [ ! -d /dev ]; then
echo '>>> No /dev directory detected, creating'
mkdir /dev
fi
# 挂载dev目录
mount -t devtmpfs none /dev -o rw,nosuid,relatime,size=50M,mode=755

echo '>> Mounting /proc'
# 若不存在proc目录,则创建
if [ ! -d /proc ]; then
echo '>>> No /proc directory detected, creating'
mkdir /proc
fi
# 挂载dev目录
mount -t proc none /proc -o rw,nosuid,nodev,noexec,relatime

echo '>> Mounting /sys'
# 若不存在sys目录,则创建
if [ ! -d /sys ]; then
echo '>>> No /sys directory detected, creating'
mkdir /sys
fi

# 挂载dev目录
mount -t sysfs none /sys -o rw,nosuid,nodev,noexec,relatime

然后指定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 \
-M vexpress-a9 \
-m 512M \
-kernel linux-6.6/arch/arm/boot/zImage \
-dtb linux-6.6/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dtb \
-nographic \
-append "root=/dev/mmcblk0 rw console=ttyAMA0" \
-drive file=rootfs.ext4,format=raw,if=sd

运行

阶段试运行结果

很好哇,挂载成功,也能成功关机。

说明到目前为止,还是很成功的。

使用tftp+u-boot启动

我们可以先使用下面的命令,启动一下我们刚刚编译的u-boot,看看能不能正常启动

sudo qemu-system-arm \
-M vexpress-a9 \
-m 512M \
-kernel u-boot/u-boot \
-nographic

一切正常

image-20231217141424747

接下来我们需要把我的编译好的内核和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查看

image-20231217233236416

可以看到内存的其实地址为0x60000000,所以我们的loadaddr为0x60008040.

此外在你还需要安装u-boot的官方工具mkimage来让makefile来调用。在arch系中可以用sudo pacman -S uboot-tools来安装。

接下来就可以编译了

make LOADADDR=0x60008040 -j$(nproc) uImage

编译结果如下

image-20231217235519156

编译好的文件位于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/uImaglinux-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
sudo ip tuntap add dev joshuaos-tap mode tap
sudo ip link set joshuaos-tap up promisc on
sudo ip link set joshuaos-tap master virbr0

并把网桥地址设置为192.168.1.1并开启网桥,以让虚拟机们找到宿主机上的服务

sudo ip addr add 192.168.1.1/24 dev virbr0
sudo ip link set virbr0 up

接着在启动qemu时添加一行参数,告诉qemu建立一个网卡,并且和主机joshuaos-tap绑定

-net nic -net tap,ifname=joshuaos-tap,script=no,downscript=no

手动加载启动

在我们把启动的环境编译进代码之前,我们先手敲一遍命令,看看有没有问题。

用下面的命令启动qemu,这里的命令除了把启动的kernel换成了u-boot,和启动linux时并没有多大区别。

sudo qemu-system-arm \
-M vexpress-a9 \
-m 512M \
-kernel u-boot/u-boot \
-nographic \
-net nic -net tap,ifname=joshuaos-tap,script=no,downscript=no

下面我们将当前地址设置为192.168.1.2/24,服务器地址设置为192.168.1.1

setenv ipaddr 192.168.1.2
setenv netmask 255.255.255.0
setenv serverip 192.168.1.1

把我们的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启动截图

固化配置

接下来就十分的简单了,把我们的配置写进代码,进行编译就行

我们需要把我的持久化配置写进头文件u-boot/include/configs/vexpress_common.h中

 /* Basic environment settings */
#define CONFIG_BOOTCOMMAND \
"setenv ipaddr 192.168.1.2;setenv netmask 255.255.255.0;setenv serverip 192.168.1.1;tftp 0x60008000 uImage;tftp 0x60700000 vexpress-v2p-ca9.dtb;setenv bootargs 'root=/dev/nfs rw nfsroot=192.168.1.1:/svc/nfs,nfsvers=4,noatime console=ttyAMA0 ip=192.168.1.2';bootm 0x60008000 - 0x60700000"

然后再次编译

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 \
-M vexpress-a9 \
-m 512M \
-kernel u-boot/u-boot \
-nographic \
-net nic -net tap,ifname=joshuaos-tap,script=no,downscript=no

编写程序

编写Hello Word

这个非常简单,代码如下

#include <stdio.h>

int main(){
printf("Hello World!");
return 0;
}

编译

arm-linux-gnueabihf-gcc hello.c -o hello

复制进nfs目录中,即虚拟机

sudo cp program/hello/hello /srv/nfs

在虚拟机中运行

/hello

运行结果

hello world运行结果

编写虚拟字符驱动

我们随便编写一个字符设备。

  • 不妨让其设备名为"joshua_char_device"。
  • 实现了打开、关闭、读取和写入操作,写入的消息可以暂时保存在一个数组中,读取的时候可以读取该数组。
  • 可以通过mknod命令创建一个对应的设备节点来访问这个设备。
  • 设备的主设备号由register_chrdev函数动态分配。

代码如下,保存在joshua_char_device.c文件中

#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <asm/uaccess.h>

#define DEVICE_NAME "joshua_char_device"
#define BUF_LEN 80

static int Major;
static char msg[BUF_LEN];
static char *msg_Ptr;

static int device_open(struct inode *inode, struct file *file)
{
msg_Ptr = msg;
try_module_get(THIS_MODULE);
return 0;
}

static int device_release(struct inode *inode, struct file *file)
{
module_put(THIS_MODULE);
return 0;
}

static ssize_t device_read(struct file *filp, char *buffer, size_t length, loff_t * offset)
{
int bytes_read = 0;
if (*msg_Ptr == 0)
return 0;
while (length && *msg_Ptr) {
put_user(*(msg_Ptr++), buffer++);
length--;
bytes_read++;
}
return bytes_read;
}

static ssize_t device_write(struct file *filp, const char *buff, size_t len, loff_t * off)
{
strncpy(msg, buff, len);
msg[len] = '\0';
return len;
}

static struct file_operations fops = {
.read = device_read,
.write = device_write,
.open = device_open,
.release = device_release
};

static int __init my_init(void)
{
Major = register_chrdev(0, DEVICE_NAME, &fops);
if (Major < 0) {
printk(KERN_ALERT "Registering char device failed with %d\n", Major);
return Major;
}
printk(KERN_INFO "I was assigned major number %d. To talk to\n", Major);
printk(KERN_INFO "'mknod /dev/%s c %d 0'.\n", DEVICE_NAME, Major);
return 0;
}

static void __exit my_exit(void)
{
unregister_chrdev(Major, DEVICE_NAME);
printk(KERN_ALERT "Removing char device\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");

接下来再写一个Makefile来方便编译

CC=arm-linux-gnueabihf-gcc
CROSS_COMPILE=arm-linux-gnueabihf-
KERNELDIR=../../linux-6.6
PWD=$(shell pwd)

obj-m += joshua_char_device.o

all:
make -C $(KERNELDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) modules

clean:
make -C $(KERNELDIR) M=$(PWD) clean

然后make

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 
cat /dev/joshua_char_device
echo "another test" > /dev/joshua_char_device
cat /dev/joshua_char_device

测试过程截图

测试过程截图

可以看到,测试结果达到了我们的预期。