OrangePi Kunpeng Pro体验——安装Hass与驱动SPI小屏幕

OrangePi Kunpeng Pro 是一款面向开发者和爱好者的高性能开发板。在本次测评中,主要将以前的一些代码在该开发板上实现,包括docker部署hass,引脚驱动SPI小屏幕。中间遇到了一些小小问题,但都成功了,一起来试试吧~ 

一、开箱

1. 开箱全貌

快递第三天收到了主办方寄来的OrangePi Kunpeng Pro套装(主板,8G,电源,散热组件,32GB存储卡),SD卡中已经安装了最新的openEuler系统,即插即用👍。

2. 主板观赏

以下说几个比较关注的,具体的主板说明可参考官方链接

正面:CPU、内存、无线网卡和一些接口指示灯。

接口:PD电源输入、HDMI、多个USB3.0接口和一个千M网口。

3. 背面接口

清晰明了的三种不同存储接入接口:SD卡、SSD和EMMC,并可通过两个拨码开关选择启动方式。特别注意的是,虽然M2接口支持nvme和sata两种SSD硬盘,但是默认是nvme硬盘,如果接入的是sata硬盘,需要进行额外的操作(如图就是sata硬盘,额外操作在后续会介绍)。

二、基础入门

0. 说明

1) 账号密码均为:openEuler

1. 烧录系统到sata固态硬盘(nvme可跳过前三个步骤)

SD卡速度慢,手头有一个sata固态硬盘,正好用上。但是sata的固态硬盘,需要额外的修改才能够被开发板识别。

1) 系统烧录:使用balenaEtcher,将系统烧录到硬盘中(使用移动硬盘盒),随后插入到开发板M.2接口中(由于无螺丝,使用了胶带简单固定),但此时还无法读取到这个硬盘。

2) 暂不更改拨码开关,从SD卡进入系统,在此系统下更新SATA 驱动需要的的dt.img 文件。

首先进入/opt/opi_test/sata 文件夹:

cd /opt/opi_test/sata

然后运行下update.sh 脚本来更新SATA 对应的dt.img

sudo ./update.sh

然后重启,使用lsblk查看硬盘,可正常识别:

3) 将SD卡的dt.img配置,更新到sata硬盘中(需要根据情况修改sata硬盘的节点名称,如图为sda)

sudo dd if=/opt/opi_test/dt_img/dt_drm_sata.img of=/dev/sda count=4096 seek=114688 bs=512

4) 切换拨码开关,以SSD方式启动,顺利开机。使用df -h可查看当前系统空间。

2. 增加swap内存

开发板内存有8G,大部分应用已经完全足够,不够时还可以通过设置swap扩展系统内存。

1) 创建一个swap文件

sudo fallocate -l 16G /swapfile

2) 依次配置

sudo chmod 600 /swapfile # 权限为root用户可以读写 
sudo mkswap /swapfile 
sudo swapon /swapfile 
free -h # 查看内存结果

可以看到swap空间为15G(小数部分被直接忽略了),使用free -m可以看到更详细的数据。

3) 设置重启自动生效

将对应的配置添加到/etc/fstab 文件中。

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

3. 配置无线网络

前面都用的是有线网口,接下来通过ssh配置其连接无线wifi。

1) 使用 nmcli 命令扫描附近的 Wi-Fi 网络:

nmcli dev wifi list

2) 使用 nmcli 命令连接到你的 Wi-Fi 网络。假设Wi-Fi SSID 是 SSIDWiFi,密码是 MyPassword,请进行修改:

sudo nmcli dev wifi connect SSIDWiFi password MyPassword

3) 验证连接状态:

nmcli dev status 
# 或者 ifconfig

4. 安装docker

方法1:直接使用 YUM 安装 Docker。简单,但可能安装的是系统软件仓库中提供的较老版本的 Docker。

sudo yum update -y
sudo yum install -y docker
sudo systemctl enable docker
sudo systemctl start docker

方法2:通过 YUM 源安装 Docker。首先添加了 Docker 的 YUM 源,然后使用 yum install 命令安装 Docker 软件包。确保了安装的是最新版本的 Docker,并且可以通过 YUM 包管理器进行更新。

1) 更新

sudo yum update -y

2) 添加 Docker YUM 源

需要添加如下源,如果后面update报错,可以删除该文件。

简单说明:Docker 官方提供了适用于 CentOS/RHEL 的 YUM 源,而 openEuler 在很大程度上与 CentOS/RHEL 兼容,因此使用这些源进行 Docker 的安装。

sudo nano /etc/yum.repos.d/docker-ce.repo

添加以下内容:

[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/centos/7/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/centos/gpg

3) 安装 Docker

sudo yum install -y docker-ce docker-ce-cli containerd.io

4) 启动 Docker 服务

sudo systemctl start docker                 # 启动服务
sudo systemctl enable docker                # 开机自启
sudo systemctl status docker                # 查看状态

5) 测试

sudo docker run hello-world

6) 其他一些指令

以下为使用docker常用的一些指令:

docker --version                     # 查看 Docker 版本
docker run hello-world               # 运行一个 Hello World 容器
docker ps -a                         # 列出所有容器
docker images                        # 列出所有镜像
docker stop CONTAINER_ID             # 停止一个运行中的容器
docker start CONTAINER_ID            # 启动一个停止的容器
docker rm CONTAINER_ID               # 移除一个容器
docker rmi IMAGE_ID                  # 移除一个镜像
docker logs CONTAINER_ID             # 查看容器日志
docker exec -it CONTAINER_ID /bin/bash # 进入一个运行中的容器
docker stats CONTAINER_ID            # 查看容器的资源使用情况
docker build -t my-image:latest .    # 构建一个 Docker 镜像
docker pull ubuntu:latest            # 拉取一个 Docker 镜像
docker push my-image:latest          # 推送一个 Docker 镜像到仓库
docker info                          # 显示 Docker 系统信息
docker network ls                    # 查看 Docker 网络配置
docker network create NETWORK_NAME   # 创建一个 Docker 网络
docker network connect NETWORK_NAME CONTAINER_ID # 连接容器到指定网络
docker network disconnect NETWORK_NAME CONTAINER_ID # 断开容器与网络的连接

三、功耗测量

虽然针对这个高性能开发板,低功耗是不太可能了,但是测量功耗可以明确对电源的需求。目前开发板搭配了最高3A的电源(仅考虑12V)。

1.说明

开发板使用了256G Sata固态硬盘作为系统盘,插入了网线和电源,不接显示器使用ssh登录。使用系统自带的风扇调节方案无修改。依次测量开机、CPU25%、50%和75%运行下、和关机的功耗。

2. 测量程序

写一个cpu_stress.py程序,占用一个核进行满负荷运行(25%CPU占用)。多开可占用更多的CPU资源。

#!/bin/bash

echo "Starting CPU stress test..."

while true; do
    # 执行一些无限循环的计算任务,例如计算圆周率
    echo "scale=5000; 4*a(1)" | bc -l >/dev/null
done

3. 结果

1) 开机:接入typeC供电后,开发板自动开机。首先开发板通过PD协议让电源输入电压升到12V,风扇启动,电流最大到1.1A,经过40s后稳定到660mA。

2) cpu运行测量:依次测量CPU占用25%、50%和75%时的功耗,由于其中1核被设置为了AI核,无法被该程序调用,因此最高占用只有75%。在25%、50%和75%占用时,电流分别为750mA、800mA和970mA

3) 关机:最后,通过指令poweroff,使开发板关机,测量功耗。此时电压仍保持在12V,电流为280mA,风扇不转。

四、Docker部署Hass

在安装docker后,依次安装homeassistant、数据库、mqtt服务器、esphome、nodered,并让他们互相链接,是指一个完善的、且可方便更新的智能家居管理系统,而且不影响其他服务的安装。

1. 配置docker

在上述安装好docker后,通过如下指令添加当前用户到Docker组:

sudo usermod -aG docker ${USER}
newgrp docker    # 重新登录,以应用用户组更改

2. 安装docker-compose

由于 openEuler 使用 ARM 架构,需要下载适用于 ARM 的 Docker Compose 二进制文件:

1) 下载 Docker Compose

sudo curl -L "https://github.com/docker/compose/releases/download/$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep 'tag_name' | cut -d'"' -f4)/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

2) 设置执行权限

sudo chmod +x /usr/local/bin/docker-compose

3) 验证安装

docker-compose --version

3. 准备hass文件夹

首先需要创建一个hass-all文件夹,用于放上述几个容器的文件,并配置文件夹权限。随后需要配置mqtt需要的文件,和nodered需要的文件夹。

1) 创建hass-all文件夹

cd
mkdir /home/openEuler/hass-all
sudo chmod -R 777 /home/openEuler/hass-all/    # 给文件夹权限

2)配置mosquitto.conf文件

cd /home/openEuler/hass-all/mosquitto/config
touch mosquitto.conf
chmod 777 mosquitto.conf
nano mosquitto.conf

3)配置nodered权限

cd /home/openEuler/hass-all
mkdir nodered
chmod 777 nodered/

4. 写入docker-compose.yml文件

在hass-all中写入docker-compose.yml,方便一键启动:

cd /home/openEuler/hass-all
nano docker-compose.yml

随后,写入如下内容,注意mariadb数据库中的password,可根据自己情况进行修改:

version: '3'
services:
  homeassistant:
    container_name: homeassistant
    image: homeassistant/home-assistant:stable
    volumes:
      - /home/openEuler/hass-all/homeassistant:/config
    environment:
      - TZ=Asia/Shanghai
    network_mode: host
    restart: unless-stopped
    depends_on:
      - mosquitto
      - mariadb

  mosquitto:
    container_name: mosquitto
    image: eclipse-mosquitto:latest
    volumes:
      - /home/openEuler/hass-all/mosquitto/config:/mosquitto/config
      - /home/openEuler/hass-all/mosquitto/data:/mosquitto/data
      - /home/openEuler/hass-all/mosquitto/log:/mosquitto/log
    restart: always
    ports:
      - "1883:1883"
      - "9001:9001"

  mariadb:
    container_name: mariadb
    image: mariadb:latest
    volumes:
      - /home/openEuler/hass-all/mariadb:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=homeassistant
      - MYSQL_USER=root
      - MYSQL_PASSWORD=password
    restart: always
    ports:
      - "3306:3306"

  nodered:
    container_name: nodered
    image: nodered/node-red:latest
    volumes:
      - /home/openEuler/hass-all/nodered:/data
    user: "node-red"
    restart: always
    ports:
      - "1880:1880"

  esphome:
    container_name: esphome
    image: esphome/esphome
    volumes:
      - /home/openEuler/hass-all/esphome:/config
    network_mode: host
    restart: always
    ports:
      - "6052:6052"
      - "6123:6123"

5. 启动docker-compose

随后,通过指令启动上述docker:

docker-compose up       # 关闭窗口后就会停止上述docker,方便调试
# docker-compose up -d    # 后台运行

6. 进入homeassistant

通过ifconfig查看开发板IP地址,随后浏览器输入IP:8123端口,进行homeassistant配置,完成后如图,可以看到当地的天气啦!

7. 后续配置

1) 数据库配置:由于homeassistant的内置数据库效率低,在后面多设备情况下,可能会影响稳定性,因此使用mariadb作为其数据库。

修改/home/openEuler/hass-all/homeassistant/configuration.yaml文件,加入如下内容,其中的数据库password需要与上面对应:

recorder:
  db_url: mysql://root:password@127.0.0.1/homeassistant?charset=utf8

2) 界面添加:将上述的nodered和esphome添加到homeassistant界面中。

修改/home/openEuler/hass-all/homeassistant/configuration.yaml文件,加入如下内容,其中的IP需要对应修改为自己的:

panel_iframe:
 nodered:
   title: 'Node-Red'
   icon: 'mdi:shuffle-variant'
   #填写node-red的地址
   url: 'http://192.168.10.181:1880/'

 esphome:
   title: 'ESPHome'
   icon: 'mdi:car-esp'
   #填写node-red的地址
   url: 'http://192.168.10.181:6052/'

完成上述后,重启docker-compose,可以看到如下内容。

3)安装HACS

HACS可以帮助homeassistant扩展更多的界面和应用,如小米、天气卡片等。

参考官方:https://hacs.xyz/docs/setup/download/,使用container的教程

打开HA的bash,输入如下指令即可

wget -O - https://get.hacs.xyz | bash -

随后打开homeassistant中的高级模式。最后添加集成HACS,并进行相应配置,即可显示HACS内容。

4) 配置Node-Red

在Node-Red中添加节点node-red-contrib-home-assistant-websocket,并安装。

5)配置MQTT

在homeassistant中添加集成MQTT,并配置如下

五、使用SPI小屏幕

开发板和小电脑最大的区别是,开发板上有引出多功能引脚,可以方便连接外部设备和传感器。在这里,测试使用该开发板驱动SPI小屏幕。

1. 查看手册

  SPI小屏幕包括引脚:GND VCC SCL SDA RES DC CS BLK引脚,1.14寸st7789 TFT屏幕,定义如下。

  开发板的引脚如下,需要使用到SPI引脚和几个通用引脚。

2. 引脚控制测试

根据手册,进行引脚控制。

0)报错解决:测试过程中,发现gpio_operate -h报错,究其原因是sudo yum update导致库更新不兼容,解决办法是将对应库降级,即可解决。

# 降级
sudo yum downgrade glibc glibc-common
# 重新加载驱动
lsmod | grep gpio
dmesg | grep gpio
# 重启
sudo reboot

1)基础引脚测试

使用引脚2-20,读取其方向为输入,随后读取其value,发现为1。通过杜邦线连接2-20和GND,在此读取value,发现变成了1。

gpio_operate -h                    # 帮助help
gpio_operate get_direction 2 20    # 查看引脚方向,0表示输入,1表示输出
gpio_operate get_value 2 20        # 获取引脚值

2) SPI回环测试

回环测试是指将SPI的SDI和SDO连接,发出去的数据被自己接收,查看收发数据是否一致判断SPI工作是否正常。(回环测试也可用于UART中)

ls /dev/spidev0.0            # 查看SPI设备
# 控制SPI进行测试,依次测试不连接和连接O/I的情况
sudo spidev_test -v -D /dev/spidev0.0

3) python控制引脚

写一个read_gpio.py程序,将上述bash指令由python调用,读取2-20引脚并显示其引脚状态。

import subprocess
import time

def get_gpio_value(gpio_group, gpio_pin):
    result = subprocess.run(f'gpio_operate get_value {gpio_group} {gpio_pin}', shell=True, capture_output=True, text=True)
    return result.stdout.strip()

def main():
    gpio_group = 2
    gpio_pin = 20

    while True:
        value = get_gpio_value(gpio_group, gpio_pin)
        print(f'GPIO {gpio_group}-{gpio_pin} Value: {value}')
        time.sleep(1)  # 每隔一秒读取一次

if __name__ == '__main__':
    main()

4) cpp控制引脚

上述都是在gpio_operate基础上进行引脚操作,不是特别方便。利用最底层,通过直接操作 sys/class/gpio来控制GPIO。以下是一个示例,周期读取GPIO2_20引脚状态并显示。

注意:GPIO2_20 是指第 2 组的第 20 个引脚,编号规则通常是 (组号 * 32) + 引脚号,例如 2 * 32 + 20 = 84。因此这里的引脚设置为84。

创建名为 gpio_read.cpp 的文件:

#include <iostream>
#include <fstream>
#include <string>
#include <unistd.h>

using namespace std;

class GPIO {
public:
    GPIO(int pin) : pinNumber(pin) {
        exportGPIO();
        setDirection("in");
    }

    ~GPIO() {
        unexportGPIO();
    }

    int getValue() {
        ifstream gpioValueFile("/sys/class/gpio/gpio" + to_string(pinNumber) + "/value");
        int value = -1;
        if (gpioValueFile.is_open()) {
            gpioValueFile >> value;
            gpioValueFile.close();
        } else {
            cerr << "Unable to get value for GPIO" << endl;
        }
        return value;
    }

private:
    int pinNumber;

    void exportGPIO() {
        ofstream gpioExportFile("/sys/class/gpio/export");
        if (gpioExportFile.is_open()) {
            gpioExportFile << pinNumber;
            gpioExportFile.close();
        } else {
            cerr << "Unable to export GPIO" << endl;
        }
        usleep(100000); // 等待 GPIO 文件系统创建
    }

    void unexportGPIO() {
        ofstream gpioUnexportFile("/sys/class/gpio/unexport");
        if (gpioUnexportFile.is_open()) {
            gpioUnexportFile << pinNumber;
            gpioUnexportFile.close();
        } else {
            cerr << "Unable to unexport GPIO" << endl;
        }
    }

    void setDirection(const string& direction) {
        ofstream gpioDirectionFile("/sys/class/gpio/gpio" + to_string(pinNumber) + "/direction");
        if (gpioDirectionFile.is_open()) {
            gpioDirectionFile << direction;
            gpioDirectionFile.close();
        } else {
            cerr << "Unable to set direction for GPIO" << endl;
        }
    }
};

int main() {
    int gpioPin = 84; // GPIO2_20 的编号

    GPIO gpio(gpioPin);

    while (true) {
        int value = gpio.getValue();
        cout << "GPIO " << gpioPin << " Value: " << value << endl;
        sleep(1); // 每秒读取一次
    }

    return 0;
}

编译和运行程序

g++ -o gpio_read gpio_read.cpp
sudo ./gpio_read

5) cpp控制SPI

使用 /dev/spidevX.Y 进行 SPI 通信,开发板为0.0的SPI,使用标准的 C++ 库和 ioctl 系统调用来控制 SPI 设备。

创建名为 spi_control.cpp 的文件:

#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>
#include <cstring>

using namespace std;

class SPI {
public:
    SPI(const string& device, uint8_t mode, uint32_t speed) {
        fd = open(device.c_str(), O_RDWR);
        if (fd < 0) {
            perror("Failed to open SPI device");
            exit(1);
        }

        // 设置 SPI 模式
        if (ioctl(fd, SPI_IOC_WR_MODE, &mode) == -1) {
            perror("Failed to set SPI mode");
            exit(1);
        }

        // 设置 SPI 速度
        if (ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed) == -1) {
            perror("Failed to set SPI speed");
            exit(1);
        }

        this->speed = speed;
        this->mode = mode;
    }

    ~SPI() {
        close(fd);
    }

    void transfer(uint8_t* tx_buffer, uint8_t* rx_buffer, size_t length) {
        struct spi_ioc_transfer spi;
        memset(&spi, 0, sizeof(spi));
        spi.tx_buf = reinterpret_cast<unsigned long>(tx_buffer);
        spi.rx_buf = reinterpret_cast<unsigned long>(rx_buffer);
        spi.len = length;
        spi.speed_hz = speed;
        spi.bits_per_word = bits_per_word;

        if (ioctl(fd, SPI_IOC_MESSAGE(1), &spi) == -1) {
            perror("Failed to transfer SPI message");
            exit(1);
        }
    }

private:
    int fd;
    uint32_t speed;
    uint8_t mode;
    uint8_t bits_per_word = 8;
};

int main() {
    string device = "/dev/spidev0.0"; // 根据需要修改设备路径
    uint8_t mode = SPI_MODE_0;
    uint32_t speed = 500000; // 500kHz

    SPI spi(device, mode, speed);

    uint8_t tx_buffer[] = {0x01, 0x02, 0x03, 0x04, 0x05};
    uint8_t rx_buffer[sizeof(tx_buffer)];

    while (true) {
        spi.transfer(tx_buffer, rx_buffer, sizeof(tx_buffer));
        cout << "Sent data: ";
        for (size_t i = 0; i < sizeof(tx_buffer); ++i) {
            cout << "0x" << hex << static_cast<int>(tx_buffer[i]) << " ";
        }
        cout << endl;

        cout << "Received data: ";
        for (size_t i = 0; i < sizeof(rx_buffer); ++i) {
            cout << "0x" << hex << static_cast<int>(rx_buffer[i]) << " ";
        }
        cout << endl;

        sleep(1); // 每秒进行一次通信
    }

    return 0;
}

编译和运行:

g++ -o spi_control spi_control.cpp
sudo ./spi_control

3. 程序封装

将上述的GPIO和SPI程序优化,并放置在工程文件夹下,方便后续调用。

1) 新建一个工程文件夹,名为TFT_SHOW,并包括文件夹include和src,后面创建的文件夹结构如下。

project_root/
├── include/
│   ├── GPIO.h
│   └── SPI.h
│   └── TFT.h
├── src/
│   ├── GPIO.cpp
│   ├── SPI.cpp
│   ├── TFT.cpp
│   └── main.cpp
├── CMakeLists.txt

2) 将上述GPIO优化,包括GPIO.h放在include中,GPIO.cpp放在src中。

#ifndef GPIO_H
#define GPIO_H

#include <string>

class GPIO {
public:
    enum Direction {
        IN,
        OUT
    };

    GPIO(int pin, Direction direction);
    ~GPIO();
    void setDirection(Direction direction);
    void setValue(int value);
    int getValue();

private:
    int pinNumber;
    void exportGPIO();
    void unexportGPIO();
    std::string directionToString(Direction direction);
};

#endif // GPIO_H
#include "GPIO.h"
#include <fstream>
#include <iostream>
#include <unistd.h>

using namespace std;

GPIO::GPIO(int pin, Direction direction) : pinNumber(pin) {
    exportGPIO();
    setDirection(direction);
}

GPIO::~GPIO() {
    unexportGPIO();
}

void GPIO::setDirection(Direction direction) {
    ofstream gpioDirectionFile("/sys/class/gpio/gpio" + to_string(pinNumber) + "/direction");
    if (gpioDirectionFile.is_open()) {
        gpioDirectionFile << directionToString(direction);
        gpioDirectionFile.close();
    } else {
        cerr << "Unable to set direction for GPIO" << endl;
    }
}

void GPIO::setValue(int value) {
    ofstream gpioValueFile("/sys/class/gpio/gpio" + to_string(pinNumber) + "/value");
    if (gpioValueFile.is_open()) {
        gpioValueFile << value;
        gpioValueFile.close();
    } else {
        cerr << "Unable to set value for GPIO" << endl;
    }
}

int GPIO::getValue() {
    ifstream gpioValueFile("/sys/class/gpio/gpio" + to_string(pinNumber) + "/value");
    int value = -1;
    if (gpioValueFile.is_open()) {
        gpioValueFile >> value;
        gpioValueFile.close();
    } else {
        cerr << "Unable to get value for GPIO" << endl;
    }
    return value;
}

void GPIO::exportGPIO() {
    ofstream gpioExportFile("/sys/class/gpio/export");
    if (gpioExportFile.is_open()) {
        gpioExportFile << pinNumber;
        gpioExportFile.close();
    } else {
        cerr << "Unable to export GPIO" << endl;
    }
    usleep(100000); // 等待 GPIO 文件系统创建
}

void GPIO::unexportGPIO() {
    ofstream gpioUnexportFile("/sys/class/gpio/unexport");
    if (gpioUnexportFile.is_open()) {
        gpioUnexportFile << pinNumber;
        gpioUnexportFile.close();
    } else {
        cerr << "Unable to unexport GPIO" << endl;
    }
}

string GPIO::directionToString(Direction direction) {
    return (direction == IN) ? "in" : "out";
}

3) 将上述SPI优化,包括SPI.h放在include中, SPI.cpp放在scr中。

#ifndef SPI_H
#define SPI_H

#include <string>
#include <cstdint>
#include <cstddef>

class SPI {
public:
    SPI(const std::string& device, uint8_t mode, uint32_t speed);
    ~SPI();
    void transfer(uint8_t* tx_buffer, uint8_t* rx_buffer, size_t length);

private:
    int fd;
    uint32_t speed;
    uint8_t mode;
    uint8_t bits_per_word = 8;
};

#endif // SPI_H
#include "SPI.h"
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>
#include <cstring>
#include <iostream>

using namespace std;

SPI::SPI(const std::string& device, uint8_t mode, uint32_t speed) {
    fd = open(device.c_str(), O_RDWR);
    if (fd < 0) {
        perror("Failed to open SPI device");
        exit(1);
    }

    // 设置 SPI 模式
    if (ioctl(fd, SPI_IOC_WR_MODE, &mode) == -1) {
        perror("Failed to set SPI mode");
        exit(1);
    }

    // 设置 SPI 速度
    if (ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed) == -1) {
        perror("Failed to set SPI speed");
        exit(1);
    }

    this->speed = speed;
    this->mode = mode;
}

SPI::~SPI() {
    close(fd);
}

void SPI::transfer(uint8_t* tx_buffer, uint8_t* rx_buffer, size_t length) {
    struct spi_ioc_transfer spi;
    memset(&spi, 0, sizeof(spi));
    spi.tx_buf = reinterpret_cast<unsigned long>(tx_buffer);
    spi.rx_buf = reinterpret_cast<unsigned long>(rx_buffer);
    spi.len = length;
    spi.speed_hz = speed;
    spi.bits_per_word = bits_per_word;

    if (ioctl(fd, SPI_IOC_MESSAGE(1), &spi) == -1) {
        perror("Failed to transfer SPI message");
        exit(1);
    }
}

4)CMakeLists.txt

cmake_minimum_required(VERSION 3.10)

# 设置项目名称和版本
project(GPIOSPIControl VERSION 1.0)

# 指定 C++ 标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 包含头文件目录
include_directories(${PROJECT_SOURCE_DIR}/include)

# 查找所有源文件
file(GLOB SOURCES ${PROJECT_SOURCE_DIR}/src/*.cpp)

# 添加可执行文件
add_executable(main ${SOURCES})

# 链接必要的库
target_link_libraries(main pthread)

5)简单测试

在src中写一个main.cpp,内容如下,进行测试:

#include "GPIO.h"
#include "SPI.h"
#include <iostream>
#include <unistd.h>

using namespace std;
#define SPI_MODE_0 0

int main() {
    // GPIO 示例
    GPIO gpio(84, GPIO::IN); // 例如 GPIO2_20 对应的编号为 84

    while (true) {
        int value = gpio.getValue();
        cout << "GPIO Value: " << value << endl;
        sleep(1); // 每秒读取一次
    }

    // SPI 示例
    /*
    string device = "/dev/spidev0.0"; // 根据需要修改设备路径
    uint8_t mode = SPI_MODE_0;
    uint32_t speed = 500000; // 500kHz

    SPI spi(device, mode, speed);

    uint8_t tx_buffer[] = {0x01, 0x02, 0x03, 0x04, 0x05};
    uint8_t rx_buffer[sizeof(tx_buffer)];

    while (true) {
        spi.transfer(tx_buffer, rx_buffer, sizeof(tx_buffer));
        cout << "Sent data: ";
        for (size_t i = 0; i < sizeof(tx_buffer); ++i) {
            cout << "0x" << hex << static_cast<int>(tx_buffer[i]) << " ";
        }
        cout << endl;

        cout << "Received data: ";
        for (size_t i = 0; i < sizeof(rx_buffer); ++i) {
            cout << "0x" << hex << static_cast<int>(rx_buffer[i]) << " ";
        }
        cout << endl;

        sleep(1); // 每秒进行一次通信
    }
    */

    return 0;
}

编译和运行:

# 进入文件夹
cd /home/openEuler/TFT_SHOW/
# 创建build文件并编译
mkdir build
cd build
cmake ..
# 编译
make
# 运行
sudo ./main

4. 引脚连接

TFT显示屏引脚 <----------> OrangePi Kunpeng Pro引脚

GND <----------> GND

VCC <----------> 3.3V

SCL <----------> GPIO2_25(SPI0_SDO)

SDA <----------> GPIO2_27(SPI0_SCLK)

RES <----------> GPIO2_20(84)

DC <----------> GPIO4_00(128)

CS <----------> GPIO2_26(SPI0_CS)

BLK <----------> GPIO0_03(3)

5. TFT驱动程序撰写

include中添加一个tft.h,包括如下内容,根据开源的eSPI_TFT进行修改适配

#ifndef TFT_H
#define TFT_H

#include <iostream>
#include <string>
#include "GPIO.h"
#include "SPI.h"

using namespace std;

// 定义相关宏和命令
#define TFT_INIT_DELAY 0x80
#define TFT_NOP 0x00
#define TFT_SWRST 0x01
#define TFT_SLPIN 0x10
#define TFT_SLPOUT 0x11
#define TFT_NORON 0x13
#define TFT_INVOFF 0x20
#define TFT_INVON 0x21
#define TFT_DISPOFF 0x28
#define TFT_DISPON 0x29
#define TFT_CASET 0x2A
#define TFT_PASET 0x2B
#define TFT_RAMWR 0x2C
#define TFT_RAMRD 0x2E
#define TFT_MADCTL 0x36
#define TFT_COLMOD 0x3A

// 其他宏定义
#define TFT_MAD_MY 0x80
#define TFT_MAD_MX 0x40
#define TFT_MAD_MV 0x20
#define TFT_MAD_ML 0x10
#define TFT_MAD_RGB 0x00
#define TFT_MAD_BGR 0x08
#define TFT_MAD_MH 0x04
#define TFT_MAD_SS 0x02
#define TFT_MAD_GS 0x01

#ifdef TFT_RGB_ORDER
#if (TFT_RGB_ORDER == 1)
#define TFT_MAD_COLOR_ORDER TFT_MAD_RGB
#else
#define TFT_MAD_COLOR_ORDER TFT_MAD_BGR
#endif
#else
#ifdef CGRAM_OFFSET
#define TFT_MAD_COLOR_ORDER TFT_MAD_BGR
#else
#define TFT_MAD_COLOR_ORDER TFT_MAD_RGB
#endif
#endif

#define TFT_IDXRD 0x00
#define ST_CMD_DELAY 0x80
#define ST7789_240x240_XSTART 0
#define ST7789_240x240_YSTART 0

// ST7789 特定命令
#define ST7789_NOP 0x00
#define ST7789_SWRESET 0x01
#define ST7789_RDDID 0x04
#define ST7789_RDDST 0x09
#define ST7789_RDDPM 0x0A
#define ST7789_RDD_MADCTL 0x0B
#define ST7789_RDD_COLMOD 0x0C
#define ST7789_RDDIM 0x0D
#define ST7789_RDDSM 0x0E
#define ST7789_RDDSR 0x0F
#define ST7789_SLPIN 0x10
#define ST7789_SLPOUT 0x11
#define ST7789_PTLON 0x12
#define ST7789_NORON 0x13
#define ST7789_INVOFF 0x20
#define ST7789_INVON 0x21
#define ST7789_GAMSET 0x26
#define ST7789_DISPOFF 0x28
#define ST7789_DISPON 0x29
#define ST7789_CASET 0x2A
#define ST7789_RASET 0x2B
#define ST7789_RAMWR 0x2C
#define ST7789_RGBSET 0x2D
#define ST7789_RAMRD 0x2E
#define ST7789_PTLAR 0x30
#define ST7789_VSCRDEF 0x33
#define ST7789_TEOFF 0x34
#define ST7789_TEON 0x35
#define ST7789_MADCTL 0x36
#define ST7789_IDMOFF 0x38
#define ST7789_IDMON 0x39
#define ST7789_RAMWRC 0x3C
#define ST7789_RAMRDC 0x3E
#define ST7789_COLMOD 0x3A

// 其他定义
#define TFT_BGR 0
#define TFT_RGB 1
#define ST7789_2_DRIVER
#define TFT_RGB_ORDER TFT_RGB
#define TFT_WIDTH 240
#define TFT_HEIGHT 135

// 定义引脚
#define TFT_DC 128
#define TFT_RST 84
#define TFT_BL 3

class TFT_ST7789
{
public:
    uint colstart = 40;
    uint rowstart = 53;

    TFT_ST7789(SPI &spi, GPIO &dc, GPIO &rst, GPIO &bl);
    int tft_init(uint8_t r = 0, uint8_t g = 0, uint8_t b = 0);
    void tft_deinit();
    void tft_transRotation();
    void tft_invertDisplay(bool i);
    uint16_t color565(uint8_t r, uint8_t g, uint8_t b);
    void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color);
    void fillScreen(uint32_t color);
    void pushImage(int32_t x, int32_t y, int32_t w, int32_t h, uint16_t *data);
    void tft_drawrgb(uint8_t *rgb, uint32_t len);
    void tft_drawrgb(uint8_t *r, uint8_t *g, uint8_t *b, uint32_t len);
#ifdef TFT_OPENCV
    void tft_drawrgb(Vec3b *rgb, uint32_t len);
    void tft_drawjpg(string path, uint16_t *dat);
    void tft_drawjpg(Mat &img, uint16_t *dat);
    void tft_drawjpg(string path);
    void tft_drawjpg(Mat &img);
#endif
    void tft_drawbgr(uint8_t *bgr, uint32_t len);
    void tft_drawbgr(uint8_t *r, uint8_t *g, uint8_t *b, uint32_t len);
#ifdef TFT_OPENCV
    void tft_drawbgr(Vec3b *bgr, uint32_t len);
#endif

private:
    void pin_init();
    void st7789_init();
    void tft_commandList(const uint8_t *addr);
    uint8_t spi_read_write(uint8_t send_data);
    void spi_writenb(const char *tbuf, uint32_t len);
    void tft_Write_8(uint8_t dat);
    void tft_Write_16(uint16_t C);
    void tft_Write_16(const uint16_t *C, uint32_t len);
    void tft_Write_16(uint16_t C, uint32_t len);
    void tft_Write_32(uint32_t C);
    void tft_Write_32C(uint16_t C, uint16_t D);
    void tft_Write_32D(uint32_t C);
    void tft_writecmd(uint8_t c);
    void tft_writedat(uint8_t d);
    void pushPixels(const void *data_in, uint32_t len);
    void setWindow(int32_t x0, int32_t y0, int32_t x1, int32_t y1);
    void pushBlock(uint16_t color, uint32_t len);

    SPI &spi;
    GPIO &dc;
    GPIO &rst;
    GPIO &bl;
};

#endif // TFT_H
在src中添加一个tft.cpp文件
#include "TFT.h"
#include <unistd.h>
#include <iostream>

using namespace std;

TFT_ST7789::TFT_ST7789(SPI &spi, GPIO &dc, GPIO &rst, GPIO &bl) : spi(spi), dc(dc), rst(rst), bl(bl)
{
    pin_init();
}

void TFT_ST7789::pin_init()
{
    rst.setValue(1);
    dc.setValue(1);
    bl.setValue(1);
}

int TFT_ST7789::tft_init(uint8_t r, uint8_t g, uint8_t b)
{
    pin_init();
    rst.setValue(1);
    usleep(5000);
    rst.setValue(0);
    usleep(20000);
    rst.setValue(1);
    usleep(150000);
    st7789_init();

    tft_transRotation();
    fillScreen(color565(r, g, b));

    return 0;
}

void TFT_ST7789::st7789_init()
{
    static const uint8_t st7789[] = {
        8,
        TFT_SLPOUT, TFT_INIT_DELAY, 255,
        TFT_COLMOD, 1 + TFT_INIT_DELAY, 0x55, 10,
        TFT_MADCTL, 1, 0x00,
        TFT_CASET, 4, 0x00, 0x00, 0x00, 0xF0,
        TFT_PASET, 4, 0x00, 0x00, 0x00, 0xF0,
        TFT_INVON, TFT_INIT_DELAY, 10,
        TFT_NORON, TFT_INIT_DELAY, 10,
        TFT_DISPON, TFT_INIT_DELAY, 255};
    tft_commandList(st7789);
}

void TFT_ST7789::tft_commandList(const uint8_t *addr)
{
    uint8_t numCommands = *(addr++);
    uint8_t numArgs;
    uint8_t ms;

    while (numCommands--)
    {
        tft_writecmd(*(addr++));
        numArgs = *(addr++);
        ms = numArgs & TFT_INIT_DELAY;
        numArgs &= ~TFT_INIT_DELAY;

        while (numArgs--)
        {
            tft_writedat(*(addr++));
        }

        if (ms)
        {
            ms = *(addr++);
            usleep((ms == 255 ? 500 : ms) * 1000);
        }
    }
}

void TFT_ST7789::tft_transRotation()
{
    tft_writecmd(TFT_MADCTL);
    tft_writedat(TFT_MAD_MX | TFT_MAD_MV | TFT_MAD_COLOR_ORDER);
}

void TFT_ST7789::tft_invertDisplay(bool i)
{
    tft_writecmd(i ? TFT_INVON : TFT_INVOFF);
    tft_writecmd(i ? TFT_INVON : TFT_INVOFF);
}

void TFT_ST7789::tft_deinit()
{
    // 反初始化过程
}

uint16_t TFT_ST7789::color565(uint8_t r, uint8_t g, uint8_t b)
{
    return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

void TFT_ST7789::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color)
{
    setWindow(x, y, x + w - 1, y + h - 1);
    pushBlock(color, w * h);
}

void TFT_ST7789::fillScreen(uint32_t color)
{
    fillRect(0, 0, TFT_WIDTH, TFT_HEIGHT, color);
}

void TFT_ST7789::pushImage(int32_t x, int32_t y, int32_t w, int32_t h, uint16_t *data)
{
    setWindow(x, y, x + w - 1, y + h - 1);
    pushPixels(data, w * h);
}

void TFT_ST7789::pushPixels(const void *data_in, uint32_t len)
{
    uint16_t *data = (uint16_t *)data_in;
    tft_writecmd(TFT_RAMWR);
    for (uint32_t i = 0; i < len; i++)
    {
        tft_Write_16(data[i]);
    }
}

void TFT_ST7789::pushBlock(uint16_t color, uint32_t len)
{
    tft_writecmd(TFT_RAMWR);
    for (uint32_t i = 0; i < len; i++)
    {
        tft_Write_16(color);
    }
}

void TFT_ST7789::tft_drawrgb(uint8_t *rgb, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawrgb(uint8_t *r, uint8_t *g, uint8_t *b, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(r[i], g[i], b[i]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

#ifdef TFT_OPENCV
void TFT_ST7789::tft_drawrgb(Vec3b *rgb, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(rgb[i][0], rgb[i][1], rgb[i][2]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawjpg(string path, uint16_t *dat)
{
    Mat img = imread(path);
    Mat imgResize;
    resize(img, imgResize, Size(TFT_WIDTH, TFT_HEIGHT));
    Scalar color;
    for (int i = 0; i < TFT_HEIGHT; i++)
    {
        for (int j = 0; j < TFT_WIDTH; j++)
        {
            color = imgResize.at<Vec3b>(i, j);
            dat[i * TFT_WIDTH + j] = color565(color[2], color[1], color[0]);
        }
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawjpg(Mat &img, uint16_t *dat)
{
    Mat imgResize;
    resize(img, imgResize, Size(TFT_WIDTH, TFT_HEIGHT));
    Scalar color;
    for (int i = 0; i < TFT_HEIGHT; i++)
    {
        for (int j = 0; j < TFT_WIDTH; j++)
        {
            color = imgResize.at<Vec3b>(i, j);
            dat[i * TFT_WIDTH + j] = color565(color[2], color[1], color[0]);
        }
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawjpg(string path)
{
    uint16_t dat[TFT_WIDTH * TFT_HEIGHT];
    tft_drawjpg(path, dat);
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawjpg(Mat &img)
{
    uint16_t dat[TFT_WIDTH * TFT_HEIGHT];
    tft_drawjpg(img, dat);
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}
#endif

void TFT_ST7789::tft_drawbgr(uint8_t *bgr, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(bgr[i * 3 + 2], bgr[i * 3 + 1], bgr[i * 3 + 0]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

void TFT_ST7789::tft_drawbgr(uint8_t *b, uint8_t *g, uint8_t *r, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(r[i], g[i], b[i]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}

#ifdef TFT_OPENCV
void TFT_ST7789::tft_drawbgr(Vec3b *bgr, uint32_t len)
{
    if (TFT_WIDTH * TFT_HEIGHT != len)
        return;
    uint16_t dat[len];
    for (uint32_t i = 0; i < len; i++)
    {
        dat[i] = color565(bgr[i][2], bgr[i][1], bgr[i][0]);
    }
    pushImage(0, 0, TFT_WIDTH, TFT_HEIGHT, dat);
}
#endif

void TFT_ST7789::tft_writecmd(uint8_t c)
{
    dc.setValue(0); // Command mode
    spi.transfer(&c, nullptr, 1);
    dc.setValue(1); // Data mode
}

void TFT_ST7789::tft_writedat(uint8_t d)
{
    dc.setValue(1); // Data mode
    spi.transfer(&d, nullptr, 1);
}

void TFT_ST7789::setWindow(int32_t x0, int32_t y0, int32_t x1, int32_t y1)
{
    int32_t addr_row = 0xFFFF;
    int32_t addr_col = 0xFFFF;

    x0 += colstart;
    x1 += colstart;
    y0 += rowstart;
    y1 += rowstart;

    tft_writecmd(TFT_CASET);
    tft_Write_32C(x0, x1);
    tft_writecmd(TFT_PASET);
    tft_Write_32C(y0, y1);
    tft_writecmd(TFT_RAMWR);
}

void TFT_ST7789::tft_Write_8(uint8_t dat)
{
    spi.transfer(&dat, nullptr, 1);
}

void TFT_ST7789::tft_Write_16(uint16_t C)
{
    uint8_t data[2] = {static_cast<uint8_t>(C >> 8), static_cast<uint8_t>(C & 0xFF)};
    spi.transfer(data, nullptr, 2);
}

void TFT_ST7789::tft_Write_16(const uint16_t *C, uint32_t len)
{
    for (uint32_t i = 0; i < len; i++)
    {
        tft_Write_16(C[i]);
    }
}

void TFT_ST7789::tft_Write_16(uint16_t C, uint32_t len)
{
    for (uint32_t i = 0; i < len; i++)
    {
        tft_Write_16(C);
    }
}

void TFT_ST7789::tft_Write_32(uint32_t C)
{
    uint8_t data[4] = {
        static_cast<uint8_t>(C >> 24),
        static_cast<uint8_t>(C >> 16),
        static_cast<uint8_t>(C >> 8),
        static_cast<uint8_t>(C & 0xFF)};
    spi.transfer(data, nullptr, 4);
}

void TFT_ST7789::tft_Write_32C(uint16_t C, uint16_t D)
{
    uint8_t data[4] = {
        static_cast<uint8_t>(C >> 8),
        static_cast<uint8_t>(C & 0xFF),
        static_cast<uint8_t>(D >> 8),
        static_cast<uint8_t>(D & 0xFF)};
    spi.transfer(data, nullptr, 4);
}

void TFT_ST7789::tft_Write_32D(uint32_t C)
{
    uint8_t data[4] = {
        static_cast<uint8_t>(C >> 24),
        static_cast<uint8_t>(C >> 16),
        static_cast<uint8_t>(C >> 8),
        static_cast<uint8_t>(C & 0xFF)};
    spi.transfer(data, nullptr, 4);
}

6. 测试程序

在src中添加一个main.cpp文件,添加如下代码:

#include "TFT.h"
#include "GPIO.h"
#include "SPI.h"
#include <iostream>
#include <unistd.h>

using namespace std;

#define SPI_MODE_0 0

int main() {
    // 初始化 GPIO 和 SPI
    GPIO dc(128, GPIO::OUT);
    GPIO rst(84, GPIO::OUT);
    GPIO bl(3, GPIO::OUT);

    SPI spi("/dev/spidev0.0", SPI_MODE_0, 25000000);    // 设置最高25M

    // 初始化 TFT 显示屏
    TFT_ST7789 tft(spi, dc, rst, bl);
    if (tft.tft_init() != 0) {
        cerr << "Failed to initialize TFT" << endl;
        return -1;
    }

    cout << "TFT initialized successfully" << endl;
    // 基本测试:填充屏幕颜色
    cout << "Filling screen with red color" << endl;
    tft.fillScreen(tft.color565(255, 0, 0)); // 红色
    sleep(2);
    cout << "Filling screen with green color" << endl;
    tft.fillScreen(tft.color565(0, 255, 0)); // 绿色
    sleep(2);
    cout << "Filling screen with blue color" << endl;
    tft.fillScreen(tft.color565(0, 0, 255)); // 蓝色
    sleep(2);

    // 刷新屏幕
    tft.tft_deinit();
    cout << "TFT test completed" << endl;
    
    return 0;
}

7. 运行效果

运行程序后,屏幕依次刷新为黑色、红色、绿色、蓝色,最后停在蓝色。测试过程中发现刷新的速度特别慢,即使将SPI的速率改为最高25M,也难以满足正常刷新要求。应该是SPI驱动库没有选对,后续有机会再进行优化。

六、评测观点

1. 可以夸夸的地方

性能强悍:相比于之前使用的树莓派4B和4B,桌面流畅度和网页浏览提升很多。

接口丰富:开发板没有为了卡片式而过分缩减接口,有两个标准的HDMI接口、背面完整长度的M2接口和EMMC接口、typeC形式的USB3接口。

主动散热:性能强悍带来的是功耗爆炸,主动可调散热让性能释放更加自由。

2. 值得改进的地方

系统资源目前官方安装的系统为openEuler,还未有更多专用适配的系统例如Ubuntu、安卓等。

官方案例:手册很完善,但是官方示例较少,用户难以快速体验到开发板的优势。

引脚驱动:手册中提供了gpio_operate方式进行gpio操作,但是针对python、C、C++等语言的支持库不完善。

3. 最后说说

非常荣幸能够获得测评OrangePi Kunpeng Pro的机会,在测试过程中也尽可能将以往的一些小代码应用在这块优秀的开发板中,最后也都成功实现了,实属不易。相信随着加入OrangePi Kunpeng Pro的开发者增多,官方支持的加大,OrangePi Kunpeng Pro将会越来越好,毕竟,性能高的底子还是有的。

  • 23
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值