1.前言

项目背景:这是我在Linux上的第一个学习项目,通过Buildroot+QEMU来模拟真实的ARM开发环境,可以实现对CPU的平均负载、当前进程数以及进程ID的显示。

环境介绍:

  • 宿主机:Ubuntu
  • 模拟器:QEMU(模拟 ARM Versatile PB 开发板)
  • 构建系统:Buildroot 2024.08

最终效果展示:

2.核心难点与环境搭建

  • 网络驱动的大坑:

        Virtio 网卡在 Versatile PB 上不工作,需要启动开发板的原生 SMC91c111 网卡驱动(需要开启 GPIO Support 才能看到 SMC91x 驱动的依赖关系)。

  • SSH配置的持久化(Overlay):

        问题:为了允许 SSH 远程登录,我们需要修改 /etc/ssh/sshd_config 文件(添加 PermitRootLogin yes)。但由于 QEMU 使用的是基于内存的根文件系统 (rootfs.cpio.gz),每次重启 QEMU 后,所有的修改都会丢失,必须重新手动配置,极其繁琐。

        原因分析:Buildroot 生成的 cpio 镜像在启动时被加载到内存(RAM Disk)中。运行时的任何修改都只存在于内存里,关机即焚。我们需要一种方法,在编译阶段就把配置文件“焊死”在镜像里。

        解决方法:使用 buildroot 的 Overlay 机制,在编译时注入配置(PermitRootLogin yes)。

3.系统架构设计

C/S架构:

  • Client(ARM板):C 语言编写,负责采集数据。
  • Server(Ubuntu):Python 编写,负责展示数据。

数据流:读取 /proc/loadavg ,经Socket打包,再由TCP发送,使用Python解码展示。

4.代码实现

服务端(Python):

  服务端部署在 Ubuntu 宿主机上,负责监听特定端口,接收并打印来自开发板的数据。

新建文件 monitor_server.py:

import socket

# 监听配置
HOST = '0.0.0.0'  # 监听所有网卡接口
PORT = 9999       # 自定义端口号

print(f"=== Server Started on port {PORT} ===")

# 创建 TCP Socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    # 允许端口复用,防止程序退出后短时间内无法重启
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
    s.bind((HOST, PORT))
    s.listen()
    
    print("Waiting for connection...")
    conn, addr = s.accept()  # 阻塞等待连接
    
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            # 解码并打印接收到的数据
            print(f"[ARM Board]: {data.decode().strip()}")

关键点解析:

  • 0.0.0.0:这里不写 127.0.0.1 也不写具体 IP,而是用 0.0.0.0,确保无论是本地回环还是来自虚拟网卡的数据都能被接收到。

  • SO_REUSEADDR:在调试阶段,如果我们频繁强制关闭服务,端口可能会处于 TIME_WAIT 状态。开启此选项可以让我们立即重启服务,无需等待。

客户端(C语言):

客户端运行在 QEMU 模拟的 ARM 开发板上。我们需要读取系统的 CPU 负载信息,并通过网络发送。

新建文件 sys_monitor.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

/* QEMU User Network 特性:
 * 在 QEMU 内部,网关地址通常是 10.0.2.2
 * 该地址会直接映射到宿主机 (Ubuntu) 的 Loopback 接口
 */
#define SERVER_IP "10.0.2.2"  
#define SERVER_PORT 9999
#define BUFFER_SIZE 128

int main() {
    int sock;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    FILE *fp;
    char load_data[64];

    // 1. 创建 Socket (IPv4, TCP)
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("Socket creation failed");
        return -1;
    }

    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERVER_PORT);
    
    // 将 IP 地址从字符串转换为二进制
    if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
        perror("Invalid address");
        return -1;
    }

    // 2. 连接到服务端
    printf("Connecting to Server %s:%d ...\n", SERVER_IP, SERVER_PORT);
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Connection failed");
        return -1;
    }
    printf("Connected! Sending data...\n");

    // 3. 循环采集与发送
    while (1) {
        // 打开 /proc/loadavg 获取负载信息
        fp = fopen("/proc/loadavg", "r");
        if (fp == NULL) {
            perror("Failed to read /proc/loadavg");
            break;
        }

        // 读取一行数据
        if (fgets(load_data, sizeof(load_data), fp) != NULL) {
            // 拼接发送字符串
            snprintf(buffer, BUFFER_SIZE, "CPU Load: %s", load_data);
            
            // 发送数据
            send(sock, buffer, strlen(buffer), 0);
        }

        fclose(fp); // 务必关闭文件句柄

        sleep(2); // 设置 2 秒心跳间隔
    }

    close(sock);
    return 0;
}

关键点解析:

  • 关于 10.0.2.2:

        这是 QEMU 用户模式网络(User Networking)的一个特殊约定。

        10.0.2.15:QEMU开发板自己的IP。

        10.0.2.2:QEMU所在宿主机的IP别名。

5.交叉编译与部署

代码写好了,但我们不能直接使用 Ubuntu 系统自带的 gcc 进行编译。因为 Ubuntu 是 x86 架构,而我们的 QEMU 模拟的是 ARM 架构。如果直接编译,在板子上运行时会报错 Exec format error。

  • 交叉编译:

Buildroot 在构建系统的同时,已经为我们生成了一套完整的工具链,位于 output/host/bin/ 目录下

# 使用 Buildroot 生成的专用 gcc 编译器
./output/host/bin/arm-buildroot-linux-gnueabi-gcc sys_monitor.c -o sys_monitor

    编译完成后,务必使用 file 命令检查生成的可执行文件类型,确保它是 ARM 格式:

    file sys_monitor

    检查命令的预期输出:

    sys_monitor: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ...

    这里要看到 ARM 字样才算成功,如果是 x86-64 ,就说明误用了宿主机的编译器,板子会无法启动。

    • 部署(SCP)

    由于我们之前配置好了 SSH 服务,并将 QEMU 的 22 端口映射到了宿主机的 2222 端口,我们可以使用 scp (Secure Copy) 命令像操作本地文件一样传输文件。

    # -P 2222: 指定映射端口
    # sys_monitor: 本地文件
    # root@localhost: 目标用户和地址
    # :/root/: 目标路径
    scp -P 2222 sys_monitor root@localhost:/root/
    • 关于 SSH Host Key 冲突

    在开发过程中,如果我们重新编译了 Buildroot 镜像(特别是执行了 make clean 后),QEMU 重启后会生成新的 SSH 主机密钥(Host Key)。此时再次使用 SCP 或 SSH 连接时,Ubuntu 会报错并拦截连接,提示 "REMOTE HOST IDENTIFICATION HAS CHANGED!"

    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    ...
    Host key verification failed.
    lost connection

    这是因为 SSH 客户端发现目标主机的“身份证”变了,怀疑遭遇了中间人攻击。但在嵌入式开发中,这是正常现象。解决方法如下,

    使用以下命令清除旧的密钥记录,然后重试即可:

    ssh-keygen -f "$HOME/.ssh/known_hosts" -R "[localhost]:2222"

    6.运行测试

    为了清晰地展示数据流向,我们需要打开两个中断窗口配合操作。

    • 启动服务端 (Ubuntu)

    首先,在 Ubuntu 宿主机的终端中启动 Python 服务端脚本。它将挂起并等待来自开发板的连接。

    # 在 Ubuntu 终端执行
    python3 monitor_server.py

    输出状态:

    === Server Started on port 9999 ===
    Waiting for connection...

    此时,服务端进入监听状态。

    • 启动客户端 (ARM 开发板)

    保持服务端窗口不动,打开一个新的终端窗口(或使用之前的 QEMU 窗口),通过 SSH 登录到开发板,运行我们在上一节传输进去的监控程序。

    # 1. SSH 登录开发板
    ssh -p 2222 root@localhost
    
    # 2. 赋予可执行权限 (首次运行需要)
    chmod +x sys_monitor
    
    # 3. 运行监控程序
    ./sys_monitor

    输出状态:

    Connecting to Server 10.0.2.2:9999 ...
    Connected! Sending data...
    • 结果验证与数据解读

    客户端显示 Connected! 后,服务端窗口就会开始刷新数据:

    Connected by ('127.0.0.1', 45678)
    [ARM Board]: CPU Load: 0.12 0.05 0.01 1/34 112
    [ARM Board]: CPU Load: 0.12 0.05 0.01 1/34 112
    [ARM Board]: CPU Load: 0.11 0.05 0.01 1/34 112
    ...

    这段数字包含如下系统信息:

            0.12 0.05 0.01:分别代表系统在最近 1分钟、5分钟、15分钟 内的平均负载。

            1/34:分母 34 表示系统中总共有 34 个进程/线程;分子 1 表示当前只有 1 个进程处于运行状态(就是我们的 sys_monitor)。

            112:表示最近创建的进程 PID。

    • 停止运行

            停止客户端:在 SSH 窗口按 Ctrl + C,程序终止,停止发送心跳。

            停止服务端:在 Python 窗口按 Ctrl + C,停止监听。

    7.总结与展望

    总结:本次项目有几个关键的知识点

    1. 依赖管理的艺术:在 Buildroot 中,软件包的显示与否往往取决于依赖关系(如 SMC91x 网卡驱动依赖于 GPIO Support)。学会使用 / 搜索并查看 Depends on 是解决配置问题的金钥匙。

    2. 文件系统的定制:通过 Overlay 机制,我们学会了如何在编译阶段“注入”配置文件,解决了嵌入式设备掉电配置丢失的痛点。这是产品化必不可少的一步。

    3. 跨架构开发的思维:时刻保持清醒——代码在哪里写?编译器用的是哪个?程序在哪里跑?交叉编译是嵌入式开发与纯软件开发最大的区别。

    4. 软硬结合:代码写得再好,如果内核没有开启对应的硬件驱动(如 PCI vs MMIO vs 原生网卡),系统依然跑不起来。

    展望:目前的监控系统虽然能跑,但还比较简陋。基于当前的环境,还可以继续挑战更有趣的功能:

    • 数据可视化:在 Buildroot 中集成 Web 服务器(如 lighttpd 或 nginx),将 C 语言采集的数据通过 CGI 或 WebSocket 推送到网页端,实现酷炫的仪表盘展示。

    • 数据持久化:移植 SQLite 数据库,将采集到的负载历史数据保存到本地文件中,而不是仅仅打印在屏幕上。

    • 内核级开发:不再满足于读取 /proc 文件,尝试编写一个真正的 Linux 内核模块 (Kernel Module),在内核态直接获取系统信息,甚至模拟一个字符设备驱动。

    Logo

    智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

    更多推荐