1. 项目节点

红外项目代码

功能SDK全局变量名称测试项完成情况
伪彩切换
环境温度
测温距离
发射率
量程
报警功能
ISR(分辨率切换)

红外项目节点

菜单对应的各项功能

伪彩切换

直接切换伪彩。

融合模式

切换可见光和红外的模式

模式介绍
只可见光只把摄像头数据流放到界面上
可见光+红外融合(全屏)红外降低透明度叠加在可见光摄像头上
可见光+红外融合(画中画)红外降低透明度叠加在可见光摄像头上,但红外只占中间一部分。
只红外只显示红外,可见光不显示

图片

视频

设置

基础功能

功能值描述
语言切换可以切换
单位切换°C,K,°F温度单位切换功能,切换之后主界面上显示的最高温,最低温的单位随之变化(摄氏度,华氏度,开尔文)
亮度高中低高中低,三档调节。
U盘模式Switch切换为u盘模式。把设备上接的sd卡接到电脑。开启/关闭
照明灯Switch操作板子上面的led灯。开启/关闭
时间日期设置系统时间,还需要设置rtc时间。日期格式切换,年月日,日月年。时间格式切换,24小时制,12小时制。
显示主界面靶子是否显示:中心点,最高点,最低点。主界面是否显示:充电图标,时间,发射率,距离。
恢复出厂修改出厂时期的红外数据。以及各种设置:语言,单位,显示,日期格式等。
格式化格式化SD卡
关于设备打开之后可以看到:型号,固件版本,应用版本,SN号,当前内存卡的容量(使用量和总量)。在关于设备长按5s的up键进入工厂模式。

红外功能

功能SDK全局变量名称测试项完成情况
参数-->环境温度获取和修改
参数-->测温距离获取和修改
参数-->发射率获取和修改
量程
ISR(分辨率切换)
报警
双光校验
图像调节

图片解析需求:

​ 技术分析:JPEG (jpg图片)标准允许 EOI 结束符后有附加数据(解码器忽略,普通查看工具不受影响)

视频解析需求:

​ 技术分析:视频编码标准(如H.264/H.265)自带的补充增强信息(SEI) 机制来实现。Rockchip的硬件编码器支持SEI机制

适配mipi摄像头

将5个按钮映射到内核按钮里面

usb摄像头:

可见光摄像头:

5个按钮:上,下,ok,back,照相

屏幕:2.4寸,RGB屏幕

。。。略

408

计算机网络

TCP/IP模型是基于广泛应用的互联网协议族(TCP/IP协议栈)而制定的,是现代互联网的事实标准。它更加精简,通常被划分为5层(为便于教学,将链路层和物理层分开)或4层。

5层模型(最常用于教学和描述)

  1. 物理层:与OSI物理层完全相同。
  2. 数据链路层:与OSI数据链路层基本相同。
  3. 网络层:对应OSI的网络层。核心协议是IP。
  4. 传输层:对应OSI的传输层。核心协议是TCP和UDP。
  5. 应用层:融合了OSI的应用层、表示层和会话层的功能。所有面向用户的应用程序和协议都在这一层。

4层模型(TCP/IP原模型)

  1. 网络接口层: 对应OSI的物理层+数据链路层。
  2. 网络层: 对应OSI的网络层。
  3. 传输层: 对应OSI的传输层。
  4. 应用层: 对应OSI的应用层+表示层+会话层。

第1层:物理层

  • 功能:负责在物理媒介(如光纤、双绞线、无线电波)上透明地传输原始比特流(0和1)。定义电气、机械、时序和接口标准。
  • 关键设备/协议:中继器、集线器;物理接口(如RJ-45)。

第2层:数据链路层

  • 功能:在相邻节点(直接相连的设备)之间进行可靠的数据帧传输。负责物理寻址(MAC地址)、差错控制、流量控制和介质访问控制。
  • 关键协议/设备:以太网(Ethernet)、Wi-Fi(802.11)、PPP;交换机、网桥。

第3层:网络层

  • 功能:负责数据包从源到目的地的逻辑寻址和路径选择(路由)。实现不同网络之间的通信。
  • 关键协议/设备:IP协议(IPv4/IPv6)、ICMP、OSPF、BGP;路由器。

第4层:传输层

  • 功能:提供端到端的可靠或不可靠的数据传输服务。负责分段、重组、流量控制、差错控制和端口寻址。
  • 关键协议:TCP(可靠的、面向连接的)、UDP(不可靠的、无连接的)。

第5层:会话层

  • 功能:负责建立、管理和终止应用进程之间的会话(Session)。在通信实体之间进行对话控制(全双工、半双工)和同步。
  • 例子:RPC(远程过程调用)、SSH会话的建立/断开。

第6层:表示层

  • 功能:处理两个系统间交换信息的语法和语义问题。负责数据格式转换、加密解密、压缩解压缩。
  • 例子:将数据从应用层格式转换为网络标准格式(如ASCII与EBCDIC码转换)、SSL/TLS加密、JPEG图像编码。

第7层:应用层

  • 功能:最靠近用户的一层,为应用程序提供网络服务接口。提供用户可直接使用的网络服务。
  • 关键协议:HTTP、HTTPS、FTP、SMTP/POP3/IMAP、DNS、DHCP。

C#

  • C# 是“语言”:它是一种优雅、现代、面向对象的编程语言,是你在代码中与人沟通、表达想法的工具。
  • .NET 是“平台”:它是一个由微软开发的开发平台,包含了一套庞大的代码库(类库)和一个能运行代码的“虚拟机”(运行时)。它为C#等语言提供了一个功能丰富、安全可控的运行环境。

1. 开端:.NET Framework (2002-2019)

这是 .NET 的第一个版本,专为 Windows 操作系统设计。它功能强大,与Windows系统深度绑定,但无法在Linux或macOS上运行。它的最终版本是 .NET Framework 4.8.1,目前微软已停止为其开发新功能,仅提供安全维护。

2. 革新:.NET Core (2016-2019)

为了解决跨平台问题,微软推出了 .NET Core。它是一个开源、跨平台(支持Windows、macOS、Linux)的现代化框架,是微软未来的主要投资方向。但它并非 .NET Framework 的直接升级,而是一次重新设计,因此早期版本功能尚不完善。

3. 统一:.NET 5 及以后 (2020-至今)

从 .NET 5 开始,微软统一了命名,不再有“Core”字样。这个版本是 .NET Framework 和 .NET Core 的集大成者,目标是打造一个统一、现代的开发平台。

此后,微软保持每年11月发布一个新的大版本的节奏。当前最新的长期支持版本(LTS)是 .NET 8,于2023年11月发布,支持到2026年。未来版本如 .NET 9 也在积极开发中。

.NET 的核心组成

无论是早期的 .NET Framework 还是现代的 .NET (Core),其核心都包含两个关键部分:

  1. 公共语言运行时 (CLR):.NET的“虚拟机”,负责管理内存、执行代码、处理异常和垃圾回收等。
  2. .NET 类库:一个庞大的、预先编写好的代码库,提供了文件读写、网络通信、数据库访问等几乎所有你可能需要的功能,让开发者不必“从零开始”

C makefile cmake

C++

C base

指针就是地址。把它当成一个地址就好

一级指针

void fun1(int a)
{
	a = 10;
}
int b = 0;
void fun2(int *a) //传入一个:int类型的指针,意思是传入一个地址。
{
	*a = 10; //解引用,将
    a = &b;
}
int x = 1;
fun1(x);//不会改变x的值
int *p = &x;//一但取址,返回的就是这个变量的地址,也就是对应的指针
fun2(p);//会将x的值变成10

二级指针(传入一个指针,改成另一个指针)

int x=0,y=1;
void func(int **a)
{
	*a = &y;
    **a = 10;//把y变成10
}
int *p = &y;      // p 指向 y
func(&p);         // 传入 p 的地址

func(int **a) 里的 a、*a、**a

表达式类型实际含义当前值
aint **p 的地址(即 &p)假设为 0x200
*aint *指针变量 p 本身&y(假设 0x100)
**aint最终指向的整数y 的值,即 1

对象创建

C 没有 new,对象创建 = 分配内存 + 初始化,分两种情况:

分配位置写法对应 Java
栈Point p; Point_init(&p, ...);—
堆Point* p = Point_new(...);new Point(...)

结构体定义

typedef struct {
    char name[32];
    int x;
    int y;
} Point;

初始化函数(不分配内存,只写数据)

void Point_init(Point* p, const char* name, int x, int y) {
    strncpy(p->name, name, sizeof(p->name) - 1);
    p->name[sizeof(p->name) - 1] = '\0';
    p->x = x;
    p->y = y;
}

构造器(分配 + 初始化)

Point* Point_new(const char* name, int x, int y) {
    Point* p = malloc(sizeof(Point));     // 堆上分配
    if (p) {
        Point_init(p, name, x, y);        // 写入数据
    }
    return p;
}

void Point_del(Point* p) {
    free(p);
}

使用示例

/* 栈 —— 已划好空间,但内容是垃圾数据,必须 init 后才能用 */
Point p1;
Point_init(&p1, "stack", 10, 20);

/* 堆 —— Point_new 内部 = malloc + Point_init */
Point* p2 = Point_new("heap", 30, 40);
Point_del(p2);

关键理解:

  • Point p1; 只是在栈上划了 sizeof(Point) 字节的空间,里面的值是未定义的(垃圾数据),必须初始化后才能用。
  • Point_init 不分配内存,它只负责往你传入的地址写入数据。内存来自栈还是堆,它不关心。
  • Point_new 内部 = malloc + Point_init,这就是 C 的"构造器"。
  • Java 的 new Point(...) 把"分配"和"初始化"合为一步;C 拆成两步,好处是可以在栈上分配、可以复用已有内存。

封装

隐藏内部数据,只暴露接口函数。

"头文件"暴露的内容

point.h

typedef struct Point point;  // 前向声明,不暴露成员

/ 公开的接口函数
Point* point_new(const char* name, int x, int y);
void point_del(Point* p);

// 只能通过函数访问数据,外部无法直接读写 p->x
void point_set_x(Point* p, int x);
int point_get_x(const Point* p);

void point_set_y(Point* p, int y);
int point_get_y(const Point* p);
//方法(函数)
void point_print(const Point* p);

"实现" —— 结构体完整定义对外不可见

point.c

struct Point {
    char   owner[32];/* 外部无法直接访问 */
    int    x;        /* 完全隐藏 */
    int    y;  		 /* 对外不可见 */
};

函数实现

point.c

// 构造函数
Point* point_new(const char* name, int x, int y) {
    Point* p = malloc(sizeof(Point));
    if (p) {
        strncpy(p->name, name, sizeof(p->name) - 1);
        p->name[sizeof(p->name) - 1] = '\0';
        p->x = x;
        p->y = y;
    }
    return p;
}

// 析构函数
void point_del(Point* p) {
    free(p);
}

// 封装后的 Setter / Getter(带合法性检查)
void point_set_x(Point* p, int x) {
    if (p) p->x = x;  // 可以在这里加范围限制,如 if(x < 0) return;
}

int point_get_x(const Point* p) {
    return p ? p->x : 0;
}

void point_set_y(Point* p, int y) {
    if (p) p->y = y;
}

int point_get_y(const Point* p) {
    return p ? p->y : 0;
}

void point_print(const Point* p) {
    if (p) {
        printf("Point(%s, %d, %d)\n", p->name, p->x, p->y);
    }
}

使用者视角

#include "point.h"

int main() {
    // Point p1;  // 这行会编译报错!因为外部不知道 Point 的大小,无法在栈上分配
    Point* p = point_new("origin", 0, 0); // 只能通过堆分配

    point_set_x(p, 100);
    // p->x = 200;  // 编译报错!提示:dereferencing pointer to incomplete type(不完整类型)

    point_print(p);
    point_del(p);
    return 0;
}

C 的封装靠"文件作用域 + 不透明指针"实现,而非 private 关键字,比如私有方法用static修饰(文件作用域),再比如typedef struct Point point;修饰的结构体其他地方通过构建函数拿到返回的指针是不能直接拿里面的变量的,因为其他地方都不知道这个Point 有什么变量和方法(不透明指针)

继承

核心技巧:基类作为子类第一个字段。

C 标准保证:结构体首字段的地址 == 结构体本身的地址,所以 (Vehicle*)&car 转换是安全的。

基类

typedef struct {
    char brand[32];
    int  year;
} Vehicle;

void Vehicle_init(Vehicle* v, const char* brand, int year) {
    strncpy(v->brand, brand, sizeof(v->brand) - 1);
    v->brand[sizeof(v->brand) - 1] = '\0';
    v->year = year;
}

void Vehicle_print(const Vehicle* v) {
    printf("Vehicle{brand=%s, year=%d}", v->brand, v->year);
}

子类 Car(继承 Vehicle)

typedef struct {
    Vehicle base;       /* 继承 —— 首字段就是基类 */
    int     doors;      /* 扩展字段 */
} Car;

void Car_init(Car* c, const char* brand, int year, int doors) {
    Vehicle_init(&c->base, brand, year);  // 先初始化"父类部分"
    c->doors = doors;                     // 再初始化自己
}

void Car_print(const Car* c) {
    printf("Car{brand=%s, year=%d, doors=%d}",
           c->base.brand, c->base.year, c->doors);
}

子类 Bike(继承 Vehicle)

typedef struct {
    Vehicle base;
    int     has_basket;
} Bike;

void Bike_init(Bike* b, const char* brand, int year, int has_basket) {
    Vehicle_init(&b->base, brand, year);
    b->has_basket = has_basket;
}

向上转型演示

int main(void) {
    Car  car;
    Bike bike;
    Car_init(&car,  "Toyota", 2020, 4);
    Bike_init(&bike, "Giant",  2023, 1);

    /* 向上转型:子类指针 → 基类指针(安全) */
    Vehicle* v1 = (Vehicle*)&car;
    Vehicle* v2 = (Vehicle*)&bike;

    Vehicle_print(v1);   // 输出基类共有的信息
    Vehicle_print(v2);
    return 0;
}

多态

核心技巧:虚函数表(vtable)—— 基类里放一个指向函数指针表的指针,不同子类填充不同的实现,调用时通过 vtable 间接跳转。

虚函数表 + 基类定义

// 虚函数表类型
typedef struct VTable VTable;
struct VTable {
    double (*area)(void* self);
    void   (*print)(void* self);
};

// 基类
typedef struct {
    const char* name;
    const VTable* vtable;      // 指向子类各自的虚函数表
} Shape;

多态调用入口(统一接口)

double Shape_area(void* self) {
    Shape* s = (Shape*)self;
    return s->vtable->area(self);    // 通过 vtable 跳转到子类实现
}

void Shape_print(void* self) {
    Shape* s = (Shape*)self;
    s->vtable->print(self);          // 通过 vtable 跳转到子类实现
}

子类 Circle

typedef struct {
    Shape  base;
    double radius;
} Circle;

static double Circle_area(void* self) {
    Circle* c = (Circle*)self;
    return M_PI * c->radius * c->radius;
}

static void Circle_print(void* self) {
    Circle* c = (Circle*)self;
    printf("Circle{radius=%.1f, area=%.1f}", c->radius, Circle_area(self));
}

// Circle 类的虚函数表(静态全局,所有实例共享)
static const VTable CIRCLE_VTABLE = {
    .area  = Circle_area,
    .print = Circle_print,
};

void Circle_init(Circle* c, const char* name, double radius) {
    c->base.name   = name;
    c->base.vtable = &CIRCLE_VTABLE;   // 绑定自己的 vtable
    c->radius      = radius;
}

子类 Rectangle

typedef struct {
    Shape base;
    double w, h;
} Rectangle;

static double Rect_area(void* self) {
    Rectangle* r = (Rectangle*)self;
    return r->w * r->h;
}

static void Rect_print(void* self) {
    Rectangle* r = (Rectangle*)self;
    printf("Rectangle{%.1f x %.1f, area=%.1f}", r->w, r->h, Rect_area(self));
}

static const VTable RECT_VTABLE = {
    .area  = Rect_area,
    .print = Rect_print,
};

void Rectangle_init(Rectangle* r, const char* name, double w, double h) {
    r->base.name   = name;
    r->base.vtable = &RECT_VTABLE;
    r->w = w;
    r->h = h;
}

子类 Triangle

typedef struct {
    Shape base;
    double a, b, c;
} Triangle;

static double Tri_area(void* self) {
    Triangle* t = (Triangle*)self;
    double s = (t->a + t->b + t->c) / 2;
    return sqrt(s * (s - t->a) * (s - t->b) * (s - t->c));
}

static void Tri_print(void* self) {
    Triangle* t = (Triangle*)self;
    printf("Triangle{sides=(%.1f,%.1f,%.1f), area=%.1f}",
           t->a, t->b, t->c, Tri_area(self));
}

static const VTable TRI_VTABLE = {
    .area  = Tri_area,
    .print = Tri_print,
};

void Triangle_init(Triangle* t, const char* name, double a, double b, double c) {
    t->base.name   = name;
    t->base.vtable = &TRI_VTABLE;
    t->a = a; t->b = b; t->c = c;
}

多态使用

void print_shape_info(void* self) {
    Shape_print(self);
    printf(" | area = %.1f\n", Shape_area(self));
}

int main(void) {
    Circle    c;
    Rectangle r;
    Triangle  t;

    Circle_init(&c, "c1", 5.0);
    Rectangle_init(&r, "r1", 4.0, 3.0);
    Triangle_init(&t, "t1", 3.0, 4.0, 5.0);

    // 通过 void* 数组统一管理,循环调用
    void* shapes[] = { &c, &r, &t };
    for (int i = 0; i < 3; i++) {
        print_shape_info(shapes[i]);   // 各自输出不同的 area
    }
    return 0;
}

运行结果

=== 多态:同一个函数,不同行为 ===
Circle{radius=5.0, area=78.5} | area = 78.5
Rectangle{4.0 x 3.0, area=12.0} | area = 12.0
Triangle{sides=(3.0,4.0,5.0), area=6.0} | area = 6.0

修饰函数

static修改的函数只能在当前 .c 文件可用,其他地方无法调用,就这一个作用,类似 “模块内部私有方法”

static void set_hello(void)
{
	printf("hello\n");
}
void main(void)
{
	set_hello();//本.c文件能用,外部用不了
}

修饰变量

在函数内部

作用域:只能在当前函数使用。第一次执行函数进行初始化,不像函数外程序加载就做初始化。

内存:静态存储区(不是栈!),非static修饰的函数中变量存储在栈中。

在函数外

作用域:只在前**.c**文件可用,也可以通过extern方式访问,可以做类似单例封装效果。

内存:静态存储区

extern 跨文件访问

所有的static修改的变量都不能通过extern进行访问

C 回调函数(函数指针)完整教程

目录

  1. 函数指针基础
  2. 回调模式
  3. 相关类型
  4. 回调变种
  5. 常用场景
  6. 底层原理
  7. 进阶:闭包模拟
  8. 练习

1. 函数指针基础

函数名就是地址

#include <stdio.h>

void hello(void)
{
    printf("Hello\n");
}

int main(void)
{
    printf("hello  = %p\n", hello);
    printf("&hello = %p\n", &hello);
    /* 输出一样,函数名就是地址 */
    return 0;
}

函数名和数组名一样,在表达式中隐式 decay 成地址。hello 和 &hello 值相同,类型不同。

声明一个函数指针

/* 声明一个指向 void(void) 的函数指针 */
void (*p)(void);

p = hello;       /* OK:hello decay 成地址 */
p = &hello;      /* OK:等价 */
(*p)();          /* 调用:显式解引用 */
p();             /* 调用:语法糖,等价 */

口诀:"变量名先跟 * 再跟 ()"

void (*p)(void);
     ^ ^
     | └── 圆括号表示"这是个函数指针"
     └──── 星号表示"这是个指针"

/* 如果不加括号:*/
void *p(void);   /* 这是个返回 void* 的函数,不是函数指针!*/

带参数的函数指针

void add(int a, int b) {
    printf("%d\n", a + b);
}

void (*p)(int, int) = add;
p(3, 4);  /* 输出 7 */

用 typedef 简化

/* 不用 typedef:每次写完整签名 */
void (*p1)(int, int) = add;
void (*p2)(int, int);
p2 = sub;

/* 用 typedef:变成类型名 */
typedef void (*op_func_t)(int, int);

op_func_t p1 = add;
op_func_t p2 = sub;

typedef 的读法:去掉 typedef 和末尾的名字,剩下的就是类型名

typedef void (*op_func_t)(int, int);
// 去掉 typedef        去掉名字
// 剩下:void (*)(int, int)  ← 这就是 op_func_t 代表的类型

2. 回调模式

回调是函数指针最常见的应用。分三步:

1. 定义一个函数指针类型(回调的"签名")
2. 接收方:存地址,在合适的时机调用
3. 调用方:传一个符合签名的函数进去

完整例子

#include <stdio.h>

/* 1. 定义回调类型 */
typedef void (*notify_t)(int code);

/* 2. 接收方:存回调 + 触发 */
static notify_t g_cb = NULL;

void register_cb(notify_t cb)
{
    g_cb = cb;            /* 存函数地址 */
}

static void do_something(void)
{
    /* 一些操作... */
    if (g_cb) {
        g_cb(42);         /* 回调通知 */
    }
}

/* 3. 调用方:写一个匹配的函数 */
static void my_handler(int code)
{
    printf("code = %d\n", code);
}

int main(void)
{
    register_cb(my_handler);   /* 注册 */
    do_something();            /* 触发 */
    return 0;
}

内存分布

BSS/数据段               .text 段
────────────             ──────────────────────
g_cb = 0x401234  ──────→ my_handler:
                            push  rbp
                            mov   esi, edi
                            call  printf
                            pop   rbp
                            ret

执行 g_cb(42):
  1. 从 g_cb 读取 0x401234
  2. 将 42 存入 rdi(参数寄存器)
  3. call 0x401234
  4. 执行 my_handler 的机器码
  5. ret 回到调用点

3. 相关类型

typedef 的几种常用形式:

/* 最简单的:无参数无返回值 */
typedef void (*cb_t)(void);

/* 带参数 */
typedef void (*cb_t)(int code);
typedef void (*cb_t)(int code, void *user_data);

/* 带返回值 */
typedef int  (*cmp_t)(const void *a, const void *b);

/* 数组指针(用于回调表) */
typedef void (*btn_handler_t[])(void);

读法一致:去掉 typedef 和末尾的名字就是类型。


4. 回调变种

4.1 带上下文指针

给回调传一个 void *,让它能访问外部数据:

typedef void (*event_cb_t)(int code, void *ctx);

static event_cb_t g_cb;
static void *g_ctx;

void register_cb(event_cb_t cb, void *ctx)
{
    g_cb = cb;
    g_ctx = ctx;      /* 上下文透传给回调 */
}

// 触发
if (g_cb) g_cb(42, g_ctx);

// 调用方
static void my_handler(int code, void *ctx)
{
    struct my_data *d = ctx;   /* 还原指针 */
    printf("code=%d, name=%s\n", code, d->name);
}

struct my_data data = { .name = "sensor1" };
register_cb(my_handler, &data);

这是最常用的模式——pthread_create、qsort、LVGL 事件回调都用这种形式。

4.2 多回调(订阅模式)

#define MAX_CB 4

typedef void (*cb_t)(int);
static cb_t cbs[MAX_CB];
static int  cb_cnt;

void add_cb(cb_t cb)
{
    if (cb_cnt < MAX_CB)
        cbs[cb_cnt++] = cb;
}

void fire(int code)
{
    for (int i = 0; i < cb_cnt; i++)
        cbs[i](code);          /* 通知所有订阅者 */
}

4.3 回调表(多路分发)

typedef void (*btn_handler_t)(void);

void on_up(void)    { printf("UP\n"); }
void on_down(void)  { printf("DOWN\n"); }
void on_left(void)  { printf("LEFT\n"); }
void on_enter(void) { printf("ENTER\n"); }

/* 用表格代替 switch-case */
const btn_handler_t btn_table[] = {
    [KEY_UP]    = on_up,
    [KEY_DOWN]  = on_down,
    [KEY_LEFT]  = on_left,
    [KEY_ENTER] = on_enter,
};

/* 调用 */
void dispatch(int key)
{
    if (btn_table[key])
        btn_table[key]();      /* 直接跳转 */
}

4.4 链式回调

typedef void (*cb_t)(int);

static cb_t g_prev = NULL;

void wrap(cb_t new_cb)
{
    g_prev = new_cb;           /* 存旧的 */
    register_cb(my_wrapper);   /* 注册新的 */
}

static void my_wrapper(int code)
{
    printf("before\n");
    if (g_prev) g_prev(code);  /* 调到旧的 */
    printf("after\n");
}

5. 常用场景

5.1 qsort —— 标准库回调

int cmp_int(const void *a, const void *b)
{
    int ia = *(int*)a, ib = *(int*)b;
    return (ia > ib) - (ia < ib);
}

int arr[] = {3, 1, 4, 1, 5};
qsort(arr, 5, sizeof(int), cmp_int);
//                         ^ 函数指针

qsort 不知道你的数组元素怎么比大小——你用回调告诉它。

5.2 pthread_create —— 线程入口

void *worker(void *arg)
{
    printf("thread arg=%s\n", (char*)arg);
    return NULL;
}

pthread_t tid;
pthread_create(&tid, NULL, worker, "hello");
//                         ^ 回调函数
//                              ^ 上下文指针

5.3 signal —— 系统信号

void on_sigint(int sig)
{
    printf("Ctrl+C pressed\n");
    _exit(0);
}

signal(SIGINT, on_sigint);
//             ^ 注册回调
//               内核收到 Ctrl+C 时调它

5.4 LVGL 事件

这就是你项目里的用法:

lv_obj_add_event_cb(btn, my_handler, LV_EVENT_CLICKED, NULL);
//                           ^ 回调           ^ 事件类型   ^ 上下文

// 你的回调函数:
static void my_handler(lv_event_t *e)
{
    /* lv_event_get_user_data(e) 可以拿上下文 */
}

5.5 长按回调

/* hal/btn-input.h 声明类型 */
typedef void (*btn_longpress_handler_t)(uint16_t code);

/* 注册:main.c */
btn_input_set_longpress(3000, on_power_longpress);

/* 触发:indev.c process_events() */
if (tick_ms() - press_tick >= longpress_ms) {
    longpress_handler(held_code);
}

6. 底层原理

x86_64 汇编视角

void hello(int n) {
    printf("%d\n", n);
}

int main(void) {
    void (*p)(int) = hello;
    p(42);
    return 0;
}

编译后(AT&T 语法,简化):

# main:
    leaq  hello(%rip), %rax     # rax = hello 的地址 (.text 段)
    movq  %rax, -8(%rbp)        # p = rax (在栈上)

    movq  -8(%rbp), %rax        # rax = p
    movl  $42, %edi             # 第一个参数放入 edi
    call  *%rax                 # 间接调用:call hello的地址

# hello:
    movl  %edi, %esi            # 参数从 edi 移到 esi(printf 第二个参数)
    leaq  .LC0(%rip), %rdi      # 格式化字符串
    call  printf@PLT
    ret

关键指令:call *%rax —— 从寄存器取地址跳转,这就是"间接调用"。

对比:普通调用 vs 间接调用

hello(42);      // call hello      — 编译时决定地址
p(42);          // call *%rax      — 运行时决定地址(多一层间接)

间接调用的代价:一次额外的内存读取(从变量或内存中加载地址)。

为什么函数代码不会被释放

程序地址空间:
┌──────────────────────────────────┐
│  .text 段(只读)                 │
│  0x401000: hello 的机器码         │ ← p 指向这里
│  0x401050: world 的机器码         │
│  0x401100: main 的机器码          │
├──────────────────────────────────┤
│  .data / .bss 段(读写)          │
│  0x502000: p 变量(存 0x401000)   │
└──────────────────────────────────┘

.text 段在进程退出前永远不会被释放。所以函数指针永远有效——你用函数名取的地址在 .text 段。这和局部变量的栈分配完全是两套机制。


7. 进阶:闭包模拟

C 没有闭包,但可以用结构体 + 函数指针模拟:

/* 定义一个"对象":数据 + 方法 */
typedef struct {
    int count;
    void (*on_event)(struct counter *self, int n);
} counter_t;

/* 方法实现 */
static void counter_handler(counter_t *self, int n)
{
    self->count += n;
    printf("count = %d\n", self->count);
}

/* 初始化 */
void counter_init(counter_t *c)
{
    c->count = 0;
    c->on_event = counter_handler;   /* 绑定方法 */
}

/* 使用 */
counter_t c;
counter_init(&c);
c.on_event(&c, 5);  /* 输出 count = 5 */
c.on_event(&c, 3);  /* 输出 count = 8 */

这就是 C 里模拟"面向对象"的典型手法——结构体存数据和函数指针,函数指针第一个参数传结构体自身(类似 C++ 的 this)。


8. 练习

下面几个例子你在项目里跑一遍,理解回调的工作方式:

练习 1:手写一个回调注册 + 触发

#include <stdio.h>

typedef void (*cb_t)(int);

static cb_t g_cb;

void reg(cb_t cb) {
    g_cb = cb;
}

void fire(int n) {
    if (g_cb) g_cb(n);
}

static void my(int n) {
    printf("n=%d\n", n);
}

int main(void) {
    reg(my);
    fire(100);   /* 期望输出 n=100 */
    return 0;
}

练习 2:带上下文

struct ctx {
    int id;
};

typedef void (*cb_t)(int code, void *ctx);

/* 自行实现 reg / fire / handler / main */
/* 要求 handler 能打印 ctx->id */

练习 3:读懂本项目现有的回调链

// 1. hal/btn-input.h 声明类型 btn_longpress_handler_t
// 2. main.c 调用 btn_input_set_longpress(3000, on_power_longpress)
// 3. indev.c 的 process_events() 在计时到 3s 时触发回调
// 4. on_power_longpress(115) 被调用

总结

概念一句话
函数名就是地址,func 和 &func 一样
函数指针void (*p)(int) —— p 存的是函数地址
typedeftypedef void (*F)(int) —— 把类型换成名字
回调把函数地址传给别人,别人到时候调回来
上下文指针传一个 void * 给回调,让它能访问外部数据
.text 段存代码,只读,程序退出前不释放
间接调用call *%rax —— 比普通 call 多一次内存读取
typedef void (*dialog_select_cb_t)(uint16_t type, void *value, uint16_t index);
  • 定义一个函数指针变量,这个函数指针的参数是(uint16_t type, void *value, uint16_t index)

  • // 定义一个函数指针变量(无 typedef)
    void (*select_cb)(uint16_t type, void *value, uint16_t index);
    select_cb = a_function; //这是变量的赋值
    
  • 无 typedef:定义的是一个变量,语法是 void (*变量名)(...); 有 typedef:定义的是一种类型,语法是 typedef void (*类型名)(...);

  • int a; —— a 是变量

    typedef int a; —— a 是 int 的别名,之后可以写 a b; 定义变量 b

void dialog_set_select_cb(dialog_select_cb_t cb);
  • 提供一个函数允许其他地方传入一个dialog_select_cb_t类型的函数
static dialog_select_cb_t select_cb; 

void dialog_set_select_cb(dialog_select_cb_t cb)
{
    select_cb = cb;
}
  • 我们定义了一个回调函数(函数指针)dialog_select_cb_t select_cb
  • 赋值select_cb = cb之后,当前函数指针指向了传入的函数。
  • 调用:select_cb(now_type, now_value, i);就相当于调用 ↓ 下面传入的函数。
dialog_set_select_cb(unit_select_cb);
static void unit_select_cb(dialog_val_type_t type, void *value, uint16_t index)
{
}

Cmake

构建配置工具 + 构建执行工具 + 编译器

构建配置工具

检测操作系统:判断当前操作系统的环境,win,linux,mac方便调用工具链。

确定构建的文件:哪些文件哪些目录需要去编译,如果不想编译

确定引入的库:程序需要链接哪些库文件,在这里配置

构建配置工具有:

cmake(跨平台) 生成makefile或者build.ninja文件交给构建执行工具

xmake(跨平台) 自己配置,自己调用编译不需要构建执行工具

Makefile这个不是构建工具,这个只是我们自己编写的让make去执行的脚本。

构建执行工具(构建的后端)

1. 头文件依赖的“精确制导”(这是最要命的)

假设你修改了 global_defines.h 这个头文件。这个头文件被 3000 个 .cpp 文件引用了。

  • 如果直接让 CMake 调用 gcc:CMake 不知道这个头文件影响了谁,它只能全部重新编译 3000 个文件。
  • 中间层(Ninja)的做法:在第一次完全编译时,GCC 配合 -MMD 参数生成了一个 .d 依赖文件。Ninja 读取这个 .d 文件,精确记录了“global_defines.h 影响了哪 3000 个 .o”。当你改了 global_defines.h,Ninja 只重新编译这 3000 个,如果只改了某个不常用的 .cpp,它可能只编译 1 个。这种“按需编译”的能力,是构建执行工具的核心命脉。
2. 作业调度器(Job Server)—— 避免“打架死锁”

GCC 本身不支持多核编译一个文件,但 Ninja 支持同时启动多个 GCC。 Ninja 内置了图论拓扑排序算法。例如:

  • 必须先编译 core_a.cpp 和 core_b.cpp,才能链接出 libcore.a。
  • 有了 libcore.a,才能编译 main.cpp。 Ninja 会先启动 16 个核心同时编译 core_a 和 core_b,等它们全部完成后,再启动 main.cpp 的编译。 如果让 CMake 干这个活,CMake 得自己写一套复杂的线程锁和信号量机制(Semaphore),这相当于让设计师去当建筑工地的安全调度员,它根本干不来,强行写会极其臃肿且容易死锁。
3. 构建状态的“永久记忆”(.ninja_log)

Ninja 不仅是看文件时间,它还存储了每次编译命令的哈希值(Hash)。

  • 假设你昨天编译时用的 GCC 版本是 10.0,今天你升级到了 11.0。
  • 即使 .cpp 文件没变,Ninja 检测到编译器的哈希值变了,它会自动重新编译所有文件,因为不同版本的 GCC 生成的机器码可能不一样。
  • 如果 CMake 直接调用,它根本不会记录这些元数据,会导致你用了新编译器,程序却还在跑旧编译器的残留缓存,引发极其诡异的崩溃 Bug。

Make (GNU Make) NMake (微软) Ninja MSBuild (VS)

编译器

GCC (gcc/g++) Clang MSVC (cl.exe)

创建CMakeLists.txt文件

  • CMakeList.txt
cmake_minimum_required(VERSION 3.20)

project(demo001 LANGUAGES C)

add_executable(demo001 main.c)
  • 在相同目录下main.c
#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
    return 0;
}
  • 构建生成文件

-S . 指定源码目录,-B build 指定构建目录(不污染源码)

-G "MinGW Makefiles" 指定生成器(Windows 下需显式指定;如果用的是 MSVC/Ninja,换成对应生成器)

# 强制使用 make
cmake -B build -G "MinGW Makefiles"
# 强制使用 make
cmake -B build -G "Unix Makefiles"
# 强制使用 ninja
cmake -B build -G "Ninja"
  • 编译

cmake --build build 等价于进入 build 目录执行 make

cmake --build build

CMake 选择编译器的优先级从高到低如下:

  1. 显式指定(最高优先级)
    • 命令行参数:-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
    • 环境变量:CC=gcc CXX=g++ cmake ..(配置阶段生效,但命令行参数优先级更高)
  2. 生成器(Generator)暗示
    • -G "MinGW Makefiles" → 自动在 PATH 中查找 MinGW 的 gcc/g++
    • -G "Visual Studio 17 2022" → 固定使用 MSVC(cl.exe),忽略环境变量
    • -G "Unix Makefiles" → 查找 gcc、clang 等常见编译器
    • -G "Ninja"→ 不绑定任何特定的编译器
  3. PATH 顺序探测 若未指定生成器或生成器不锁定特定编译器,CMake 会按 PATH 中的顺序依次尝试 gcc、clang、cl 等,找到第一个可用的即使用。
  4. 缓存复用 首次配置后,编译器路径会写入 build/CMakeCache.txt(变量 CMAKE_C_COMPILER:FILEPATH=...)。 后续重新运行 cmake ..(不清理缓存)时,CMake 不会重新探测,而是直接使用缓存中的值。
    • 若要重新探测,需删除 build 目录,或使用 -DCMAKE_C_COMPILER=... 覆盖缓存。
1
  • CMakePresets.json 文件:
    • 配置构建执行工具(ninja):"generator": "Ninja"
    • 说明交叉编译使用的工具(文件):"toolchainFile": "${sourceDir}/cmake/arm-none-eabi.cmake",
{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 20,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "debug",
      "displayName": "STM32F103C8 Debug",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/debug",
      "toolchainFile": "${sourceDir}/cmake/arm-none-eabi.cmake",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_MAKE_PROGRAM": "${sourceDir}/ninja/ninja.exe"
      }
    },
    {
      "name": "release",
      "displayName": "STM32F103C8 Release", //显示的构建名称
      "inherits": "debug", //继承上面debug的信息,这里相同的配置项会覆盖上面内容
      "binaryDir": "${sourceDir}/build/release", //构建生成的文件的存放路径
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ],
  "buildPresets": [ //真正用于执行构建的。
    {
      "name": "debug",  //构建时cmake --preset debug
      "configurePreset": "debug" //使用configurePreset的debug配置
    },
    {
      "name": "release",//构建时cmake --preset release
      "configurePreset": "release"
    }
  ]
}

cmake构建的时候使用这个文件,意思是构建的时候,使用debug的方式

cmake --preset debug
cmake --build --preset debug

VS Code CMake Tools因为配置了:

.vscode/settings.json

{
    "cmake.sourceDirectory": "${workspaceFolder}/002-arm-demo",
    "cmake.useCMakePresets": "always", //cmake插件就会识别到 CMakePresets.json中的buildPresets项
    "cmake.configureOnOpen": true
}

CMake 会将以下 3 种形式的名称作为保留标识符,⾃定义变量或命令时 应当注意避开它们:

  • 以 “CMAKE_” 开头的名称(不区分⼤⼩写); _
  • 以 “CMAKE” 开头的名称(不区分⼤⼩写);
  • 下画线 “_” 加上 CMake 中任意⼀个预定义命令的名称, 如 “_message” 。

自定义变量

set(var_a 您好 )

set(var_b a)

message(${var_${var_b}})

$CACHE{ 缓存变量 }

$ENV{ 环境变量 }

CMake自动定义的变量

  • CMAKE_ARGC 表⽰ CMake 脚本程序在被 cmake -P 命令⾏调⽤执⾏ 时,命令⾏传递的参数个数。
  • CMAKE_COMMAND 表⽰ CMake 命令⾏程序所在的路径。
  • CMAKE_HOST_SYSTEM_NAME 表⽰宿主机操作系统(运⾏ CMake 的操作系统)名称。
  • CMAKE_SYSTEM_NAME 表⽰ CMake 构建的⽬标操作系统名称。默 认与宿主机操作系统⼀致,⼀般⽤于交叉编译时,由开发者显式设置。
  • ......

工具链文件定义的变量

Preset宏

CMakePresets.json 里面的${}

表达式

Linux c

编译 汇编 装载 库

所有的c语言,编写完成后,生成可执行文件都有以下几个步骤。

​ 1.预处理,将所有的#define删除,并且展开所有的宏定义。处理#if,#ifdef,#else等。以及#include,以及删除所有注释。添加行号和文件标识方便下一步编译。将引入的头文件进行处理。

​ 2.编译:这一步主要是将预处理生成的文件(.i)生成汇编语言,这一步很重要。不同版本的gcc,比如gcc-x86,arm-linux-gcc(交叉编译器)。会将预处理生成的文件编译成不同的汇编指令集。

​ 3.汇编:这个步骤将上面编译出来的汇编指令变成二进制机器码。生成目标文件(.o)

​ 4.链接:这一步最重要,编写的c语言文件又很多个,以及要引用到libc库或者其他库中的文件比如(stdio.h)文件。这一步要将他们链接到一起。最后生成可执行文件。

在linux中,安装gcc工具链。

#include <stdio.h>
int main()
{
    printf("hello word!\n");
    return 0;
}

预处理过程,gcc命令

gcc -E test.c -o test.i

    //文件内容多,不展示

编译过程,gcc命令

gcc -S test.i -o test.s

	.file	"demo01-gcc-test.c"
	.text
	.section	.rodata
.LC0:
	.string	"hello world!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	leaq	.LC0(%rip), %rax
	movq	%rax, %rdi
	call	puts@PLT
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	1f - 0f
	.long	4f - 1f
	.long	5
0:
	.string	"GNU"
1:
	.align 8
	.long	0xc0000002
	.long	3f - 2f
2:
	.long	0x3
3:
	.align 8
4:

汇编过程指令

gcc -c test.s -0 test.o

链接过程命令

//这一步因为涉及很多个文件

一步到位生成可执行文件

gcc test.c -o test //直接生成可执行文件test 为文件test分配执行权限后,执行./test 打印hello word!

GCC 基本用法

GCC 的基本命令格式如下:

gcc [选项] [文件名]
其中,[选项] 用于指定编译行为,[文件名] 是需要编译的源文件。

常用选项

-o [文件名]:指定输出文件名,默认生成 a.out。

-c:只编译生成目标文件(.o 文件),不进行链接。

-S:生成汇编代码文件(.s 文件)。

-E:只进行预处理,输出预处理结果。

-g:生成调试信息,便于使用调试工具(如 GDB)。

-O0/-O1/-O2/-O3:优化级别,-O0 表示无优化,-O3 优化级别最高。

-Wall:启用所有警告信息。

-static:使用静态链接库,生成独立的可执行文件。

-shared:生成共享库文件。

GCC 编译过程

GCC 的编译过程分为以下四个步骤:

预处理:展开宏定义、头文件等,生成 .i 文件。

gcc -E hello.c -o hello.i
编译:将预处理后的代码转为汇编代码,生成 .s 文件。

gcc -S hello.i -o hello.s
汇编:将汇编代码转为目标文件(机器码),生成 .o 文件。

gcc -c hello.s -o hello.o
链接:将目标文件与库文件链接,生成可执行文件。

gcc hello.o -o hello

封装库,使用

自己的程序如何引入第三方库

第三方提供了:

  • foo.a 文件
  • foo.so 文件(共享库)
  • foo.h 文件

静态链接

用在编译阶段

  • 直接将foo.a文件复制到自己的可执行文件。

  • 程序在 编译链接时通过 -lfoo 将foo.a文件连接进ELF文件

  • 链接时,如果foo.a中的符号(也就是函数)和foo.h头文件中的对应不上就直接报错。

编译时动态链接

用在编译阶段 + 启动时需要so文件

  • 生成 NEEDED(依赖声明,记录需要的共享库) + PLT 条目(过程链接表,本质是一段桩代码(Stub)负责处理外部函数的具体调用地址。)
  • ld.so 在 main() 之前自动加载 .so
  • 部署需要带 .so
  • 库升级换 .so 即可(ABI 兼容前提下)
  • 缺 .so 启动即崩

运行时动态加载

运行时引入库

  • dlopen + 三种子方式
  • 方式1,dlsym 函数指针,编译时不需要-lfoo链接,要自己声明函数指针,然后再使用。
  • 方式2,PLT 惰性绑定 + patchelf,需要-lfoo链接,生成 PLT,然后去掉 NEEDED。foo() 直接调。
  • 方式3,PLT 惰性绑定 + --unresolved-symbols,不需要-lfoo(但链接器不生成 PLT,不可行),淘汰方案。

方式1: dlsym 函数指针

void *h = dlopen("libfoo.so", RTLD_LAZY);  //懒加载 ,将需要的库导入到运行中的程序中的.dynamic段
int (*foo)(void) = dlsym(h, "foo");		   //声明使用库中的foo函数
foo();  // 通过函数指针调用,完全绕过 PLT 
//调用foo()函数指针时,其实就是跑到.dynamic段的地址去执行一下库函数

方式2: PLT 惰性绑定 + patchelf

​ 先让链接器按正常动态链接生成完整的 PLT/GOT 基础设施,再用 patchelf 抹掉 NEEDED 骗过 ld.so 的启动加载,最后用 dlopen + RTLD_GLOBAL 把库"注册"进全局符号表,让 PLT 的懒解析机制自动完成原本需要 dlsym 手动做的事。

第一阶段:
$(CC) -o re001 main.o camera.o -lfoo -Wl,-z,lazy

链接器 ld 的工作:

1. 看到 -lfoo → 打开 libfoo.so
2. 看到源码调用 foo() → 在 libfoo.so 的 .dynsym 找到 foo
3. 生成 PLT 条目 foo@PLT:
   
   foo@PLT:
       jmp *foo@GOT          # 间接跳转,通过 GOT
       push $foo_idx         # 压入符号表索引(用于标识是哪个符号)
       jmp PLT0              # 跳到公共解析桩

4. 生成 GOT 条目 foo@GOT:
   - 初始值指向 PLT 的第二条指令(push $foo_idx)
   
5. 写入重定位项 .rela.plt:
   R_X86_64_JUMP_SLOT  foo  # 告诉运行时:这个 GOT 项需要动态填充

6. 写入 .dynamic 段:
   NEEDED libfoo.so         # 启动时要加载这个库

此时 ELF 结构:

.rela.plt:
  [0] R_X86_64_JUMP_SLOT  foo  →  foo@GOT

.plt:
  [foo@PLT]  jmp *foo@GOT
             push $0          ← 符号索引
             jmp PLT0

.got.plt:
  [foo@GOT]  → 指向 PLT 的 push 指令(初始陷阱值)

.dynamic:
  NEEDED libfoo.so
第二阶段:

patchelf 抹掉 NEEDED

patchelf --remove-needed libfoo.so re001

ELF 变化:

.dynamic 段:
  之前: NEEDED libfoo.so  ✅
  之后: (无)            ❌  ← 被移除

其他段(PLT/GOT/.rela.plt):
  完全不变!              ✅  ← 保留

关键:patchelf 只改 .dynamic 的一个字符串条目,不动代码段、不动 PLT/GOT。

第三阶段:

启动程序->ld.so读.dynamic 段->NEEDED: (无 libfoo.so)不加载!->这时候调用会找不到foo函数地址

第四阶段:

代码主动 dlopen

void *handle = dlopen("libfoo.so", RTLD_LAZY | RTLD_GLOBAL);

dlopen 内部流程:

1. 打开 libfoo.so 文件
2. mmap 到进程地址空间(匿名映射,无固定段名)
   ┌─────────────────────────────────────┐
   │  libfoo.so 被映射到的内存区域        │
   │  (不是 ELF 的"段",是内核的 VMA)    │
   │                                     │
   │  .text  → 代码(可执行)             │
   │  .data  → 已初始化数据               │
   │  .rodata→ 只读数据                   │
   │  .dynsym→ 动态符号表                 │
   └─────────────────────────────────────┘
   
3. 处理 libfoo.so 的依赖(递归加载其他 .so)
4. 符号表合并:
   RTLD_GLOBAL → 把 libfoo.so 的 .dynsym 注入**全局符号表**
   
   全局符号表现在包含:
     foo  → 0x7f...(libfoo.so 中的真实地址)
     bar  → 0x7f...
     ...

此时内存状态:

进程地址空间:
  [text]       re001 代码段
  [data]       数据段
  [libc.so]    ✓
  [libdl.so]   ✓
  [libfoo.so]  ✓  ← 刚 mmap 进来!
阶段五:第一次调用 foo()(PLT 解析触发)
call foo@PLT
  ↓
foo@PLT:
  jmp *foo@GOT          # 取 GOT[foo] 的值
  ↓
GOT[foo] 当前值 = 0x401036(指向 PLT 的 push 指令)
  ↓
"跳转"到 0x401036,即:
  push $0               # 符号索引 0(foo 的索引)
  jmp PLT0
  ↓
PLT0:
  push GOT[1]           # 模块 ID(re001 的 link_map)
  jmp *GOT[2]           # 跳转到 _dl_runtime_resolve
  ↓
_dl_runtime_resolve(link_map, symbol_index)
  │
  ├─ 根据 link_map 找到 re001 的重定位表
  ├─ 根据 symbol_index 找到 .rela.plt[0] = R_X86_64_JUMP_SLOT foo
  ├─ 根据重定位项的符号名 = "foo"
  ├─ 去**全局符号表**搜索 "foo"
  │     ↓
  │   找到了!dlopen + RTLD_GLOBAL 注入的
  │   foo → 0x7f...(libfoo.so 中的地址)
  │     ↓
  ├─ 回填 GOT[foo] = 0x7f...(真实地址)
  └─ 跳转 0x7f...(调用真正的 foo)
  
foo() 执行完毕,ret

关键洞察:_dl_runtime_resolve 根本不查 NEEDED!它只查全局符号表。NEEDED 只是 ld.so 启动时自动加载库的依据,不是符号解析的依据。

阶段六:第二次调用 foo()(直接跳转)
call foo@PLT
  ↓
foo@PLT:
  jmp *foo@GOT
  ↓
GOT[foo] = 0x7f...(已缓存的真实地址)
  ↓
直接跳到 foo(),无额外开销!

和正常动态链接的第二次调用完全一致。

arm-linux-gcc 交叉编译器的版本多样,主要区别在于目标架构、浮点支持和ABI等方面。以下是详细的分类和说明:

一、按目标架构位数分类

1. 32位ARM架构

  • 命名模式:arm-linux-gcc(传统命名)
  • 目标:ARMv4T到ARMv7-A/R的32位CPU
  • 常见前缀:
    • arm-linux-(传统)
    • arm-none-linux-gnueabi-(EABI)
    • armv7l-linux-gnueabihf-(带硬浮点)
  • 支持平台:Cortex-A7/A8/A9/A15, ARM9, ARM11等

2. 64位ARM架构(AArch64)

  • 命名模式:aarch64-linux-gcc
  • 目标:ARMv8-A及以上的64位CPU
  • 常见前缀:
    • aarch64-linux-gnu-
    • aarch64-none-linux-gnu-
  • 支持平台:Cortex-A53/A57/A72/A73/A75/A76等

二、按浮点支持方式分类(针对32位ARM)

1. 软浮点(Soft-float)

  • 命名:arm-linux-gnueabi(无"hf"后缀)
  • 特点:
    • 浮点运算通过软件库模拟
    • 无硬件FPU要求
    • 代码体积大,速度慢
  • 使用场景:
    • 无FPU的旧ARM芯片(ARM9, ARM11早期)
    • 兼容性要求高的场景

2. 硬浮点(Hard-float)

  • 命名:arm-linux-gnueabihf(带"hf"后缀)
  • 特点:
    • 直接使用硬件FPU指令
    • 需要目标CPU有FPU
    • 速度快,效率高
  • ABI差异:
    • 浮点参数通过FPU寄存器传递
    • 与软浮点ABI不兼容
  • 使用场景:
    • Cortex-A系列(有NEON/FPU)
    • 需要高性能浮点运算

3. 软浮点ABI兼容硬浮点

  • 命名:较少见,需要特定配置
  • 特点:
    • 使用硬浮点指令
    • 但保持软浮点ABI(通过寄存器传递参数)
    • 兼容性较好

三、按EABI版本分类

1. OABI(旧ABI)

  • 命名:arm-linux-(简单命名)
  • 特点:
    • 旧的ARM ABI标准
    • 结构体对齐、系统调用等与EABI不同
    • 已基本淘汰

2. EABI(嵌入式ABI)

  • 命名:arm-none-linux-gnueabi
  • 特点:
    • 现代ARM标准ABI
    • 更好的性能,更小的代码体积
    • 支持位置无关代码(PIC)

四、常见版本具体示例

text

# 32位ARM,软浮点
arm-none-linux-gnueabi-gcc          # 最常用软浮点
arm-linux-gnueabi-gcc               # 简化命名

# 32位ARM,硬浮点
arm-none-linux-gnueabihf-gcc        # 最常用硬浮点
arm-linux-gnueabihf-gcc             # 简化命名

# 特定架构优化版本
armv5l-linux-gnueabi-gcc            # ARMv5,软浮点
armv7l-linux-gnueabihf-gcc          # ARMv7,硬浮点

# 64位ARM
aarch64-linux-gnu-gcc               # 标准64位
aarch64-none-linux-gnu-gcc          # Bare-metal风格命名

五、选择指南

判断方法:

  1. 查看目标CPU规格:

    bash

    # 在目标板上执行
    cat /proc/cpuinfo
    uname -m
    
  2. 检查FPU支持:

    bash

    # 如果有以下内容,支持硬浮点
    Features : swp half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 
    # vfp/neon表示FPU支持
    

选择矩阵:

目标平台推荐工具链说明
ARM9, ARM11(无FPU)arm-linux-gnueabi必须用软浮点
Cortex-A5/A7/A8arm-linux-gnueabihf有VFP/NEON
Cortex-A9/A15arm-linux-gnueabihf性能最优
ARMv8 64位aarch64-linux-gnu64位系统
未知平台arm-linux-gnueabihf现代ARM通用

Makefile

windows安装


​ 什么是makefile,makefile是一个用于自动化构建程序的配置文件。它指示 make 工具如何编译和链接程序。简单来说, Makefile 就像一个构建脚本,告诉编译器如何从源文件生成可执行文件或其他目标文件。

targets ...: prerequisites...
	command
	....
targets 要生成的目标文件
prerequisites 所需要的依赖文件或者targets
command 命令

编写makefile文件

demo01 : demo01.o //将目标文件链接生成可执行文件
    gcc -o demo01 demo01.o
demo01.o: demo01.c
    gcc -c demo01.c //生成目标文件demo01.o

例子(复习)

cc = gcc #选择编译器
objects = add.o d1.o #所需要链接的目标文件
target_ = d1 #链接成的可执行文件

$(target_) : $(objects)
	$(cc) -o $(target_) $(objects)

add.o :add.o add.h
	$(cc) -c add.c
d1.o : d1.c
	$(cc) -c d1.c

链接库

gcc test.c -o test -L ./xx-so -lxxx -lpthread
  • -lxxx -lpthread : 需要链接的库(.so)
  • -L ./xx-so : 链接库所在地址,如果是系统的库pthread,会自己去系统默认路径找
  • 注意:库的名字必须是libxxx.so,也能是带版本的libxxx.so.0,libxxx.so.0.2.0

Docker

Example

Jar包生成dockerfile并创建镜像

springboot打包的jar包生成dockerfile并创建镜像

编写Dockerfile
# Docker image for springboot file run
# 基础镜像使用java
FROM java:openjdk-8

# VOLUME 指定了临时文件目录为/tmp。
# 其效果是在主机 /var/lib/docker 目录下创建了一个临时文件,并链接到容器的/tmp
# VOLUME /tmp

# 将jar包添加到容器中并更名为app.jar
ADD test.jar app.jar
# 运行jar包
RUN bash -c 'touch /app.jar'
ENTRYPOINT ["java","-jar","/app.jar"]
###声明启动端口号
EXPOSE 8801
构建docker镜像
docker build -t my-spring-app:1.0 .  
//确保Dockerfile文件在 . 当前目录 my-spring-app是docker image的名字 1.0是版本

运行镜像

# 以后台模式运行 并映射端口
docker run -d -p 8801:8801 --name my-spring-app my-spring-app:1.0
# 镜像id的方式
docker run -d -p 8801:8801 --name my-spring-app a1b2c3d4e5f6

emqx

nginx

redis

mysql

nacos

作用:

容器编排。将docker命令将容器组织起来。

安装:

apt install docker.io
apt install docker-compose

介绍:

默认情况下,如果没有配置网络,在同一个docker-compose.yaml中的所有service(服务)会使用同一个网络,这个网络名是docker-compose 命令时所在目录的名称+_default。

  • 例如,如果项目文件夹叫 my_app,那么默认网络名就是 my_app_default。

实战:

  • dockercompose结合Dockerfile一键启动后端java应用

    docker-compose.yml

    services:
      java-jar-app:
        build: ./
        container_name: java-jar-app
        ports:
          - 8088:8088
        depends_on:
          - mysql-jar-app
      mysql-jar-app:
        image: mysql:latest
        container_name: mysql-jar-app
        ports:
          - 3307:3306
    
    

    Dockerfile

    FROM openjdk:17
    COPY docker-jar-test-1.0.0.jar /usr/src/app.jar
    WORKDIR /usr/src/
    EXPOSE 8088
    CMD ["java","-jar","app.jar"]
    
    

构建最简单命令

  1. 编译hello.c文件,生成hello执行文件
  2. 新建Dockerfile文件。将以下命令写入Dockerfile
FROM scratch    #基础镜像。
ADD hello /     #添加当前目录下的hello文件到镜像的/ 目录
CMD ["/hello"]  #执行命令行命令(在镜像内部)
3. 执行构建命令
-t 指名镜像名称和tag
. 指名时当前目录(找当前目录的Dockerfile文件并执行)
docker build -t hello-my-world:1.0.0 .

docker tag

这个命令可以修改镜像的名称和tag

将hello-my-world:1.0.0修改为hmw:1.0.1
docker tag hello-my-world:1.0.0 hmw:1.0.1

RUN

构建镜像时,执行Shell命令。构建过程中安装软件包、配置环境、生成文件等。

RUN apt-get update && apt-get install -y python3
RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "python3"]

CMD和ENTRYPOINT

设置创建时的命令

ADD和COPY

ENV

设置环境变量

ENV <key>  <value>

ENV <key> =<value> <key> =<value> <key> =<value>


ENV NODE_VERSION 7.2.0

RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc"

AGE

定义变量

AGE name=TOM

docker build -t mytom:2.0 --build-arg name=Jerry //重新定义

WORKDIR

指定工作目录。

EXPOSE

暴露端口

VOLUME

在容器中创建挂载点。

docker volume create 

保存镜像

主要是将某个镜像保存为.tar文件

  • docker save:把一个或多个镜像保存为 tar 文件(保留层、历史、元数据)。
  • docker load:把 save 生成的 tar 文件加载为镜像。

✅ 主要用途:镜像的备份、迁移。

将tomcat:8.5.49镜像打包成tar包
docker save -o mytom.tar tomcat:8.5.49 或者docker save -o mytom.tar [imageId]

load加载mytom.tar包成镜像
docker load -i mytom.tar

**注意 加载 的镜像与原镜打包前的镜像完全一样。包括了镜像ID。所以必须 删除 之前的镜像才能 加载 

保存容器

  • docker commit:将容器的当前状态(文件系统+配置)保存为一个新镜像。

  • docker export:将容器的文件系统导出为一个 tar 包(不含元数据、层历史等)。

  • docker import:将 export 生成的 tar 包(或其它文件系统快照)导入为一个新镜像(可指定启动命令等)。

基本命令

安装

apt install docker.io
apt install docker-compose

docker pull

docker pull ubuntu:20.04
拉取ubuntu版本为20.04。如果没有写版本会默认下载最新版本ubuntu:latest
docker pull -q ubuntu
-q:静默,不输出下载的镜像信息(不打印日志)
docker pull --platform=arm64 ubuntu:20.04
下载arm平台的ubuntu:20.04,用于将这个镜像放到arm开发板下。

docker images

docker images 用来查看当前已有的镜像
-a, --all: 显示所有镜像(包括中间层镜像)。
--digests: 显示镜像的摘要信息。显示sha256信息
-f, --filter: 过滤输出,基于提供的条件。
--format: 使用 Go 模板格式化输出。
--no-trunc: 显示完整的镜像 ID。
-q, --quiet: 只显示镜像 ID。
docker images unbuntu 查看名称是ubuntu的镜像
docker search ubuntu 查找名为ubuntu的镜像

docker rmi

docker rmi ubuntu 移除ubuntu镜像
docker rmi adb238 移除adb238开头的镜像ID对应的镜像。

docker run

docker run hello-world 启动hello-world容器,并且随机给一个名字。会默认使用latest版本。如果没有会去拉取
docker run hello-world:1.0.0 启动hello-world:1.0.0容器
docker run --name hello-wd hello-world:1.0.0 启动一个名为hello-wd容器

*注意,这个命令会启动一个新的容器,如果--name hello-wd 同一个名称会启动不了

//启动一个名为hello-wd 的容器,并且进入这个容器中。
docker run --name hello-wd -it hello-world /bin/bash

在这个容器中命令行exit 会退出这个容器。

**启动tomcat 配置暴露端口号**

**docker run --name mytomcat -it -p 8081:8080 tomcat:8.5.49**

-P 大P的话会随机分配端口号

退出容器并且保持容器状态:**ctrl+p+q**


后台方式启动

docker run --name mytomcat2 -p 8082:8080 -d tomcat:8.5.49

删除某种镜像的全部容器

docker rm -f ${docker ps ubuntu}

docker exec

  • 进入容器

​ docker exec -it mytomcat /bin/bash

​ docker exec -w /root -it mytomcat /bin/bash

  • 进入容器

​ docker attach 跟exec用法一致。但是这个使用exit会把容器也停掉。如果使用exec 不会停容器

docker ps

docker logs

docker logs -f -n 1000 tomcat 查看tomcat中1000行日志。并且跟踪。

docker rm ; docker rmi

docker删除容器,删除镜像

# 删除所有由 nginx:latest 创建的容器
docker rm $(docker ps -a -q -f "ancestor=nginx:latest")
  • docker ps -a -q:-q 参数只输出容器ID,非常简洁。
  • -f "ancestor=nginx:latest":--filter 过滤器,ancestor= 表示筛选出所有由指定镜像创建的容器。

docker start 、stop 、kill 、restart

docker的启停命令。

docker cp

用于将宿主机的文件复制到容器内部、或将容器内部文件复制到宿主机。

docker cp ./test_dir mytomcat:/root 将宿主机当前目录下的文件夹复制到容器内部
docker cp ./test.txt mytomcat:/root 将宿主机当前目录下的文件复制到容器内部
docker cp ./test.txt mytomcat:/root/t.txt 将宿主机当前目录下的文件复制到容器内部并命名为t.txt

docker cp mytomcat:/root/test_dir ./ 将容器内的文件夹复制到宿主机当前目录
docker cp mytomcat:/root/test.txt ./ 将容器内的文件复制到宿主机当前目录
docker cp mytomcat:/root/test.txt ./t.txt 将容器内的文件复制到宿主机当前目录并命名为t.txt

容器之间文件交互(不支持)
docker cp mytomcat1:/root/test.txt mytomcat2:/root

docker commit

注意:容器挂载目录中的文件不会被打包

主要作用是将配置好的一些容器生成新的镜像,可以得到复用(再次使用不需要再配置)。

docker commit -a "wyl" -m "注释信息" mytomcat tomcat8:1.0

-a 指定作者

-m 注释这个镜像信息 可以用docker inspect contain_name 查看信息

mytomcat 将哪个容器打包

tomcat8:1.0 打包出来的镜像名称和tag

docker export、import 不使用

主要用来制作基础镜像

会丢弃历史记录和元数据。启动export与import命令导出导入的镜像必须加/bin/bash或者其他/bin/sh,否则会报错。

将mytomcat容器打包成tomcat-export:v1镜像
docker export -o tomcat-export.tar mytomcat:latest 
docker export mytomcat:latest > tomcat-export.tar

导入镜像并指定镜像的名称和tag (必须指定)
docker import mytom.tar tom8:export
这里会生成一个新的镜像,可以执行多次指定镜像的名称不同即可

docker save、load 只用这个

将指定 镜像 保存成tar文件,不会丢弃历史记录和元数据,并可以回滚版本

结合commit命令。将容器打包成镜像。再将这个镜像打包成tar文件。进行数据迁移。

将tomcat:8.5.49镜像打包成tar包
docker save -o mytom.tar tomcat:8.5.49 或者docker save -o mytom.tar [imageId]

load加载mytom.tar包成镜像
docker load -i mytom.tar

**注意 加载 的镜像与原镜打包前的镜像完全一样。包括了镜像ID。所以必须 删除 之前的镜像才能 加载 

docker tag

重命名,注意不会修改原来的镜像,只会新增一个镜像

docker tag nginx:1.27.0 nginx:latest
docker tag [imageId] nginx:latest

docker system

docker system df 查看镜像、容器、挂载卷的数量、硬盘占用、活动状态等
docker system events 跟踪容器的状态。启停等,会打印到终端
docker system info 显示当前一些系统信息
docker system prune 移除没有在使用的镜像

docker create

创建不启动

除了-d命令不能用,其他命令都与docker run 一致

使用docker start 启动命令

emqx

nginx

redis

mysql

nacos

方式1:

直接将容器打包,使用docker commit + docker save。将整个容器所有东西都进行保存。

方式2:

使用数据卷。

数据卷介绍:

​ 宿主机中的某个文件/目录与容器内的某个文件/目录进行关联,宿主机/容器内对这个文件/目录进行修改都会实时同步。

*只支持容器启动时挂载,后期想挂载不允许。只能另辟蹊径。

挂载方式

docker run --name test-volume -d -p 8080:8080 -v ./data:/usr/src/data tomcat:8.5.49

启动容器,设置容器名称,后台运行,端口映射,数据卷挂载,所用容器。

注意:挂载路径要么绝对路径,比如:/home/wyl/docker_data:/usr/src/data;要么相对路径./data:/usr/src/data。不能出现这种写法:data:/usr/src/data。这种写法会出现挂载问题。

只读方式

docker run -dp 8080:8080 -v ./data:/user/src/data:ro tomcat:8.5.49

在路径后面加ro(read only)

注意:这个操作是针对容器内部的。./data:ro:/user/src/data这种方式错的,不能这么操作。加上这个操作之后,容器内部就不能进行文件写/创建操作。防止容器往文件乱写数据

数据卷共享

**方式1:**两个容器的数据都挂载到同一个宿主机目录。

docker run -dp 8080:8080 -v ./data:/user/src/data:ro tomcat:8.5.49
docker run -dp 8080:8080 -v ./data:/user/src/data:ro tomcat:8.5.49

**方式2:**使用数据卷volume方式。

这里创建了两个nginx应用。同时挂载到同一个数据卷html和nginx_conf。他们的数据是共享的。

docker run --name nginx1 -dp 82:80 -v html:/usr/share/nginx/html -v nginx_conf:/etc/nginx nginx:latest
docker run --name nginx2 -dp 82:80 -v html:/usr/share/nginx/html -v nginx_conf:/etc/nginx nginx:latest

实践

1.docker run方式设置网络模式

# 桥接模式 -p 才有用,会把内部的断开和宿主机的端口进行映射。这种时候用来做集群好用,一台机器部署多个同类型服务

#默认就是桥接bridge 会创建一个网卡
docker run -d -p 6380:6379 --name redis redis:6.0
#设置模式bridge 
docker run -d -p 6380:6379 --name redis --network bridge redis:6.0

# 创建自定义 bridge 网络 这种方式创建的桥接名称可以 自定义

docker network create my-network
docker run -d --name redis -p 6380:6379 --network my-network bridge redis:6.0


# host模式 可以提高性能。相当于直接把docker当成一个服务启动。跟直接部署没什么区别

docker run -d --name redis --network host redis:6.0
#查看端口占用
losf -i:6379


#none模式 这个应用没有网络功能,如果是redis这种应用部署就没有意义了
docker run -d --name redis --network none redis:6.0


#容器模式container  -p指令同样没有意义 这种一般用于其他容器走nginx,只开放nginx80端口
#假设当前已经有一个nginx容器在运行
docker run -d --name redis --network container:nginx redis:6.0


#覆盖网络overlay
#略过

2.docker-compose设置网络模式

  • bridge模式

    version: '3.8'
    services:
    	web:
    		image: nginx:1.27.0
    		ports:
    		  - "8080:80" #设置多个端口映射
    		  - "443:443"
    		networks:
    		 - backnet #后端网络
    		 - beforenet # 前端网络
    	api:
    		image: java-app:1.0.0
    		ports:
    		  - "48080:48080"
    		networks:
    		  - backnet 
    	resource:
    		image: resource:1.0.0
    		ports:
    		  - "88:88"
    		network:
    		  - beforenet 
    networks:
    	backnet:
    		driver: bridge
    	beforenet:
    		driver: bridge
    		ipam:
          		config:
            	  - subnet: 172.21.0.0/16
    
    
    
  • host模式

    version: '3.8'
    services:
    	web:
    		image: nginx:1.27.0
    		network_mode: "host" #直接配置成host
        api:
        	image: java-app:1.0.0
        	network_mode: "host" #直接配置成host
    
  • none

    
    
  • container

    
    

四种模式解析

  • bridge(默认)容器创建网卡

    • docker安装完成后,docker建立一个虚拟网卡docker0,bridge模式下的容器会把这个网卡当作网关。即容器nat方式->docker0->宿主机

    • 容器创建后会在宿主机建立一个网卡,这个网卡172.17.18.1(一般来说18代表建立了多少个容器,1代表网卡地址,容器里面的服务会以.1->.2开始分配)

  • *host *容器不创建网卡,性能更好延迟更低

    直接把容器当作一个进程了。会占用端口

    网络2
  • none

    没有网络,也就是说,这个容器没法与外界进行交流,而且也不会有网卡。

  • container

    和已经存在的容器共享一个网卡。不会创建网卡和ip。如果已经存在的容器可以和宿主机进行网络数据交换,那么新建的容器也能和宿主机进行网络数据交换。

网络3
  • 自定义桥接

​ 这种可以指定桥接方式

Embedded

FreeRTOS

/*
 * FreeRTOS Kernel V11.1.0 — Windows 仿真配置文件
 *
 * 本文件为 FreeRTOS 提供了在 Windows (MinGW) 上模拟运行时的全部配置选项。
 * 每个宏都附有详细的中文说明,方便学习和调试。
 *
 * SPDX-License-Identifier: MIT
 */

#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H

/*===========================================================================
 *  编译器兼容性
 *===========================================================================*/
#if defined(_MSC_VER)
    /* 消除 MSVC 关于不安全 CRT 函数的编译警告 */
    #define _CRT_SECURE_NO_WARNINGS
#endif

/*===========================================================================
 *  内核调度策略
 *===========================================================================*/

/*
 * configUSE_PREEMPTION —— 是否启用抢占式调度
 *   1 = 抢占式调度(高优先级任务就绪时立即抢占低优先级任务)
 *   0 = 协作式调度(任务必须主动让出 CPU,如调用 vTaskDelay() 或 taskYIELD())
 *   仿真环境通常用 1,更贴近真实嵌入式场景。
 */
#define configUSE_PREEMPTION                    1

/*
 * configUSE_PORT_OPTIMISED_TASK_SELECTION —— 是否使用硬件优化的任务选择算法
 *   1 = 使用位运算(如 BSR 指令)在 O(1) 时间内找到最高优先级就绪任务
 *   0 = 使用通用的链表遍历查找
 *   Windows 仿真下也支持,开启可提高效率。要求 configMAX_PRIORITIES ≤ 32。
 */
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1

/*
 * configUSE_TICKLESS_IDLE —— 低功耗无节拍模式
 *   0 = 关闭。空闲期间定时器中断仍然周期性产生
 *   1 = 开启。空闲期间停止周期性 Tick 中断以省电(用于电池供电设备)
 *   仿真环境不需要省电,始终设为 0。
 */
#define configUSE_TICKLESS_IDLE                 0

/*===========================================================================
 *  钩子函数 (Hook / Callback)
 *===========================================================================*/

/*
 * configUSE_IDLE_HOOK —— 空闲任务钩子
 *   1 = 使能,需要在代码中实现 vApplicationIdleHook()
 *   0 = 关闭
 *   空闲钩子在空闲任务每轮循环中被调用,可用于休眠或背景处理。
 */
#define configUSE_IDLE_HOOK                     0

/*
 * configUSE_TICK_HOOK —— Tick 中断钩子
 *   1 = 使能,需要在代码中实现 vApplicationTickHook()
 *   0 = 关闭
 *   Tick 钩子在每次 SysTick 中断中执行,要求非常短小精悍。
 */
#define configUSE_TICK_HOOK                     0

/*
 * configUSE_MALLOC_FAILED_HOOK —— 内存分配失败钩子
 *   1 = 使能,需要在代码中实现 vApplicationMallocFailedHook()
 *   0 = 关闭
 *   当 pvPortMalloc() 返回 NULL 时触发。
 */
#define configUSE_MALLOC_FAILED_HOOK            0

/*
 * configUSE_DAEMON_TASK_STARTUP_HOOK —— 守护任务启动钩子
 *   1 = 使能,定时器服务任务启动时会调用 vApplicationDaemonTaskStartupHook()
 *   0 = 关闭
 */
#define configUSE_DAEMON_TASK_STARTUP_HOOK      0

/*===========================================================================
 *  协程 (Co-routines,已过时)
 *===========================================================================*/

/*
 * configUSE_CO_ROUTINES —— 是否启用协程(旧版轻量级任务替代方案)
 *   1 = 启用,需要同时定义 configMAX_CO_ROUTINE_PRIORITIES
 *   0 = 关闭,协程是 v8.0 之前的特性,新项目建议直接用任务
 */
#define configUSE_CO_ROUTINES                   0

/*===========================================================================
 *  时钟与 Tick 配置
 *===========================================================================*/

/*
 * configCPU_CLOCK_HZ —— CPU 时钟频率 (Hz)
 *   在真实 MCU 上写入实际的主频,用于计算 tick 周期。
 *   在 Windows 仿真中只是一个参考值,实际定时精度取决于 Sleep()。
 */
#define configCPU_CLOCK_HZ                      ( ( unsigned long ) 12000000 )

/*
 * configTICK_RATE_HZ —— 系统 Tick 频率 (Hz)
 *   即每秒产生多少次 Tick 中断。1000 表示每 1ms 一次 Tick。
 *   vTaskDelay(1000) 会阻塞 1000 个 Tick = 1 秒。
 *   注意:Windows 仿真受 Sleep() 精度限制,实际节拍不会精确到毫秒级。
 */
#define configTICK_RATE_HZ                      ( ( TickType_t ) 1000 )

/*
 * configTICK_TYPE_WIDTH_IN_BITS —— Tick 计数的位宽
 *   可选值:
 *     TICK_TYPE_WIDTH_16_BITS — 16 位,最大计数值 65535
 *     TICK_TYPE_WIDTH_32_BITS — 32 位,最大计数值 ~49 天(@1000Hz)
 *     TICK_TYPE_WIDTH_64_BITS — 64 位(极少使用)
 *   Windows 64 位宿主下建议用 32 位,与 portmacro.h 配合最佳。
 */
#define configTICK_TYPE_WIDTH_IN_BITS           TICK_TYPE_WIDTH_32_BITS

/*===========================================================================
 *  任务参数
 *===========================================================================*/

/*
 * configMAX_PRIORITIES —— 系统支持的最大优先级数
 *   范围为 1~32(使用位运算优化时限制为 32)。
 *   优先级 0 为最低(空闲任务),数值越大优先级越高。
 *   这里设 5 级足够 HelloWorld 测试使用。
 */
#define configMAX_PRIORITIES                    ( 5 )

/*
 * configMINIMAL_STACK_SIZE —— 最小任务堆栈大小(单位:StackType_t 个数)
 *   空闲任务使用此大小。用户创建任务时建议不小于此值。
 *   在 Windows 仿真中,每个任务实际上是一个独立线程,
 *   堆栈只是映射到线程的预留空间,所以设小一点没关系。
 */
#define configMINIMAL_STACK_SIZE                ( ( unsigned short ) 70 )

/*
 * configTOTAL_HEAP_SIZE —— 动态分配的总堆大小(字节)
 *   仅 heap_1/2/4 使用(heap_3 使用 malloc 无此限制)。
 *   此处设置为 65KB,仿真时够用。
 */
#define configTOTAL_HEAP_SIZE                   ( ( size_t ) ( 65 * 1024 ) )

/*
 * configMAX_TASK_NAME_LEN —— 任务名称最大长度(含 '\0')
 *   设置太大会浪费内存,设 12 字节足够描述任务功能。
 */
#define configMAX_TASK_NAME_LEN                 ( 12 )

/*
 * configIDLE_SHOULD_YIELD —— 空闲任务是否主动让出 CPU
 *   1 = 空闲任务在执行一次回调后就让出 CPU,避免低优先级任务饥饿
 *   0 = 空闲任务与同优先级的其他任务共享时间片
 *   建议保持 1。
 */
#define configIDLE_SHOULD_YIELD                 1

/*
 * configUSE_TASK_NOTIFICATIONS —— 是否启用任务通知
 *   1 = 启用。每个任务自带一个 32 位通知值,可用于轻量级信号量/队列
 *   0 = 关闭以节省 RAM(每个任务省 8 字节)
 *   建议开启,任务通知比信号量更快。
 */
#define configUSE_TASK_NOTIFICATIONS            1

/*
 * configUSE_TIME_SLICING —— 是否启用同优先级时间片轮转
 *   1 = 开启,同优先级任务轮流运行(每个 Tick 切换一次)
 *   0 = 关闭,同优先级任务需主动让出 CPU
 */
#define configUSE_TIME_SLICING                  1

/*
 * configNUM_THREAD_LOCAL_STORAGE_POINTERS —— 线程本地存储指针的数量
 *   每个任务可以拥有 N 个 void* 类型的本地存储槽,
 *   用于任务私有的上下文数据,通过 vTaskSetThreadLocalStoragePointer() 访问。
 *   设为 0 可省一点 RAM。
 */
#define configNUM_THREAD_LOCAL_STORAGE_POINTERS 5

/*===========================================================================
 *  内存分配方式
 *===========================================================================*/

/*
 * configSUPPORT_STATIC_ALLOCATION —— 支持静态分配
 *   1 = 启用,任务/队列/信号量等可以用静态内存(需提供回调函数)
 *   0 = 关闭,仅使用动态分配
 *   设为 0 则无需实现 vApplicationGetIdleTaskMemory 等回调,
 *   代码更简洁,适合 HelloWorld。
 */
#define configSUPPORT_STATIC_ALLOCATION         0

/*
 * configSUPPORT_DYNAMIC_ALLOCATION —— 支持动态分配
 *   1 = 启用,xTaskCreate()/xQueueCreate() 等自动从堆中分配
 *   0 = 关闭,只能用 xTaskCreateStatic() 等静态版本
 *   简单程序用动态分配最方便。
 */
#define configSUPPORT_DYNAMIC_ALLOCATION        1

/*===========================================================================
 *  同步与通信(IPC)
 *===========================================================================*/

/*
 * configUSE_MUTEXES —— 是否启用互斥量
 *   1 = 启用,支持 xSemaphoreCreateMutex() 和优先级继承
 */
#define configUSE_MUTEXES                       1

/*
 * configUSE_RECURSIVE_MUTEXES —— 是否启用递归互斥量
 *   1 = 启用,支持 xSemaphoreCreateRecursiveMutex()
 *   允许同一个任务多次获取同一互斥量而不会死锁。
 */
#define configUSE_RECURSIVE_MUTEXES             1

/*
 * configUSE_COUNTING_SEMAPHORES —— 是否启用计数信号量
 *   1 = 启用,支持 xSemaphoreCreateCounting()
 *   用于管理多个资源的场景(如生产者-消费者)。
 */
#define configUSE_COUNTING_SEMAPHORES           1

/*
 * configQUEUE_REGISTRY_SIZE —— 队列注册表大小
 *   调试辅助功能,用于在调试器中按名称查找队列/信号量。
 *   0 表示不注册。非调试时可设为 0 省 RAM。
 */
#define configQUEUE_REGISTRY_SIZE               8

/*
 * configUSE_QUEUE_SETS —— 是否启用队列集
 *   1 = 启用,支持 xQueueCreateSet() 等,可同时等待多个队列/信号量
 */
#define configUSE_QUEUE_SETS                    1

/*===========================================================================
 *  软件定时器
 *===========================================================================*/

/*
 * configUSE_TIMERS —— 是否启用软件定时器
 *   1 = 启用,需要使用 xTimerCreate()/xTimerStart() 等 API
 *   0 = 关闭,定时器相关的功能不可用
 */
#define configUSE_TIMERS                        1

/*
 * configTIMER_TASK_PRIORITY —— 定时器服务任务优先级
 *   建议设为一个较高的优先级,确保定时器回调及时执行。
 */
#define configTIMER_TASK_PRIORITY               ( configMAX_PRIORITIES - 1 )

/*
 * configTIMER_QUEUE_LENGTH —— 定时器命令队列长度
 *   定时器 API(如 xTimerStart)通过此队列向定时器任务发送命令。
 *   10 条对于测试程序足够。
 */
#define configTIMER_QUEUE_LENGTH                10

/*
 * configTIMER_TASK_STACK_DEPTH —— 定时器服务任务堆栈深度
 *   为定时器回调预留足够的堆栈空间。
 */
#define configTIMER_TASK_STACK_DEPTH            ( configMINIMAL_STACK_SIZE * 2 )

/*===========================================================================
 *  调试与追踪
 *===========================================================================*/

/*
 * configUSE_TRACE_FACILITY —— 是否启用运行时统计追踪功能
 *   1 = 启用,支持 uxTaskGetSystemState() / vTaskGetInfo() 等调试 API
 *   0 = 关闭以减小代码体积
 */
#define configUSE_TRACE_FACILITY                1

/*
 * configCHECK_FOR_STACK_OVERFLOW —— 堆栈溢出检测等级
 *   0 = 关闭检测(最快)
 *   1 = 检测栈指针是否超出范围(较安全)
 *   2 = 方法 1 + 检测栈尾部标记是否被破坏(更可靠,但稍慢)
 *   HelloWorld 中设为 0,省去实现 vApplicationStackOverflowHook() 的麻烦。
 */
#define configCHECK_FOR_STACK_OVERFLOW          0

/*
 * configASSERT —— 断言宏
 *   在 FreeRTOS API 参数校验失败时触发。
 *   调试时建议开启,发布版本可以定义为空。
 *   此处实现为:触发断言时关中断并死循环。
 */
#define configASSERT( x )                       if( ( x ) == 0 ) { taskDISABLE_INTERRUPTS(); for( ;; ); }

/*===========================================================================
 *  API 功能裁剪(设为 1 表示此 API 可用)
 *===========================================================================*/

#define INCLUDE_xTaskGetSchedulerState          1   /* 获取调度器状态(运行/挂起/未启动) */
#define INCLUDE_vTaskDelay                      1   /* 任务相对延时 */
#define INCLUDE_vTaskDelayUntil                 1   /* 任务绝对延时(固定周期) */
#define INCLUDE_vTaskDelete                     1   /* 删除任务 */
#define INCLUDE_eTaskGetState                   1   /* 查询任务状态 */
#define INCLUDE_xTaskGetCurrentTaskHandle       1   /* 获取当前任务句柄 */
#define INCLUDE_xTaskGetIdleTaskHandle          1   /* 获取空闲任务句柄 */
#define INCLUDE_xTaskGetHandle                  1   /* 通过名称查找任务句柄 */
#define INCLUDE_xSemaphoreGetMutexHolder        1   /* 查询互斥量持有者 */
#define INCLUDE_xTimerPendFunctionCall          1   /* 在定时器任务上下文中执行函数 */

#endif /* FREERTOS_CONFIG_H */

  • 下载

    https://github.com/FreeRTOS 仓库有很多freeRTOS关于协议栈的实现,HTTP,TCP,MQTT等

    这里只需要核心代码去模拟

    https://github.com/FreeRTOS/FreeRTOS-Kernel
    
  • 安装cmake

    省略,可以前往 LVGL-vscode模拟

  • vscode安装各种插件

    省略,可以前往 LVGL-vscode模拟

  • vscode项目配置

    .vscode/settings.json文件

    {
        "cmake.sourceDirectory": "E:/_1/embedded-study/freeRTOS/vscode/FreeRTOS-Kernel",
        "cmake.cmakePath": "E:/Program Files/CMake/bin/cmake.exe"
    }
    
    • cmake.sourceDirectory:cmake那个CMakeLists.txt文件目录
    • cmake.cmakePath:前面安装cmake的目录
  • 移除不需要的文件/文件夹

    • 删除所有目录下的CMakeFile.txt
    • 删除其他无用文件。如图:
    1
  • 测试

  • 编写测试程序

    main.c

    #include <FreeRTOS.h>
    #include <task.h>
    #include <stdio.h>
    
    void vTask(void *pv)
    {
        (void)pv;
        for (;;)
        {
            printf("Hello World\n");
            vTaskDelay(1000);
        }
    }
    
    void main(void)
    {
        xTaskCreate(vTask, "Task", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
        vTaskStartScheduler();
    }
    
  • freeRTOS配置文件

    FreeRTOSConfig.h

  • CmakeList.txt文件

    cmake_minimum_required(VERSION 3.15)
    project(example LANGUAGES C)
    
    # ─── Select FreeRTOS port & heap ───
    set(FREERTOS_PORT "MSVC-MingW" CACHE STRING "" FORCE)
    set(FREERTOS_HEAP "4"          CACHE STRING "" FORCE)
    
    # ─── User-provided FreeRTOSConfig.h ───
    add_library(freertos_config INTERFACE)
    target_include_directories(freertos_config INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
    
    # ─── FreeRTOS-Kernel library (from root) ───
    add_subdirectory(../ FreeRTOS-Kernel)
    
    # ─── Our test executable ───
    add_executable(${PROJECT_NAME} main.c)
    target_link_libraries(${PROJECT_NAME} freertos_kernel freertos_config)
    
    set_target_properties(${PROJECT_NAME} PROPERTIES C_STANDARD 90)
    

任务切换

在多任务情况下,利用中断功能,以及现场保护。

当前任务->停止->用到的寄存器入栈->时间片到了->出栈->恢复继续执行

一共有三个任务为例:

​ 任务1,任务2,任务三。

当时间片1ms到来的时候进入中断,这时候cpu将一部分寄存器入栈,然后freeRTOS又将其他用到的寄存器入栈。注意入的是任务栈,不是cpu的中断处理栈。然后freeRTOS将sp指针指向任务2的栈,将栈帧里面的PC指针指向任务2的PC,LR寄存器也改成任务2的LR。cpu恢复现场的时候就不是恢复任务1的现场,而是任务2的现场,从而达到任务切换的效果。

AI修改后

假设任务1、任务2、任务3 同优先级,按 1ms 时间片轮转。

任务1正在跑:

  • CPU 的 PSP 指向任务1的栈。
  • PC 指向任务1当前执行到的代码。

1ms 时间片到了:

  1. SysTick 中断触发,FreeRTOS 通常请求 PendSV,真正切换在 PendSV 里做。
  2. CPU 硬件自动把任务1的一部分现场压入“任务1自己的栈”:
    • R0、R1、R2、R3、R12、LR、PC、xPSR
  3. FreeRTOS 在 PendSV 里再把剩下的现场压入任务1的栈:
    • R4 ~ R11
  4. 然后把当前 SP/PSP 保存到任务1的 TCB:
    • TCB1.SP = 任务1栈顶
  5. 调度器选择下一个任务:任务2。
  6. 从 TCB2 里取出任务2的 SP,让 PSP 指向任务2的栈。
  7. 从任务2的栈里恢复 R4 ~ R11。
  8. PendSV 异常返回时,CPU 硬件自动从任务2的栈里弹出:
    • R0、R1、R2、R3、R12、LR、PC、xPSR
  9. 于是 PC 变成任务2上次暂停的位置,CPU 继续跑任务2。

任务2跑 1ms 后,同理保存到任务2的 TCB,再切到任务3。任务3跑完再切回任务1。

任务1栈:
... [R4-R11] [R0-R3, R12, LR, PC, xPSR]  <- 切出时 PSP 保存到 TCB1

任务2栈:
... [R4-R11] [R0-R3, R12, LR, PC, xPSR]  <- 切入时 PSP 从 TCB2 取出

TCB1 -> 保存任务1的 SP
TCB2 -> 保存任务2的 SP
TCB3 -> 保存任务3的 SP
#include <FreeRTOS.h>
#include <task.h>
#include <stdio.h>
TaskHandle_t h1;
void vTask1(void *pv)
{
    (void)pv; /* 防止编译器警告:未使用参数 pv */
    for (;;) // 无限循环,FreeRTOS 任务绝不能直接 return
    {
        printf("Hello 1\n");
        vTaskDelay(100); //freertos专用定时
    }
}
void main(void)
{
     BaseType_t rt = xTaskCreate(vTask1, "Task1", configMINIMAL_STACK_SIZE, NULL, 1, &h1);
    vTaskStartScheduler();//启动 FreeRTOS 调度器
}
参数说明:
	1. vTask1              - 任务函数指针
	2. "Task1"             - 任务名(调试用,最大长度由 configMAX_TASK_NAME_LEN 决定)
	3. configMINIMAL_STACK_SIZE
                        - 任务栈大小(单位是“字”,不是字节)
                          configMINIMAL_STACK_SIZE 是 FreeRTOS 推荐的最小栈
                          对于 printf 这类函数,实际项目中可能需要更大
	4. NULL                - 传给任务函数的参数(这里不需要,传 NULL)
	5. 1                   - 任务优先级(数字越大,优先级越高) 0 是最低优先级
	6. &h1                 - 任务句柄,用于后续操作该任务

	BaseType_t rt		   -返回值:pdPASS / errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY

执行 xTaskCreate(...)FreeRTOS会从 heap_4.c 管理的堆中分配两块核心内存:

TCB:任务控制块
Stack:任务栈 ,大小configMINIMAL_STACK_SIZE

需要在配置文件中开启

/*
 * configSUPPORT_STATIC_ALLOCATION —— 支持静态分配
 *   1 = 启用,任务/队列/信号量等可以用静态内存(需提供回调函数)
 *   0 = 关闭,仅使用动态分配
 *   设为 0 则无需实现 vApplicationGetIdleTaskMemory 等回调,
 *   代码更简洁,适合 HelloWorld。
 */
#define configSUPPORT_STATIC_ALLOCATION         1

全局状态链表

  1. 就绪链表 pxReadyTasksLists[ configMAX_PRIORITIES ] 每个优先级一个链表,所以是 configMAX_PRIORITIES 个。 当前正在运行的 Running 任务也挂在自己的优先级就绪链表里,只是 pxCurrentTCB 指向它。
  2. 延时链表 xDelayedTaskList1、xDelayedTaskList2 共 2 个,通过 pxDelayedTaskList 和 pxOverflowDelayedTaskList 使用。 两个是为了处理 tick 计数器溢出。
  3. 挂起链表 xSuspendedTaskList 一般 1 个,受 INCLUDE_vTaskSuspend 影响。
  4. 等待终止链表 xTasksWaitingTermination 一般 1 个,受 INCLUDE_vTaskDelete 影响。 任务删除自身后先挂这里,由空闲任务回收 TCB/栈。
  5. Pending Ready 链表 xPendingReadyList 调度器被挂起时,中断中唤醒/恢复的任务先暂存在这里,等 xTaskResumeAll() 后再移入就绪链表。

事件等待链表

  1. 事件等待链表不是全局固定链表 每个队列、信号量、互斥量、事件组、流缓冲区等对象内部都有自己的等待链表,例如:

    • 队列:xTasksWaitingToSend、xTasksWaitingToReceive
    • 信号量/互斥量复用队列结构
    • 事件组:xTasksWaitingForBits

    任务通过 TCB 里的 xEventListItem 挂入这些事件等待链表。 所以“阻塞链表”通常指这些对象私有的等待链表,数量不固定。

因此,如果只算全局状态链表,常见是:

configMAX_PRIORITIES 个就绪链表 + 2 个延时链表 + 1 个挂起链表 + 1 个等待终止链表 + 1 个 PendingReady 链表。 事件等待链表另算。

创建

xTaskCreate()` → 加入就绪链表 → `Ready

就绪与运行

  • Ready → Running:调度器选中最高优先级就绪任务。
  • Running → Ready:被更高优先级任务抢占,或同优先级时间片用完。 注意:运行态任务仍在就绪链表中。

进入阻塞

运行中的任务调用阻塞 API:

  • vTaskDelay() / vTaskDelayUntil() → 从就绪链表移除 → 加入延时链表 → Blocked
  • 等待队列/信号量/事件组,且条件不满足:
    • 无限等待:从就绪链表移除 → 加入对应事件等待链表 → Blocked
    • 带超时等待:从就绪链表移除 → 加入事件等待链表,同时加入延时链表 → Blocked

阻塞唤醒

  • 超时:tick 中断从延时链表移除;如果还在事件等待链表,也移除 → 加入就绪链表 → Ready
  • 事件发生:从事件等待链表移除;如果还在延时链表,也移除 → 加入就绪链表或 PendingReady → Ready

挂起

  • vTaskSuspend() 从就绪/延时/事件等待/PendingReady 中移除 → 加入挂起链表 → Suspended
  • vTaskResume() / xTaskResumeFromISR() 从挂起链表移除 → 加入就绪链表或 PendingReady → Ready

注意:挂起恢复后通常直接变就绪,不会自动恢复到挂起前的阻塞等待。

删除

  • vTaskDelete(NULL) 删除自身 → 从各链表移除 → 加入 xTasksWaitingTermination → 空闲任务回收
  • vTaskDelete(其他任务句柄) → 从各链表移除 → 直接释放 TCB/栈
创建
  |
  v
Ready  <---------------- 事件发生 / 超时 / 恢复 / PendingReady合并
  |  ^
  |  | 抢占 / 时间片到
  v  |
Running
  |
  | vTaskDelay / 等事件失败
  v
Blocked
  |-- 纯延时:挂延时链表
  |-- 等事件:挂事件等待链表
  |-- 等事件+超时:事件等待链表 + 延时链表
  |
  | 超时或事件发生
  v
Ready

Ready/Running/Blocked --vTaskSuspend--> Suspended --vTaskResume--> Ready/PendingReady
Running --vTaskDelete(NULL)--> xTasksWaitingTermination --空闲任务--> 释放

多任务多优先级防止饥饿

  1. 让高优先级任务少占 CPU,低优先级任务有机会运行。
  2. 改用同优先级轮转:把需要公平的任务放同一优先级,开启时间片。
  3. 自己实现动态优先级/老化:临时提升饥饿任务,但会牺牲实时性。

全部相同优先级,中断切换

创建两个任务

xTaskCreate(vLowPriorityTask,"Low",configMINIMAL_STACK_SIZE,NULL,1,NULL);
xTaskCreate(vHighPriorityTask,"High",configMINIMAL_STACK_SIZE,NULL,2,NULL);

这里优先级Priority有三种情况:

  • TaskA > TaskB

    TaskA 一直占用时间片,只有当TaskA 进行vTaskDelay让出cpu的操作之后TaskB才能拿到时间片执行TaskB里面的业务。但是vTaskDelay结束之后,TaskA会马上抢占下一个时间片,TaskB又拿不到时间片了。低优先级任务可能发生饥饿。

    即:高优先级任务从Blocked变成Ready时,可以立即抢占低优先级任务。

  • TaskA = TaskB

    交替轮询,不管他们各自是否进行了vTaskDelay,一但他们vTaskDelay结束之后就会加入到轮询。

  • TaskA < TaskB

    与上面第一种刚好相反。

主要是学习任务状态相互转换

参考概念:005_任务状态及相互转换

创建

创建两个任务,一个worker,一个controller。controller的优先级比worker要高

static TaskHandle_t xWorkerHandle = NULL;
xTaskCreate(vWorkerTask,Worker",configMINIMAL_STACK_SIZE,NULL,1,&xWorkerHandle);

xTaskCreate(vControllerTask,"Controller",configMINIMAL_STACK_SIZE,NULL,2,NULL);

worker需要提供一个任务句柄,用来控制任务。

vWorkerTask

static void vWorkerTask(void *pvParameters)
{
    unsigned long ulRunCount = 0;

    (void)pvParameters;

    for (;;)
    {
        printf("WORKER running!\n");
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

测试

在vControllerTask中通过句柄操作vWorkerTask

static void vControllerTask(void *pvParameters)
{
	(void)pvParameters;
    vTaskDelay(pdMS_TO_TICKS(1000));
    vTaskSuspend(xWorkerHandle); //挂起worker任务
    
    vTaskDelay(pdMS_TO_TICKS(1000));
    vTaskResume(xWorkerHandle); //恢复worker任务
    
    vTaskDelay(pdMS_TO_TICKS(1000));
    vTaskDelete(xWorkerHandle); //删除worker任务
    xWorkerHandle = NULL;//防止空指针
    
    vTaskDelete(NULL); // 删除自身任务
}

Blocked和Suspended的区别

对比BlockedSuspended
产生方式Delay、等待队列等vTaskSuspend()
是否等待条件是否
时间到会否恢复会不会
如何恢复时间或事件满足必须调用vTaskResume()
是否参与调度否否
TCB和栈是否保留是是

Controller删除自己

vTaskDelete(NULL);

Controller正在使用自己的栈,所以其资源需要等切换走以后,由Idle任务安全清理。

Timer Service Task

配置中

#define configUSE_TIMERS 1

它负责:

  • 软件定时器回调;
  • 软件定时器命令;
  • xTimerPendFunctionCall();
  • 定时器到期处理。

Idle任务

执行vTaskStartScheduler(),FreeRTOS内核会自动创建Idle任务。优先级为0,最低优先级。

reeRTOS队列提供:

  • 任务间数据传递;
  • 按值复制数据;
  • 多条消息缓存;
  • 队列为空时阻塞接收任务;
  • 队列满时阻塞发送任务;
  • 自动处理并发访问。

创建

static QueueHandle_t xSensorQueue = null;
xSensorQueue = xQueueCreate(3, sizeof(SensorMessage_t));

生产的消息类型

typedef struct
{
    uint32_t ulSequence;
    int32_t lValue;
} SensorMessage_t;

生产者

static void vProducerTask(void *pvParameters)
{
    SensorMessage_t xMessage;

    (void)pvParameters;
    xMessage.ulSequence = 0;
    xMessage.lValue = 100;

    for (;;)
    {
        if (xQueueSend(xSensorQueue, &xMessage, portMAX_DELAY) == pdPASS) //消息发送成功返回
        {
            printf("生产消息\n");
        }
        xMessage.ulSequence++;
        xMessage.lValue += 10;
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

消费者

static void vConsumerTask(void *pvParameters)
{
    SensorMessage_t xReceived; //接收消息的对象

    (void)pvParameters;

    for (;;)
    {
    if (xQueueReceive(xSensorQueue, &xReceived, portMAX_DELAY) == pdPASS) //队列是否为空判断
    {
        printf("处理消息\n");
    }
    }
}

创建两个任务

xTaskCreate(vConsumerTask,"Consumer",configMINIMAL_STACK_SIZE,NULL,2,NULL); //高优先级

xTaskCreate(vProducerTask,"Producer",configMINIMAL_STACK_SIZE,NULL,1,NULL);

启动之后流程

  1. 调度器首先运行Consumer。但此时队列为空。

    Consumer:Running → Blocked
    
  2. portMAX_DELAY在当前配置下表示持续等待,直到有消息到达。

  3. Consumer阻塞以后,Producer才开始运行。

  4. 队列会把消息内容复制到自己的内部存储区:

    Producer的xMessage
            ↓ 复制
    Queue内部消息槽
    
    队列保存的不是:&xMessage指针,而是整个:SensorMessage_t结构体的副本。
    
  5. Producer把消息写入队列

  6. Consumer等待的条件满足:队列不再为空

    Consumer:Blocked → Ready
    Consumer优先级2 > Producer优先级1
    所以:
    Producer:Running → Ready
    Consumer:Ready → Running
    
  7. 消费信息,队列又空了,consumer再次阻塞。

  8. Producer才恢复执行。

Producer进入xQueueSend()
→ 消息复制进队列
→ Consumer被唤醒
→ Consumer抢占Producer
→ Consumer取走并打印消息
→ Consumer再次阻塞
→ Producer从xQueueSend()中恢复
→ Producer打印sent
  1. Queue用于在任务之间安全传递数据。
  2. xQueueSend默认复制消息内容。
  3. 队列为空时,接收任务可以进入Blocked。
  4. 消息到来后,接收任务自动恢复Ready。
  5. 如果被唤醒任务优先级更高,发送API内部就可能发生抢占。
  6. 队列让任务等待数据,而不是持续轮询数据。

生产者生产消息很快

消费者处理消息慢

生产者

设置

xQueueSend(xDataQueue,&ulValue,pdMS_TO_TICKS(200)); //如果队列满,最多等待200 Tick
等待参数队列满时行为适用情况
0立即失败数据允许丢弃、任务不能阻塞
有限Tick等待后超时一般业务任务
portMAX_DELAY一直等待数据绝对不能丢且允许长期阻塞

队列满后,Producer不会持续轮询,而是进入Blocked:

Producer:Running → Blocked
原因:等待队列出现空位

此时CPU可以运行:

  • Consumer;
  • 其他Ready任务;
  • Idle任务;
  • 中断。

所以队列满造成的是:

Producer任务阻塞

生产发送超时处理

if (xResult == pdPASS)
{
    /* 发送成功 */
}
else
{
    /* 超时,记录丢弃 */
}

创建两个任务

接收者优先级比较高

static TaskHandle_t xHandlerTaskHandle = NULL;
xTaskCreate(vEventHandlerTask,"Handler",configMINIMAL_STACK_SIZE,NULL,2,&xHandlerTaskHandle);

xTaskCreate(vEventGeneratorTask,"Generator",configMINIMAL_STACK_SIZE,NULL,1,NULL);

通知发送者

static void vEventGeneratorTask(void *pvParameters)
{
    (void)pvParameters;

    for (;;)
    {
        vTaskDelay(pdMS_TO_TICKS(500));
        xTaskNotifyGive(xHandlerTaskHandle); //就是给一个简单的通知
    }
}

通知接收者

static void vEventHandlerTask(void *pvParameters)
{
    (void)pvParameters;

    for (;;)
    {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY); //返回值为1
    }
}

如果当前没有通知:

Handler:Running → Blocked

时序

Handler开始运行
→ 没有通知
→ ulTaskNotifyTake()
→ Handler进入Blocked

Generator延时结束
→ Generator运行
→ xTaskNotifyGive()
→ Handler通知值加一
→ Handler恢复Ready

Handler优先级更高
→ Handler抢占Generator
→ ulTaskNotifyTake()返回1
→ Handler处理事件
→ Handler再次等待
→ Handler进入Blocked

Generator恢复运行
→ 输出“continues after give”
→ 再次延时500ms

创建二值信号量

static SemaphoreHandle_t xEventSemaphore = NULL;
xEventSemaphore = xSemaphoreCreateBinary();

创建两个任务

注意这里GeneratorTask优先级最高

xTaskCreate(vSemaphoreReceiverTask,"SemReceiver",configMINIMAL_STACK_SIZE,NULL,1,NULL);

xTaskCreate(vSemaphoreGeneratorTask,"SemGenerator",configMINIMAL_STACK_SIZE,NULL,2,NULL);

给出

for(int i = 0;i < 3; i++)
{
	xSemaphoreGive(xEventSemaphore);
}
vTaskDelay(pdMS_TO_TICKS(1000));//需要主动让出CPU,不然Take会出现任务饥饿

这里由于GeneratorTask优先级比ReceiverTask高,所以会执行完这个for,也就是Give三次。

处理

xSemaphoreTake(xEventSemaphore,portMAX_DELAY);

处理的时候只会处理一次

流程

Receiver调用Take
→ Semaphore=0
→ Receiver进入Blocked

Generator运行
→ 第一次Give
→ Semaphore=1
→ Receiver变成Ready
→ 但Receiver优先级低,不能抢占Generator

Generator继续运行
→ 第二次Give
→ Semaphore已经为1
→ Give失败

Generator继续运行
→ 第三次Give
→ Semaphore仍然为1
→ Give失败

Generator调用vTaskDelay
→ Generator进入Blocked

调度器选择Receiver
→ Receiver开始运行
→ Take成功
→ Semaphore从1变成0
static SemaphoreHandle_t xEventCounter = NULL;
xEventCounter = xSemaphoreCreateCounting(3,0);//计数3
xSemaphoreTake(xEventCounter, portMAX_DELAY);

由于计数仍然大于0,所以不会阻塞:

  1. Mutex用于保护共享资源。
  2. 任意时刻只有一个任务可以持有Mutex。
  3. Mutex被占用时,其他任务可以阻塞等待。
  4. 高优先级任务也不能强行夺走Mutex。
  5. Mutex应由取得它的任务释放。
  6. 持锁时间应该尽量短。
  7. 不要在持锁期间执行长延时、慢速打印或阻塞操作。
  8. Mutex和二值信号量用途不同。

创建互斥锁

static SemaphoreHandle_t xResourceMutex = NULL;
xResourceMutex = xSemaphoreCreateMutex();

创建两个任务

xTaskCreate(vLowPriorityTask,"LowMutex",configMINIMAL_STACK_SIZE,NULL,1,NULL);

xTaskCreate(vHighPriorityTask,"HighMutex",configMINIMAL_STACK_SIZE,NULL,2,NULL);

高优先级任务

static void vHighPriorityTask(void *pvParameters)
{
    (void)pvParameters;

    vTaskDelay(pdMS_TO_TICKS(100));//先让出cpu给到低优先级的任务

    for (;;)
    {
        if (xSemaphoreTake(xResourceMutex, portMAX_DELAY) == pdTRUE)
        {
            xSemaphoreGive(xResourceMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

低优先级任务

static void vLowPriorityTask(void *pvParameters)
{
    (void)pvParameters;

    for (;;)
    {
        if (xSemaphoreTake(xResourceMutex, portMAX_DELAY) == pdTRUE)
        {
            vTaskDelay(pdMS_TO_TICKS(300));//又让出给到高优先级,如果不让出去,高优先级任务也只能阻塞等待
            xSemaphoreGive(xResourceMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(700));
    }
}

流程

LOW取得Mutex
Mutex Owner=LOW

HIGH醒来并抢占LOW
HIGH尝试Take
Mutex仍属于LOW
HIGH进入Blocked

LOW恢复运行
LOW完成共享资源操作
LOW执行Give

HIGH恢复Ready
HIGH优先级更高
HIGH抢占LOW

HIGH的Take返回成功
Mutex Owner=HIGH
HIGH修改共享资源
HIGH执行Give
  1. High等待Low持有的Mutex会产生优先级反转风险。
  2. Mutex会让Low临时继承High的优先级。
  3. 继承后的Low可以避免被Medium抢占。
  4. Low释放Mutex后恢复原始优先级。
  5. High仍需等待Low完成临界区。
  6. 优先级继承降低额外干扰,不能消除锁等待。
  7. 共享资源使用Mutex,而不是二值信号量。
  8. 临界区和持锁时间必须尽量短。
  1. 持有一个Mutex时等待另一个Mutex可能产生死锁。
  2. 两个任务相反的加锁顺序容易形成循环等待。
  3. 死锁时调度器仍然运行,只是相关任务永久Blocked。
  4. 优先级继承不能解决循环等待。
  5. 最重要的预防方法是统一加锁顺序。
  6. 第二个锁获取失败时必须释放已经持有的锁。
  7. 有限超时可以检测和恢复,但不能代替正确设计。
  8. 尽量避免同时持有多个Mutex。
static SemaphoreHandle_t xMutexA = NULL;

static SemaphoreHandle_t xMutexB = NULL;

xMutexA = xSemaphoreCreateMutex();

xMutexB = xSemaphoreCreateMutex();

两个任务互相不释放对方的锁

  1. Event Group使用不同Bit表示不同条件。
  2. SetBits只设置指定Bit,不影响其他Bit。
  3. WaitBits可以等待任意条件或全部条件。
  4. 条件不满足时,等待任务进入Blocked。
  5. 最后一个条件满足时,等待任务恢复Ready。
  6. clearOnExit可以在成功后自动清除目标位。
  7. Event Bit只表示0或1,不累计事件次数。
  8. Event Group适合系统状态组合,不适合传递数据。

假设系统只有同时满足以下条件才能启动:

配置加载完成
传感器初始化完成
网络连接完成
#define EVENT_CONFIG_READY  (1U << 0)
#define EVENT_SENSOR_READY  (1U << 1)
#define EVENT_NETWORK_READY (1U << 2)

#define EVENT_ALL_READY \    //EVENT_ALL_READY 标志事件是否全部完成
    (EVENT_CONFIG_READY  | \
     EVENT_SENSOR_READY  | \
     EVENT_NETWORK_READY)

创建事件组

static EventGroupHandle_t xSystemEvents = NULL;
xSystemEvents = xEventGroupCreate();

创建后的事件位初始为:

0x00

即所有条件都没有满足。

创建四个任务:配置加载,传感器初始化,网络初始化,管理

Manager优先级最高,其他的事件处理完之后阻塞结束,立马抢占CPU

xTaskCreate(vConfigTask, "Config",configMINIMAL_STACK_SIZE, NULL, 1, NULL);
xTaskCreate(vSensorTask, "Sensor",configMINIMAL_STACK_SIZE, NULL, 1, NULL);
xTaskCreate(vNetworkTask, "Network",configMINIMAL_STACK_SIZE, NULL, 1, NULL);
xTaskCreate(vSystemManagerTask, "Manager",configMINIMAL_STACK_SIZE, NULL, 2, NULL);

vSystemManagerTask

static void vSystemManagerTask(void *pvParameters)
{
    (void)pvParameters;
    for (;;)
    {
        EventBits_t xBits;
        xBits = xEventGroupWaitBits(xSystemEvents, // 等待哪个事件组
                                    EVENT_ALL_READY, // 等待哪些Bit
                                    pdTRUE,			// 成功后自动清除这些Bit
                                    pdTRUE,			// 必须全部满足
                                    portMAX_DELAY); // 一直等待
        printf("全部完成,Blocked → Running");
    }
}

vConfigTask

static void vConfigTask(void *pvParameters)
{
    (void)pvParameters;
    vTaskDelay(pdMS_TO_TICKS(200));

    for (;;)
    {
        xEventGroupSetBits(xSystemEvents, EVENT_CONFIG_READY);
        vTaskDelete(NULL);
    }
}

vSensorTask

static void vSensorTask(void *pvParameters)
{
    (void)pvParameters;
    vTaskDelay(pdMS_TO_TICKS(400));

    for (;;)
    {
        xEventGroupSetBits(xSystemEvents, EVENT_SENSOR_READY);
        vTaskDelete(NULL);
    }
}

vNetworkTask

static void vNetworkTask(void *pvParameters)
{
    (void)pvParameters;
    vTaskDelay(pdMS_TO_TICKS(700));

    for (;;)
    {
        xEventGroupSetBits(xSystemEvents, EVENT_NETWORK_READY);
        vTaskDelete(NULL);
    }
}
  1. 软件定时器分为单次和自动重载两种。
  2. xTimerCreate只创建,xTimerStart才启动。
  3. 所有回调都在公共Timer Service Task中执行。
  4. 回调之间是串行的。
  5. 回调必须短小,不能长时间阻塞。
  6. 耗时工作应通过Queue或Notification交给普通任务。
  7. 单次Timer到期后停止。
  8. 自动重载Timer到期后安排下一周期。

FreeRTOS软件定时器用于:

经过一段时间后执行回调
或者
按照固定周期反复执行回调

创建定时器

周期定时器

static TimerHandle_t xPeriodicTimer = NULL;

//名称:Periodic, 周期:400 Tick, 自动重载:pdTRUE, Timer ID:NULL,回调函数:vPeriodicTimerCallback
xTimerCreate("Periodic",pdMS_TO_TICKS(400),pdTRUE,NULL,vPeriodicTimerCallback);

static void vPeriodicTimerCallback(TimerHandle_t xTimer)
{
    if (condition)
    {
        (void)xTimerStop(xTimer, 0); //主动停止周期定时器
    }
}

单次定时器

static TimerHandle_t xOneShotTimer = NULL;
//名称:Periodic, 周期:100Tick, 自动重载:pdFALSE, Timer ID:NULL,回调函数:vOneShotTimerCallback
xTimerCreate("OneShot",pdMS_TO_TICKS(100),pdFALSE,NULL,vOneShotTimerCallback);

static void vOneShotTimerCallback(TimerHandle_t xTimer)
{
    (void)xTimer;
}

启动定时器

//启动哪个定时器,第二个参数:向Timer命令队列发送启动命令时,最多等待多少Tick
BaseType_t xPeriodicStartResult = xTimerStart(xPeriodicTimer, 0); 
BaseType_t xOneShotStartResult = xTimerStart(xOneShotTimer, 0);

软件定时器不是独立任务

每个软件定时器并没有自己的任务和任务栈。

FreeRTOS只创建一个公共任务:

Timer Service Task

也称:

Timer Daemon Task

所有软件定时器回调都由这个任务执行:

Timer Service Task
├── 调用Periodic回调
├── 调用One-shot回调
└── 调用其他软件定时器回调

所以定时器回调是串行执行的,不会同时运行。

Timer Service Task优先级

配置:

#define configTIMER_TASK_PRIORITY \
    (configMAX_PRIORITIES - 1)

回调不能阻塞

所有Timer共享一个Timer Service Task。

因此Timer回调中不要使用:

vTaskDelay()
xQueueReceive(..., portMAX_DELAY)
xSemaphoreTake(..., portMAX_DELAY)
长时间循环
慢速外设操作

回调中应该做什么

推荐:

设置标志
发送任务通知
发送短队列消息
启动或停止另一个Timer

常用Timer API

xTimerCreate()        /* 创建 */
xTimerStart()         /* 启动 */
xTimerStop()          /* 停止 */
xTimerReset()         /* 从当前时刻重新计时 */
xTimerChangePeriod()  /* 修改周期 */
xTimerDelete()        /* 删除 */
xTimerIsTimerActive() /* 查询是否活动 */
  1. Timer回调运行在公共Timer Service Task中。
  2. Timer回调应快速返回。
  3. 回调适合发送通知、Queue消息或设置事件位。
  4. 耗时业务应该交给普通Worker任务。
  5. Worker没有工作时通过通知进入Blocked。
  6. Timer决定触发时间,Worker负责实际处理。
  7. Worker处理不过来时,通知计数可能累积。
  8. 每次工作携带不同数据时,应使用Queue。
Timer到期
→ Callback快速发送任务通知
→ Callback立即返回
→ Worker被唤醒
→ Worker执行耗时操作

创建定时器

static TimerHandle_t xWorkTimer = NULL;

xWorkTimer = xTimerCreate("WorkTimer",pdMS_TO_TICKS(TIMER_PERIOD_MS),pdTRUE,NULL,vWorkTimerCallback);

创建worker

static TaskHandle_t xWorkerTaskHandle = NULL; // 任务句柄
xWorkerResult = xTaskCreate(vWorkerTask,"TimerWorker",configMINIMAL_STACK_SIZE,NULL,2,&xWorkerTaskHandle;

static void vWorkerTask(void *pvParameters)
{
    (void)pvParameters;
    for (;;)
    {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);//一直等待
        vTaskDelay(pdMS_TO_TICKS(200));//模拟处理,处理完成之后让步
    }
}

定时回调

static void vWorkTimerCallback(TimerHandle_t xTimer)
{
    xTaskNotifyGive(xWorkerTaskHandle);//唤醒worker
}

另一个低优先级任务

xTaskCreate(vHeartbeatTask,"Heartbeat",configMINIMAL_STACK_SIZE,NULL,1,
NULL);
static void vHeartbeatTask(void *pvParameters)
{
    (void)pvParameters;
    for (;;)
    {
        vTaskDelay(pdMS_TO_TICKS(200));//模拟一直处理,当定时器给到通知worker之后,woker抢占cpu
    }
}

启动定时器

xTimerStart(xWorkTimer, 0)

Stream Buffer: 发送"ABC"+"12345" → 接收方可能一次收到"ABC12" Message Buffer: 发送"ABC"+"12345" → 接收方分两次收到"ABC"和"12345"

Stream Buffer用于在任务或中断之间传输连续字节流,典型用途包括:

  • UART接收数据;
  • 网络字节流;
  • DMA数据流;
  • 连续ADC采样;
  • 音频数据。
  1. Stream Buffer传输连续原始字节。
  2. Stream Buffer不保存Send调用之间的消息边界。
  3. 一次Send可能被拆成多次Receive。
  4. 多次Send也可能被一次Receive合并。
  5. 没有数据时Reader可以进入Blocked。
  6. 高优先级Reader可能在Send内部抢占Writer。
  7. Stream Buffer主要为单Writer、单Reader优化。
  8. 字节流协议需要应用层处理长度、帧头或分隔符。

创建StreamBuffer

static StreamBufferHandle_t xByteStream = NULL;
xByteStream = xStreamBufferCreate(16,1);//缓冲容量:16字节,触发级别:至少1字节

创建发送任务

低优先级

xTaskCreate(vStreamWriterTask,"StreamWriter",configMINIMAL_STACK_SIZE,NULL,1,NULL);
static void vStreamWriterTask(void *pvParameters)
{
    static const char pcFirstChunk[] = "ABC";
    static const char pcSecondChunk[] = "12345";

    (void)pvParameters;

    for (;;)
    {
        //发完第一次,停下来给Reader处理
        xStreamBufferSend(xByteStream,pcFirstChunk,sizeof(pcFirstChunk) - 1U,pdMS_TO_TICKS(100));
        vTaskDelay(pdMS_TO_TICKS(300));

        xStreamBufferSend(xByteStream,pcSecondChunk,sizeof(pcSecondChunk) - 1U,pdMS_TO_TICKS(100));;
        vTaskDelay(pdMS_TO_TICKS(300));
    }
}

创建接收任务

高优先级

xTaskCreate(vStreamReaderTask,"StreamReader",configMINIMAL_STACK_SIZE,NULL,2,NULL);
static void vStreamReaderTask(void *pvParameters)
{
    char pcReceiveBuffer[4 + 1U];//Reader每次最多读取4字节

    (void)pvParameters;

    for (;;)
    {
        xReceived = xStreamBufferReceive(xByteStream,pcReceiveBuffer,READER_CHUNK_BYTES,portMAX_DELAY);//没有数据时一直等待
        pcReceiveBuffer[xReceived] = '\0';
    }
}
  1. Message Buffer保存变长消息。
  2. 一次Send对应一条消息。
  3. 一次Receive最多返回一条完整消息。
  4. 多次Send不会被一次Receive合并。
  5. 一条消息不会被拆成多次Receive。
  6. 接收数组必须能容纳完整消息。
  7. Buffer容量还包含消息长度字段开销。
  8. 默认使用场景仍然是单Writer、单Reader。

适用场景

Message Buffer适合:

  • 不同长度的命令;
  • 不同长度的协议帧;
  • 日志字符串;
  • 模块间变长消息;
  • 一写一读的消息通道。
特性QueueMessage Buffer
消息长度固定可变
消息边界保留保留
多生产者/消费者支持较好默认单写单读
结构体传递很适合需要自行定义格式
变长协议帧浪费固定空间更适合

Linux技术栈

Libjpeg库

一、像素格式

1. 一句话理解

每个像素是一个小点,用 3 个数(红/绿/蓝)就能描述颜色。但"这 3 个数在内存里怎么摆"能摆出几十种姿势,每种姿势就是一种像素格式。

2. 区别就 3 件事

① 字节顺序:RGB 还是 BGR?

RGB:  内存里依次是 红 绿 蓝
BGR:  内存里依次是 蓝 绿 红

颜色完全一样,只是顺序相反。BGR 来自 Windows BMP 文件格式的早年约定,显示硬件和 OpenCV 跟着抄。纯历史惯例,没有谁更好。

② 占几个字节:3 个还是 4 个?

RGB  (3字节):  R G B         —— 省内存
RGBX (4字节):  R G B X       —— 多 1 个没用的 X(占位)
RGBA (4字节):  R G B A       —— 多 1 个 A(透明度, 255=不透明)

CPU/GPU 读内存喜欢 4 字节对齐,3 字节别扭还不能按整数一口气算;4 字节一个像素正好一个整数,SIMD/GPU 直接吃。多花 33% 内存换速度。

③ 空间换不换:RGB 还是 YUV?

人眼: 对明暗(亮度)敏感, 对颜色(色度)不敏感
YUV = 把颜色拆成 亮度 Y + 颜色 U/V
相机做法: 亮度全保留, 颜色偷工减料
  YUV444: 颜色一点不少   = 3 字节/像素
  YUYV  : 颜色横着减半   = 2 字节/像素 (省 33%)
  NV12  : 颜色横竖都减半 = 1.5 字节/像素 (省 50%)

1280x720 一张图,NV12 比 RGBA 省一半内存,人眼几乎看不出区别。JPEG 压缩也利用这一点(默认 4:2:0)。

3. 20 种格式总表

格式字节/像素内存排布是谁在用
RGB3R G Blibjpeg 解码默认输出,通用
BGR3B G RWindows BMP / 老显示控制器 DMA
RGBX4R G B XD3D/GPU 纹理,X 未定义
BGRX4B G R XBMP 风格 4 字节对齐
XBGR4X B G R小端下当 uint32 = 0x00RRGGBB,位运算友好
XRGB4X R G BOpenGL XRGB8888 约定
RGBA4R G B AGPU/合成管线,需要透明度
BGRA4B G R AWindows 32bpp 屏幕 / COCOA
ABGR4A B G ROpenGL GL_ABGR_EXT
ARGB4A R G BQt ARGB32(数值 0xAARRGGBB 存小端内存)
GRAY1Y灰度,最省内存
CMYK4C M Y K印刷分色(转 RGB 仅近似)
YCBCR4443Y Cb CrJPEG 内部色彩空间
YUYV2Y0 U Y1 VUVC/USB 摄像头经典输出
UYVY2U Y0 V Y1视频采集卡常见
NV121.5Y 平面 + UV 半平面硬件编码器 / RV1106 MPP
NV211.5Y 平面 + VU 半平面Android 相机默认
I4201.5Y | U | V 三平面FFmpeg / 软件编码通用
I4443Y | U | V 三平面专业视频中间格式

实测(1920x1080):GRAY 2.0MB < NV12/I420 3.0MB < YUYV 4.0MB < RGB 5.9MB < 4 字节格式 7.9MB

4. YUV 家族详解

采样档位(人眼对色彩不敏感,逐级偷色度):

4:4:4  每像素 3 字节, 色度全保留 (I444 / YCBCR444)
4:2:2  每像素 2 字节, 色度水平减半 (YUYV / UYVY)
4:2:0  每像素 1.5 字节, 色度横竖都减半 (NV12 / NV21 / I420)

色度摆放:同样是偷色度,摆法不同 → 不同格式。

交织(混装):  YUYV [Y0 U Y1 V] — USB 摄像头芯片直接吐出, 拿来就能用
半平面:      NV12 [Y整块][UV交替] — 硬件编码器最小 DMA 单元
全平面:      I420 [Y整块][U整块][V整块] — 软件编码器缓存友好

原则上:传感器给你什么就用什么,少转一次就快一次。

5. 为什么搞这么多格式?(3 个推动力)

  1. 历史惯性:Windows/BMP/OpenGL/Qt 各定各的顺序,互不相让(BGR/ARGB 家族)。
  2. 硬件胃口:有的硬件要 4 字节对齐才好 DMA(X/RGBA 家族);有的硬件按 YUV 半平面搬运(NV12)。格式跟着芯片走。
  3. 人眼物理:颜色可以偷懒少存,偷多少、怎么偷 → 4:2:2 / 4:2:0 各档位。

6. 选型一句话

省内存 → YUV (1.5~2B),要速度 → 4 字节 RGBX/RGBA,
要通用 → RGB,显示硬件要 BGR → 给它 BGR

二、视频本质

1. 视频 = 按固定频率连续播放图片

1 秒视频 = 30 张图(帧率 30fps)。1080p RGB 一张 6MB,30 张 = 180MB/秒,根本存不下。所以要"视频编码"。

2. 视频编码 = 两种偷懒的组合

懒法一·帧内压缩:  单张图自己压 (JPEG 那套)  → 一张图砍到 1/20
懒法二·帧间压缩:  相邻帧几乎一样, 只存"变化" → 再省 10~30 倍
门派做法代表压缩率
只用懒法一每帧压成独立图片MJPEG约 1/20
两个都用关键帧完整 + 后续帧只存变化H.264 / H.265约 1/200

3. 为什么 MJPEG 既是图片又是视频?

因为它的每一帧就真的是一个标准 JPEG 图片:

MJPEG 视频流 = [JPEG图片][JPEG图片][JPEG图片]... 按帧率播放
  • 当图片看:随便抽一帧都是完整 JPEG,能单独打开、精确跳转、单独裁剪。哪帧坏了解哪帧,互不影响。
  • 当视频看:装在时间轴容器里(如 AVI),按帧率播放就是视频。

所以 MJPEG 站在中间:内容是图片编码,载体是视频容器。

代价:文件是 H.264 的 5~10 倍。

用在哪:安防录像(容错第一)、医疗/工业图像(逐帧精确)、行车记录仪、RV1106 这种硬件 JPEG 编码器现成的板卡。

4. 和我们学的 libjpeg 的关系

MJPEG 编码 = 循环调用 jpeg_en   (一帧一个 JPEG)
MJPEG 解码 = 循环调用 jpeg_de   (逐帧解压播放)

libjpeg 就是 MJPEG 的全部内核 —— 这也是"JPEG 是图片/视频双栖格式"的根本原因:编解码器是同一个。


三、配套代码(/root/c-std/libjpeg/)

文件内容
pixfmt.h / pixfmt.c20 种像素格式转换工具(信息表、逐像素函数、整图转换)
demo/pixfmt_demo.c格式信息/字节形态/内存占用/往返误差演示
demo/jpeg_en.c / jpeg_de.clibjpeg 编码/解码(MJPEG 的帧处理核心)
demo/turbo_demo.cTurboJPEG 一行 API + 缩放解码

下一步建议:mjpeg_demo.c —— 连续生成 30 帧动态画面,逐帧用 jpeg_en 的流程编码,按 AVI/MJPG 容器头存成视频文件,直观看到"视频 = 一串 JPEG"。

只做一件事:在"原始像素缓冲"和"JPEG 位流"之间互相转换,过程中可以顺带做颜色空间变换和缩放。它不读写任何其他图片格式。

解码: JPEG文件/内存 --jpeg_read_scanlines--> 像素缓冲(RGB/灰度/YCbCr...) 编码: 像素缓冲(任意内存) --jpeg_write_scanlines--> JPEG文件/内存

jpeglib

libturbojpeg

libjpeg(IJG 参考实现)libjpeg‑turbo
出身JPEG 标准小组的参考代码,1986 年起IJG libjpeg 的兼容加速分支(2009+)
APIjpeglib.h 一套100% 兼容 jpeglib + 新增 turbojpeg.h
性能纯 C,单线程SIMD(SSE2/AVX2、ARM NEON),快 2~6 倍
构建make 传统构建cmake + nasm
现状慢速更新,学术 / 教学用事实标准:Android、各大发行版、嵌入式全用它

生成一张RGB图片在内存中。

/* 图像尺寸 */
#define WIDTH  640
#define HEIGHT 480
/* 生成一张测试图像的真实数据: 左上→右下渐变 + 中间一个红方块 */
static unsigned char pixel[HEIGHT][WIDTH][3];
static void gen_image(void)
{
    for (int y = 0; y < HEIGHT; y++) {
        for (int x = 0; x < WIDTH; x++) {
            pixel[y][x][0] = (unsigned char)(x * 255 / WIDTH);   /* R 随 x 变化 */
            pixel[y][x][1] = (unsigned char)(y * 255 / HEIGHT);  /* G 随 y 变化 */
            pixel[y][x][2] = (unsigned char)((x + y) / 2);        /* B 两者叠加 */
            /* 中间画一个红色方块 */
            if (x > 250 && x < 390 && y > 170 && y < 310) {
                pixel[y][x][0] = 255;
                pixel[y][x][1] = 0;
                pixel[y][x][2] = 0;
            }
        }
    }
}

编码配置

#include <jpeglib.h>
struct jpeg_compress_struct cinfo;
struct jpeg_error_mgr jerr;
/* 1. 错误处理挂钩(必须设置, 否则遇到错误会直接崩溃) */
cinfo.err = jpeg_std_error(&jerr);
//初始化
jpeg_create_compress(&cinfo);
/* 2. 输出目标 */
FILE *fp = fopen("test.jpg", "wb");
if (!fp) { perror("fopen"); return 1; }
jpeg_stdio_dest(&cinfo, fp);

/* 3. 图像元信息 */
cinfo.image_width       = WIDTH;//图片宽
cinfo.image_height      = HEIGHT;//图片高
cinfo.input_components  = 3;                 /* 每像素 3 个分量 RGB */
cinfo.in_color_space    = JCS_RGB;           /* 输入颜色空间 jpeg会将RGB转成*/

/* 4. 默认参数: 主要决定采样(4:2:0 / 4:4:4 等)和 Huffman 表 */
jpeg_set_defaults(&cinfo);
cinfo.comp_info[0].h_samp_factor = 1; // 
cinfo.comp_info[0].v_samp_factor = 1; // 得到 4:4:4
/* 5. 画质 = 85 */
jpeg_set_quality(&cinfo, 85, TRUE);
/* 6. 开始压缩 */
jpeg_start_compress(&cinfo, TRUE);

/* 7. 逐行写入扫描线数据 */
unsigned char *row = (unsigned char *)malloc(WIDTH * 3);
while (cinfo.next_scanline < cinfo.image_height) {
    memcpy(row, pixel[cinfo.next_scanline], WIDTH * 3);
    jpeg_write_scanlines(&cinfo, &row, 1);
}
/* 8. 结束压缩 + 释放对象 */
jpeg_finish_compress(&cinfo);
jpeg_destroy_compress(&cinfo);

fclose(fp);
free(row);

流程一般只需要改

  • 图像元信息
  • 默认参数444 或者420
  • 画质
struct jpeg_decompress_struct cinfo;
struct jpeg_error_mgr jerr;

/* 1. 错误处理挂钩 */
cinfo.err = jpeg_std_error(&jerr);
jpeg_create_decompress(&cinfo);

/* 2. 输入来源 = 文件 */
FILE *fp = fopen(argv[1], "rb");
if (!fp) { perror("fopen"); return 1; }
jpeg_stdio_src(&cinfo, fp);

/* 3. 读取文件头. 返回 JPEG_SUSPENDED 表示数据不完整 */
if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK)
	printf("error\n");
printf("尺寸: %dx%d\n", cinfo.image_width, cinfo.image_height);

/* 4. 指定解码输出格式: 统一转成 RGB (也可 JCS_GRAYSCALE 出灰度) */
cinfo.out_color_space = JCS_RGB;

/* 5. 开始解码 */
if (!jpeg_start_decompress(&cinfo))
	printf("error\n");
/* 6. 逐行读出像素 */
unsigned int rowbytes = cinfo.output_width * cinfo.output_components; //每行多少字节
unsigned char *buf = (unsigned char *)malloc(rowbytes * cinfo.output_height);//分配缓冲区
unsigned char *row = buf; //指向缓冲区的第0下标
while (cinfo.output_scanline < cinfo.output_height) {
    jpeg_read_scanlines(&cinfo, &row, 1);//为缓冲区中写入一行数据
    row += rowbytes;//行数累加
}
/* 7. 结束解码 + 释放 */
jpeg_finish_decompress(&cinfo);
jpeg_destroy_decompress(&cinfo);
fclose(fp);

最终拿到 buf 以及尺寸,image_width,image_height

/*
 * fb_demo.c — 极简版: JPEG 解码 → 直接写到 /dev/fb0 (左上角)
 *
 * 只做 3 件事:
 *   1. jpeg 解码出 RGB 像素
 *   2. mmap 映射显存
 *   3. RGB24 → XRGB8888 逐像素写入 (图片放屏幕左上角)
 *
 * 简化假设 (本机 /dev/fb0 实测 = 1280x800 32bpp XRGB8888):
 *   - 屏幕 32bpp: 每像素 4 字节, 内存序 [B][G][R][X]
 *   - 每行 1280*4 = 5120 字节
 *   换板子时改 SCREEN_W 和 bpp 相关代码即可。
 *
 * 编译: make fb_demo   用法: ./fb_demo <输入.jpg>
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <jpeglib.h>

#define SCREEN_W 1280
#define SCREEN_H 800

/* ---------- ① JPEG 解码 (和 jpeg_de.c 一样) ---------- */
static unsigned char *decode_jpeg(const char *path, int *out_w, int *out_h)
{
    FILE *fp = fopen(path, "rb");
    if (!fp) { perror("fopen"); return NULL; }

    struct jpeg_decompress_struct cinfo;
    struct jpeg_error_mgr jerr;
    cinfo.err = jpeg_std_error(&jerr);
    jpeg_create_decompress(&cinfo);
    jpeg_stdio_src(&cinfo, fp);
    if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) return NULL;
    cinfo.out_color_space = JCS_RGB;
    jpeg_start_decompress(&cinfo);

    unsigned int rowbytes = cinfo.output_width * cinfo.output_components;
    unsigned char *buf = malloc(rowbytes * cinfo.output_height);
    unsigned char *row = buf;
    while (cinfo.output_scanline < cinfo.output_height) {
        jpeg_read_scanlines(&cinfo, &row, 1);
        row += rowbytes;
    }
    jpeg_finish_decompress(&cinfo);
    jpeg_destroy_decompress(&cinfo);
    fclose(fp);

    *out_w = cinfo.output_width;
    *out_h = cinfo.output_height;
    return buf;
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s <输入.jpg>\n", argv[0]);
        return 1;
    }

    /* ① 解码 -> RGB 缓冲 */
    int w, h;
    unsigned char *rgb = decode_jpeg(argv[1], &w, &h);
    if (!rgb) { fprintf(stderr, "解码失败\n"); return 1; }
    printf("解码: %dx%d RGB\n", w, h);

    /* ② 打开屏幕设备 + 映射显存 */
    int fbfd = open("/dev/fb0", O_RDWR);
    if (fbfd < 0) { perror("open /dev/fb0"); return 1; }
    size_t fb_size = (size_t)SCREEN_W * SCREEN_H * 4;
    unsigned char *fb = mmap(NULL, fb_size, PROT_READ | PROT_WRITE,
                             MAP_SHARED, fbfd, 0);
    if (fb == MAP_FAILED) { perror("mmap"); return 1; }

    memset(fb, 0, fb_size);          /* 整屏刷黑 */

    /* ③ 写入: 图片从 (0,0) 开始, 每行 src 是 3 字节 RGB,
     *   每行 dst 是 4 字节 BGRX (XRGB8888 小端内存序) */
    for (int y = 0; y < h && y < SCREEN_H; y++) {
        unsigned char *src = rgb + (size_t)y * w * 3;
        unsigned char *dst = fb + (size_t)y * SCREEN_W * 4;
        for (int x = 0; x < w && x < SCREEN_W; x++) {
            dst[x*4 + 0] = src[x*3 + 2];   /* B */
            dst[x*4 + 1] = src[x*3 + 1];   /* G */
            dst[x*4 + 2] = src[x*3 + 0];   /* R */
            dst[x*4 + 3] = 0xFF;           /* X = 不透明 */
        }
    }

    printf("已写入 /dev/fb0 左上角 (%dx%d), 屏幕 %dx%d 32bpp\n", w, h, SCREEN_W, SCREEN_H);

    munmap(fb, fb_size);
    close(fbfd);
    free(rgb);
    return 0;
}

JPEG Marker:通常有 11 个 Marker,其中 9 个是带长度的 Segment

序号字节Marker段结构含义
1FF D8SOI无长度JPEG 文件开始(第一个字节就是它)
2FF E0APP0有长度JFIF 头(产业约定,通常第一个 APP)
3FF E1APP1有长度EXIF 约定 / XMP
4FF E2APP2有长度ICC 色彩配置约定
5FF E3~EFAPP3~APP15有长度无强约定,私有数据安全区
6FF DBDQT有长度量化表(常见 2 张:亮度表 + 色度表)
7FF C0SOF0有长度基线帧:宽/高/颜色分量/采样因子
8FF C1 / FF C2SOF1 / SOF2有长度扩展帧 / 渐进帧(取代 SOF0 出现)
9FF C4DHT有长度Huffman 表(常见 4 张:亮度/色度 × DC/AC)
10FF DDDRI有长度重启间隔(写 RST 标记时用)
11FF FECOM有长度注释(内容自由,也在这片段序列区)
12FF DASOS有长度扫描开始:选择分量、指定 Huffman 表
——熵编码数据无段结构真正的压缩码流(DCT 系数+Huffman 位流)
13FF D0~D7RSTn无长度重启标记(可选,DRI>0 时出现)
14FF D9EOI无长度JPEG 文件结束 ← 标准结构到此为止
——尾部附加非标准EOI 之后:我们自己规定的裸数据
※FF 01TEM无长度临时保留标记——标准里占个号,实际没有任何文件会写它

两种"附加自己的数据"对比

APP 段(规范内)EOI 后追加(规范外)
塞的时机编码时 jpeg_write_marker() 插入段序列编码后 fopen("ab") 追加文件尾
在文件中的位置文件前部,混在 marker 段里EOI 之后,文件最末尾
数据形态标准段结构:FF XX LL LL 数据裸字节,无结构无长度
解码器行为必须跳过,永不报错读到 EOI 就停,多数忽略;严格实现可能告警
读回方式jpeg_save_markers() 从 marker_list 取自己扫文件找最后 FF D9,挖尾部
大小限制段长度字段 2 字节,单段 ≤ 65533 字节无限制
适合元数据、给别人读的标准数据自用大块数据、图片+数据打包

写入APP区数据

读出APP区数据

#include <jpeglib.h>
struct jpeg_compress_struct cinfo;
struct jpeg_error_mgr jerr;
/**
*	省略前面步骤
*/

/* 8. 结束压缩 + 释放对象 */
jpeg_finish_compress(&cinfo); /* 到这里 EOI(FF D9) 已写入文件尾 */
jpeg_destroy_compress(&cinfo);

const char *tail = "112323";
fwrite(tail, 1, strlen(tail), fp);//直接写数据 strlen()依赖<string.h>头文件
fclose(fp);
free(row);

/* 1. 整个文件读进内存 */
FILE *fp = fopen("path.jpg", "rb");
if (!fp) { perror("fopen"); return 1; }
//fseek + ftell 对大于 2GB 的文件(在 32 位系统上 long 通常为 4 字节)会溢出,返回负值或截断
fseek(fp, 0, SEEK_END); //文件指针移动到末尾
long n = ftell(fp);//文件长度
fseek(fp, 0, SEEK_SET);//文件指针移动到开头
unsigned char *buf = malloc(n);
fread(buf, 1, n, fp);//全部读到内存
fclose(fp);

/* 2. 从尾向前找最后一个 FF D9 = EOI */
long eoi = -1;
for (long i = n - 1; i > 0; i--)
    if (buf[i] == 0xD9 && buf[i - 1] == 0xFF) { eoi = i - 1; break; }
if (eoi < 0) { printf("没找到 EOI, 不是合法 JPEG\n"); return 1; }

/* 3. 打印 EOI 之后的字节 */
long tail_len = n - (eoi + 2);
printf("%s: %ld 字节, EOI@%ld, 尾部 %ld 字节\n", path, n, eoi, tail_len);
for (long i = 0; i < tail_len; i++) printf("%02X ", buf[eoi + 2 + i]);
printf("\n文本内容: %.*s\n", (int)tail_len, buf + eoi + 2);

这个库不支持EOI后面写数据

它是libjpeg的再度封装,简化使用。

#include <turbojpeg.h>
static unsigned char pixel[HEIGHT][WIDTH][3];
/* ---------- 编码: 一行搞定 ----------
     * tjCompress2(句柄, 像素, 宽, 行间距pitch=0, 高, 像素格式TJPF_RGB,
     *             输出缓冲指针, 输出长度, 采样TJSAMP_420, 质量, 标志0)
     * 内部自动: RGB→YCbCr + 4:2:0 下采样 + 量化 + Huffman, 全部打包 */
unsigned char *jout = NULL;
unsigned long jout_size = 0;
tjhandle tj = tjInitCompress();
if (tjCompress2(tj, (unsigned char *)pixel, 640, 0, 480, TJPF_RGB,
                &jout, &jout_size, TJSAMP_420, 85, 0) < 0) {
    fprintf(stderr, "编码失败: %s\n", tjGetErrorStr());
    return 1;
}

FILE *fp = fopen("turbo_out.jpg", "wb");
fwrite(jout, 1, jout_size, fp);
fclose(fp);
tjFree(jout);          /* 输出缓冲由 tjCompress2 分配, 用 tjFree 释放 */
tjDestroy(tj);

读文件

FILE *fp = fopen(path, "rb");
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fclose(fp);
//分配内存
unsigned char *jbuf = malloc(size); //可以用mmap
//读文件内容到内存
fread(jbuf, 1, size, fp);
fclose(fp);

解码

tjhandle tj = tjInitDecompress();

int w = 0, h = 0, subsamp = 0, colorspace = 0;//拿到宽/高/采样(420/442/444)/色彩空间
tjDecompressHeader3(tj, jbuf, size, &w, &h, &subsamp, &colorspace);

/* ③ 一块输出缓冲 */
unsigned char *rgb = malloc((size_t)w * h * 4);/* 4字节/像素 */
/* ④ 全尺寸解码 */
tjDecompress2(tj, jbuf, size, rgb, w, 0, h, TJPF_RGBX, 0);//TJPF_RGBX会将YUV转成RGBX
/* ⑤ 缩放 */
tjDecompress2(tj, jbuf, size, rgb, w/2, 0, h/2, TJPF_RGBX, 0);//TJPF_RGB会将YUV转成RGB

tjDestroy(tj);
close(fd);
free(rgb);

**EXIF 缩略图:**内嵌在Maker的app区的一个小jpeg图片。历史包袱

**DCT 缩放:**将一整张图片缩小,当前缩略图,更好。

libjpeg支持turbojpeg不支持


示例


#define CELL_W   160
#define CELL_H   120
tjhandle tj = tjInitDecompress();
unsigned char *cell = malloc(CELL_W*CELL_H*4); //缩放大小

/* 读原图尺寸 */
unsigned char *file = 文件字节数指针
unsigned long jpegSize = strlen(file);//文件长度
int w, h, subsamp, cs;
tjDecompressHeader3(tj, file, jpegSize, &w, &h, &subsamp, &cs);

/* DCT 1/8 缩放: 指定目标尺寸, TurboJPEG 自动选最佳缩放因子 */
tjDecompress2(tj, file, jpegSize, cell,
              CELL_W, 0, CELL_H, TJPF_BGRX, 0);

Linux IO

读取一个文件,并开启非阻塞式

  • O_RDONLY:只读

  • O_NONBLOCK:非阻塞

代码编写

int fd;
int init_read()
{
    int fd = open("/dev/input/event1", O_RDONLY | O_NONBLOCK);
    if(fd == -1)
        return -1;
    return 0;
}
int btns_read(int fd, btn_code_t *code, btn_event_t *event)
{
    struct input_event ev; // 输入事件

    ssize_t n = read(fd, &ev, sizeof(ev));
    if (n < 0) { //1.是否有错
        if (errno == EAGAIN || errno == EWOULDBLOCK) 
            return 0;
        return -1;
    }
    if ((size_t)n < sizeof(ev)) //2.返回的数据是不是input_event
        return -1;
    if (ev.type != EV_KEY) //3.如果是按钮才执行
        continue;
    //4.参数传递回去
    *code  = ev.code;
    *event = ev.value ? BTN_PRESSED : BTN_RELEASED;

    printf("btns: fd=%d ev.code=%u ev.value=%d -> btn=%d\n",
           fd, (unsigned)ev.code, ev.value, (int)c);
    fflush(stdout); // 5.fflush 保证日志不卡在缓冲区里
    return 1;
}

解析

  • 开启一个文件设置为非阻塞模式。

Linux驱动(模块)

001. 字符设备驱动

一、环境

apt update
apt install linux-headers-$(uname -r)

二、编写文件

  • 001_helloworld.c
#include <linux/init.h>
#include <linux/module.h>

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("Hello World");

// 模块加载时调用的函数
static int hello_init(void)
{
    printk("Hello, World! Driver loaded.\n");
    return 0; // 返回 0 表示加载成功
}

// 模块卸载时调用的函数
static void hello_exit(void)
{
    printk("Goodbye, World! Driver unloaded.\n");
}

// 注册模块的入口和出口函数
module_init(hello_init);
module_exit(hello_exit);
  • Makefile
# 如果未定义 KERNELDIR,则使用当前运行的内核版本路径
KERNELDIR ?= /lib/modules/$(shell uname -r)/build

# 当前目录
PWD := $(shell pwd)

# 模块名称,对应 .c 文件名去掉 .c 后缀
obj-m := 001_helloworld.o

# 默认目标:编译模块
all:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules #-C : Change Directory(切换目录) -M : Module Directory(模块目录) modules : Make Target(构建目标)

# 清理目标
clean:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) clean

# 安装模块 (可选)
install:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install

# 卸载模块 (可选)
uninstall:
	rm -f /lib/modules/$(shell uname -r)/extra/001_helloworld.ko
	depmod -a
  • 编译使用
make #必须使用root用户
insmod 001_helloworld.ko #加载驱动,必须使用root用户
dmesg | tail #查看驱动加载情况
rmmod 001_helloworld  #卸载驱动,必须使用root用户
dmesg | tail #查看驱动卸载情况
  • 查看挂载情况

    lsmod
    
    cat /proc/devices
    

动态申请设备号

只学习怎么申请设备号。暂时没什么用。

  • 挂载设备
insmod 002_alloc_chrdev.ko
  • 查看
root@docker:~/driver_std/002_alloc_char_dev# lsmod
Module                  Size  Used by
004_alloc_chrdev       16384  0
root@docker:~/driver_std/002_alloc_char_dev# cat /proc/devices
Character devices:
236 my_chrdev #(设备名称)

代码:002_alloc_chrdev.c

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/kdev_t.h>
#include <linux/moduleparam.h>

//主设备号
static int major = 0;
module_param(major, int, S_IRUGO);
//次设备号
static int minor = 0;
module_param(minor, int, S_IRUGO);
//设备名称
static char* name = "my_chrdev";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("alloc_chrdev");

static dev_t dev_no; //设备号:32位unsigned int,主设备号(前12位)+次设备号(后20位)

static int moduleparam_init(void)
{
    int ret;

    // 分配设备号:如果指定了major则使用register_chrdev_region,否则自动分配
    if (major) {
        dev_no = MKDEV(major, minor);
        ret = register_chrdev_region(dev_no, 1, name);
        printk(KERN_INFO "Using specified major=%d, minor=%d\n", major, minor);
    } else {
        ret = alloc_chrdev_region(&dev_no, 0, 1, name);
        major = MAJOR(dev_no); //获取主设备号
        minor = MINOR(dev_no); //获取次设备号
        printk(KERN_INFO "Auto allocated major=%d, minor=%d\n", major, minor);
    }

    if (ret < 0) {
        printk(KERN_ALERT "Failed to allocate device number: %d\n", ret);
        return ret;
    }

    printk(KERN_INFO "Device number allocation success\n");
    return 0;
}

static void moduleparam_exit(void)
{
    unregister_chrdev_region(dev_no, 1);
    printk(KERN_INFO "unregister_chrdev_region success\n");
}

module_init(moduleparam_init);
module_exit(moduleparam_exit);

创建的是一个真正可被系统识别的字符设备。

在 002 的基础上增加了:

新增内容作用
struct cdev cdev_test内核中代表一个字符设备
struct file_operations fops定义设备的操作接口(open/read/write 等)
cdev_init()把 cdev 和 file_operations 绑定
cdev_add()向内核注册,设备变为"活"状态
cdev_del()卸载时移除 cdev

代码:003_register_cdev.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>

dev_t dev_no; // 设备号
struct cdev cdev_test; // 字符设备结构体

struct file_operations fops = {
    .owner = THIS_MODULE
};

//设备名称
static char* name = "003_register_cdev";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("003_register_cdev");
static int modulecdev_init(void)
{ 
    int ret;
    ret = alloc_chrdev_region(&dev_no, 0, 1, name);
    if (ret<0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("major:%d, minor:%d\n", MAJOR(dev_no), MINOR(dev_no));
    cdev_init(&cdev_test, &fops);
    cdev_add(&cdev_test, dev_no, 1);
    return 0;
}
static void modulecdev_exit(void)
{
    cdev_del(&cdev_test);
    unregister_chrdev_region(dev_no, 1);
    printk("modulecdev exit\n");
}

module_init(modulecdev_init);
module_exit(modulecdev_exit);

挂载

  • 挂载设备
insmod 004_file_operations.ko
  • 查看
lsmod
#可以看到
Module                  Size  Used by
004_file_operations       16384  0
cat /proc/devices
#可以看到
Character devices:
236 004_file_operations #(设备名称)
  • 创建设备节点(最好和代码中的一致)

    sudo mknod /dev/<设备名> c <主设备号> <次设备号>

    mknod /dev/004_register_cdev c 236 0
    
    chmod 666 /dev/004_register_cdev
    
    ls /dev/004*
    

卸载

  • 移除设备节点

    rm /dev/004_register_cdev
    
  • 取消挂载

    rmmod 004_file_operations.ko
    

代码:004_file_operations.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>

dev_t dev_no; // 设备号
struct cdev cdev_test; // 字符设备结构体

static int test_open(struct inode *inode,struct file *file)
{
    printk("open\n");
    return 0;
}
static ssize_t test_read(struct file *file,char __user *buf, size_t size,loff_t *off){
    printk("read\n");
    return 0;
}
static ssize_t test_write(struct file *file,const char __user *buf, size_t size,loff_t *off){
    printk("write\n");
    return 0;
}
static int test_release(struct inode *inode,struct file *file)
{
    printk("release\n");
    return 0;
}
struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = test_open,
    .read = test_read,
    .write = test_write,
    .release = test_release,
};

//设备名称
static char* name = "004_register_cdev";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("004_register_cdev");
static int modulecdev_init(void)
{ 
    int ret;
    ret = alloc_chrdev_region(&dev_no, 0, 1, name);
    if (ret<0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("major:%d, minor:%d\n", MAJOR(dev_no), MINOR(dev_no));
    cdev_init(&cdev_test, &fops);
    cdev_add(&cdev_test, dev_no, 1);
    return 0;
}
static void modulecdev_exit(void)
{
    cdev_del(&cdev_test);
    unregister_chrdev_region(dev_no, 1);
    printk("modulecdev exit\n");
}

module_init(modulecdev_init);
module_exit(modulecdev_exit);

自动创建设备节点,不需要使用mknod去创建/dev下的设备节点

流程

  • 编译和挂载

    make
    
    insmod 005_udev_mdev.ko
    
  • 查看

    ls /sys/class/005_udev_mdev #struct class *class_test 的 class_create(THIS_MODULE, name)中的name对应
    
    lsmod #命令
    Module                  Size  Used by
    005_udev_mdev          16384  0
    
    cat /proc/devices # 命令
    Character devices:
    	237 005_udev_mdev
    
    ls /dev/005_udev_mdev #device_create(class_test, NULL, dev_no, NULL, name)中的name对应
    
  • 卸载

    会卸载**/proc/devices**,/dev,/sys/class下有的东西

    rmmod 005_udev_mdev.ko
    

代码:005_udev_mdev.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>

dev_t dev_no; // 设备号
struct cdev cdev_test; // 字符设备结构体

struct class *class_test; // 类
struct device *device_test; // 设备

static int test_open(struct inode *inode,struct file *file)
{
    printk("open\n");
    return 0;
}
static ssize_t test_read(struct file *file,char __user *buf, size_t size,loff_t *off){
    printk("read\n");
    return 0;
}
static ssize_t test_write(struct file *file,const char __user *buf, size_t size,loff_t *off){
    printk("write\n");
    *off += size;
    return size;
}
static int test_release(struct inode *inode,struct file *file)
{
    printk("release\n");
    return 0;
}
struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = test_open,
    .read = test_read,
    .write = test_write,
    .release = test_release,
};

//设备名称
static char* name = "005_udev_mdev";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("005_udev_mdev");
static int modulecdev_init(void)
{ 
    int ret;
    ret = alloc_chrdev_region(&dev_no, 0, 1, name);
    if (ret<0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("major:%d, minor:%d\n", MAJOR(dev_no), MINOR(dev_no));
    cdev_init(&cdev_test, &fops);
    cdev_add(&cdev_test, dev_no, 1);

    class_test = class_create(THIS_MODULE, name); //创建类
    device_test = device_create(class_test, NULL, dev_no, NULL, name);
    return 0;
}
static void modulecdev_exit(void)
{
    // 1. 先销毁设备节点
    device_destroy(class_test, dev_no);
    
    // 2. 销毁类 (原代码缺失此步,必须添加)
    class_destroy(class_test);
    
    // 3. 删除字符设备
    cdev_del(&cdev_test);
    
    // 4. 最后注销设备号
    unregister_chrdev_region(dev_no, 1);
    
    printk("modulecdev exit\n");
}

module_init(modulecdev_init);
module_exit(modulecdev_exit);

代码:app.c

#include <stdio.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
  int fd = open("/dev/005_udev_mdev", O_RDWR);
  if (fd < 0) {
    printf("open error\n");
    return -1;
  }
  printf("open success\n");
  close(fd);
  return 0;
}
insmod → init() →
  ├─ alloc_chrdev_region() → /proc/devices 多一行  [黄页:告诉你 236 是谁]
  ├─ cdev_add()            → 内核 cdev 链表注册    [内核内部:使设备号可路由到 fops]
  ├─ class_create()        → /sys/class/ 下建目录  [公告牌:告知用户空间]
  │
  └─ device_create()
       ├─ /sys/class/xxx/dev 出现 "236:0"
       └─ 发 uevent ──→ udev/mdev
                            ↓
                      mknod /dev/005_udev_mdev  [门:用户程序访问入口]

编写的驱动用户空间和内核空间数据交互

代码:006_kernel_user_data.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>

struct device_data{
    dev_t dev_no; // 设备号

    struct cdev cdev_test; // 字符设备结构体
    struct class *class_test; // 类
    struct device *device_test; // 设备
    char kbuf[32];
};
struct device_data dev_data;
static int test_open(struct inode *inode,struct file *file)
{
    // 打开驱动时,配置私有数据
    file->private_data = &dev_data;
    printk("open\n");
    return 0;
}
static ssize_t test_read(struct file *file,char __user *buf, size_t size,loff_t *off){
    //拿到私有数据
    struct device_data *priv_data =  (struct device_data *)file->private_data;

    if(copy_to_user(buf,priv_data->kbuf , strlen(priv_data->kbuf))!=0){
        printk("copy_to_user failed\n");
        return -1;
    }
    printk("read\n");
    return 0;
}
static ssize_t test_write(struct file *file,const char __user *buf, size_t size,loff_t *off){
    // 拿到私有数据
    struct device_data *priv_data =  (struct device_data *)file->private_data;
    if(size > sizeof(priv_data->kbuf)){
        printk("data too large\n");
        return -ENOSPC;
    }
    if(copy_from_user(priv_data->kbuf, buf, size)!=0){
        printk("copy_from_user failed\n");
        return -EFAULT;
    }
    priv_data->kbuf[size - 1] = '\0'; // 确保字符串终止
    *off += size;
    printk("write\n");
    return size;
}
static int test_release(struct inode *inode,struct file *file)
{
    printk("release\n");
    return 0;
}
struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = test_open,
    .read = test_read,
    .write = test_write,
    .release = test_release,
};

//设备名称
static char* name = "006_kernel_user_data";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("006_kernel_user_data");
static int modulecdev_init(void)
{ 
    int ret;
    ret = alloc_chrdev_region(&dev_data.dev_no, 0, 1, name);
    if (ret<0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("major:%d, minor:%d\n", MAJOR(dev_data.dev_no), MINOR(dev_data.dev_no));
    cdev_init(&dev_data.cdev_test, &fops);
    cdev_add(&dev_data.cdev_test, dev_data.dev_no, 1);

    dev_data.class_test = class_create(THIS_MODULE, name); //创建类
    dev_data.device_test = device_create(dev_data.class_test, NULL, dev_data.dev_no, NULL, name);
    return 0;
}
static void modulecdev_exit(void)
{
    // 1. 先销毁设备节点
    device_destroy(dev_data.class_test, dev_data.dev_no);
    
    // 2. 销毁类 (原代码缺失此步,必须添加)
    class_destroy(dev_data.class_test);
    
    // 3. 删除字符设备
    cdev_del(&dev_data.cdev_test);
    
    // 4. 最后注销设备号
    unregister_chrdev_region(dev_data.dev_no, 1);
    
    printk("modulecdev exit\n");
}

module_init(modulecdev_init);
module_exit(modulecdev_exit);

代码:app.c

#include <stdio.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
    int fd = open("/dev/006_kernel_user_data", O_RDWR);
    char rbuf[64];
    char wbuf[64];

    if (fd < 0)
    {
        printf("open error\n");
        return -1;
    }
    printf("open success\n");

    // 用户 -> 内核:写入数据到驱动
    strcpy(wbuf, "hello from user space!");
    write(fd, wbuf, strlen(wbuf) + 1);
    printf("write to kernel: %s\n", wbuf);

    // 内核 -> 用户:从驱动读取数据
    memset(rbuf, 0, sizeof(rbuf));
    read(fd, rbuf, sizeof(rbuf));
    printf("read from kernel: %s\n", rbuf);

    close(fd);
    return 0;
}

杂项设备(misc device)在Linux内核驱动开发中有以下典型应用场景:

1. 简单字符设备的快速注册

  • 适用于功能相对简单、不需要复杂设备管理的字符设备
  • 自动分配主设备号(固定为10),只需关心次设备号
  • 简化了传统字符设备的注册流程(无需手动申请设备号、创建类和设备节点)

2. 系统级小工具驱动

  • 看门狗定时器(watchdog)
  • 温度传感器读取
  • LED控制
  • 蜂鸣器控制
  • 按键输入处理

3. 硬件抽象层

  • 为上层应用提供简单的硬件访问接口
  • 例如:GPIO控制、I2C/SPI设备访问、ADC读取等

4. 调试和测试设备

  • 内核模块开发时的测试驱动
  • 提供简单的读写接口用于验证内核与用户空间通信

5. 特殊功能设备

  • 随机数生成器(/dev/random, /dev/urandom)
  • 内存映射设备(/dev/mem, /dev/kmem)
  • 日志设备(/dev/kmsg)

杂项设备的优势:

✅ 简化注册流程:只需调用 misc_register(),自动完成设备号分配、设备节点创建 ✅ 统一管理:所有杂项设备共享主设备号10,便于系统管理 ✅ 自动创建设备节点:在 /dev/ 下自动生成设备文件 ✅ 适合小型驱动:代码量少,结构清晰

代码:007_misc.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/miscdevice.h>

static int test_open(struct inode *inode,struct file *file)
{
    printk("open\n");
    return 0;
}
static ssize_t test_read(struct file *file,char __user *buf, size_t size,loff_t *off){

    printk("read\n");
    return 0;
}
static ssize_t test_write(struct file *file,const char __user *buf, size_t size,loff_t *off){

    *off += size;
    printk("write\n");
    return size;
}
static int test_release(struct inode *inode,struct file *file)
{
    printk("release\n");
    return 0;
}
struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = test_open,
    .read = test_read,
    .write = test_write,
    .release = test_release,
};


MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("007_misc");

// 杂项设备
static struct miscdevice misc_dev = {
    .minor = MISC_DYNAMIC_MINOR, //自动分配从设备号
    .name = "007_misc", //设备名称
    .fops = &fops,
};
static int modulecdev_init(void)
{ 
    int ret;
    ret = misc_register(&misc_dev);
    if(ret<0){
        printk(KERN_ERR "misc_register failed!\n");
        return ret;
    }
    printk(KERN_INFO "misc_register success!\n");
    return 0;
}
static void modulecdev_exit(void)
{
    misc_deregister(&misc_dev); //卸载
    printk("modulecdev exit\n");
}

module_init(modulecdev_init);
module_exit(modulecdev_exit);

在编写驱动时,返回的错误最好使用以下错误码

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _ASM_GENERIC_ERRNO_BASE_H
#define _ASM_GENERIC_ERRNO_BASE_H

#define	EPERM		 1	/* Operation not permitted */
#define	ENOENT		 2	/* No such file or directory */
#define	ESRCH		 3	/* No such process */
#define	EINTR		 4	/* Interrupted system call */
#define	EIO		 5	/* I/O error */
#define	ENXIO		 6	/* No such device or address */
#define	E2BIG		 7	/* Argument list too long */
#define	ENOEXEC		 8	/* Exec format error */
#define	EBADF		 9	/* Bad file number */
#define	ECHILD		10	/* No child processes */
#define	EAGAIN		11	/* Try again */
#define	ENOMEM		12	/* Out of memory */
#define	EACCES		13	/* Permission denied */
#define	EFAULT		14	/* Bad address */
#define	ENOTBLK		15	/* Block device required */
#define	EBUSY		16	/* Device or resource busy */
#define	EEXIST		17	/* File exists */
#define	EXDEV		18	/* Cross-device link */
#define	ENODEV		19	/* No such device */
#define	ENOTDIR		20	/* Not a directory */
#define	EISDIR		21	/* Is a directory */
#define	EINVAL		22	/* Invalid argument */
#define	ENFILE		23	/* File table overflow */
#define	EMFILE		24	/* Too many open files */
#define	ENOTTY		25	/* Not a typewriter */
#define	ETXTBSY		26	/* Text file busy */
#define	EFBIG		27	/* File too large */
#define	ENOSPC		28	/* No space left on device */
#define	ESPIPE		29	/* Illegal seek */
#define	EROFS		30	/* Read-only file system */
#define	EMLINK		31	/* Too many links */
#define	EPIPE		32	/* Broken pipe */
#define	EDOM		33	/* Math argument out of domain of func */
#define	ERANGE		34	/* Math result not representable */

#endif

002. 设备树

001. GPIO驱动

设备树驱动支持的属性

文档路径路径:

/Documentation/devicetree/bindings

概念

应用层 (LVGL / Qt / shell)
    ↓
接口层  (sysfs / libgpiod / /dev/mem / 字符设备)
    ↓
内核层  (pinctrl / gpiolib / gpio-leds / gpio-keys ...)
    ↓
硬件层  (SoC GPIO 控制器寄存器)

常见 compatible 一览

compatible用途控制方式
gpio-keys按键输入/dev/input/eventX
gpio-ledsLED 亮灭/sys/class/leds/
gpio-beeper蜂鸣器/sys/class/input/inputX/
regulator-fixed电源开关regulator 框架
gpio-poweroff关机断电内核 poweroff 流程自动触发
gpio-restart重启内核 restart 流程自动触发
gpio-muxGPIO 选通mux 框架
gpio-wdt硬件看门狗watchdog 框架

驱动源码路径:

drivers/input/keyboard/gpio_keys.c

文档路径:

Documentation/devicetree/bindings/input/gpio-keys.yaml

设备树配置:

gpio-keys {
    compatible = "gpio-keys";         // 绑定内核 drivers/input/keyboard/gpio_keys.c
    pinctrl-names = "default";        // 使用 default 状态的 pinmux 配置
    pinctrl-0 = <&soc_gpio_bell>;     // 引用下面的 pinmux 定义

    bell-key {
        label = "SOC_GPIO_BELL";       // 名字,debug 时能看到
        linux,code = <KEY_POWER>;      // 按键码 116,上报给 input 子系统
        gpios = <&gpio0 RK_PA3 GPIO_ACTIVE_LOW>;  // GPIO0_A3,低电平触发
        debounce-interval = <10>;      // 10ms 消抖
        wakeup-source;                 // 休眠时这个键能唤醒 SoC
    };
};
&pinctrl {
    bell {                              // 分组名,方便管理
        soc_gpio_bell: soc-gpio-bell {  // 标签 + 节点名
            rockchip,pins = <0 RK_PA3 RK_FUNC_GPIO &pcfg_pull_up>; #对应引脚和模式,驱动方式
        };
    };
};

leds {
    compatible = "gpio-leds";

    led0: led-0 {
        gpios = <&gpio0 RK_PA0 GPIO_ACTIVE_HIGH>;
        linux,default-trigger = "heartbeat";  // 自动心跳闪烁
    };

    led1: led-1 {
        gpios = <&gpio0 RK_PA1 GPIO_ACTIVE_LOW>;
        default-state = "on";                 // 默认亮
    };
};

  • 如果使用pwm并且配置compatible = "pwm-backlight"(与pwm_bl 驱动进行匹配);会受到驱动影响,屏幕初始化失败有可能导致背光的电源强制关闭bl_power = 4

    DRM 面板驱动把背光禁用了

  • 在设备树的树根/下配置backlight节点

    // LCD Backlight
    backlight: backlight {
    	compatible = "pwm-backlight";
    	pwms = <&pwm3 0 50000 0>;
    	brightness-levels = <0 20 40 60 80 100 200 255>;
    	default-brightness-level = <7>;
    	status = "okay";
    };
    
  • 配置pwm3的复用(pinctrl子系统)

    &pwm3 {
    	status = "okay";
    	pinctrl-0 = <&pwm3m1_pins>;
    };
    
  • GPIO测试背光

    echo 40 > /sys/class/gpio/export
    echo out > /sys/class/gpio/gpio40/direction
    echo 1 > /sys/class/gpio/gpio40/value
    

002. 节点&属性

chosen

cpu

&spi1 {
	/* 不使用硬件 SPI 引脚功能,引脚全部当 GPIO 用做 bitbang */
	status = "okay";
	/delete-property/ pinctrl-0;
	/delete-property/ pinctrl-names;
}

删除属性,意思是删除节点下的某一个属性。

这里删除了spi1下的pinctrl引脚属性,直接就导致spi1在系统下用不了了。不允许用户通过**/dev/spi1操作spi1**。但是可以gpio去模拟。

003. 设备树底层

案例

  • 借鉴已经有的lcd驱动配置

    这里借鉴:rv1106-evb-ext-rgb-v10.dtsi

  • DTS 中相关的几个节点:

    通过VOP(video output processor)视频输出处理器,rk内部处理模块。功能如下:

    1. 图层合成 — 支持多个显示图层(video layer、UI layer 等),将它们叠加合成输出
    2. 格式转换 — 将帧缓冲区的数据转换成显示接口所需的格式
    3. 输出路由 — 把信号路由到不同的显示接口(RGB、MIPI DSI、LVDS、HDMI 等)
    节点作用
    &vopVOP 模块本身,必须 status = "okay"
    &rgbRGB 并行接口(VOP 的输出之一)
    &route_rgb路由选择,告诉 VOP 把信号送到 RGB 接口
    &rgb_in_vopVOP 到 RGB 的输入连接
    &rgb {
    	status = "okay";
    	/**省略*/
    };
    &rgb_in_vop {
    	status = "okay";
    };
    
    &route_rgb {
    	status = "okay";
    };
    
    &sdio {
    	status = "disabled"; //有些sdmmc复用会影响,禁用掉
    };
    
    &vop {
    	status = "okay";
    };
    
    
  • 设备树下的rbg相关配置

    • clock-frequency 像素时钟频率

    • hactive 分辨率 行

    • hback-porch

    • hfront-porch

    • hsync-len

    • hsync-active

    • vactive 分辨率 高

    • vback-porch

    • vfront-porch

    • vsync-len

    • vsync-active

    • de-active

    • pixelclk-active

    /* 显示时序配置 */
    display-timings {
        native-mode = <&timing0>;         /* 指定默认时序 */
        timing0: timing0 {
             clock-frequency = <31250000>; /* 像素时钟频率 31.25MHz */
             hactive = <240>;               /* 水平有效像素 */
             vactive = <320>;               /* 垂直有效像素 */
             hfront-porch = <360>;          /* 水平前沿 */
             hback-porch = <360>;           /* 水平后沿 */
             hsync-len = <80>;              /* 水平同步宽度 */
             vfront-porch = <20>;           /* 垂直前沿 */
             vback-porch = <20>;            /* 垂直后沿 */
             vsync-len = <8>;               /* 垂直同步宽度 */
             hsync-active = <0>;             /* HSYNC 低电平有效 */
             vsync-active = <0>;             /* VSYNC 低电平有效 */
             de-active = <1>;                /* DE 高电平有效 */
             pixelclk-active = <1>;          /* 像素时钟上升沿采样 */
        };
    };
    
  • 纯色测试

    • 白色
    dd if=/dev/zero bs=4 count=$((240*320)) 2>/dev/null | tr '\0' '\377' > /dev/fb0
    
    • 黑色
    dd if=/dev/zero bs=4 count=$((240*320)) 2>/dev/null > /dev/fb0
    
    • 红色
    python3 - <<'PY' > /dev/fb0
    import sys
    w, h = 240, 320
    red   = b'\x00\x00\xff\x00'
    sys.stdout.buffer.write(red * (w * h))
    PY
    
    • 绿色
    python3 - <<'PY' > /dev/fb0
    import sys
    w, h = 240, 320
    green = b'\x00\xff\x00\x00'
    sys.stdout.buffer.write(green * (w * h))
    PY
    
    • 蓝色
    python3 - <<'PY' > /dev/fb0
    import sys
    w, h = 240, 320
    blue  = b'\xff\x00\x00\x00'
    sys.stdout.buffer.write(blue * (w * h))
    PY
    
  • modetest测试

    1. modetest -c
      
      [root@luckfox ]# modetest -c
      Connectors:
      id      encoder status          name            size (mm)       modes   encoders
      70      69      connected       DPI-1           43x57           1       69
        modes:
              index name refresh (Hz) hdisp hss hse htot vdisp vss vse vtot
        #0 240x320 68.98 240 282 292 302 320 328 332 336 7000 flags: nhsync, nvsync; type: preferred, driver
      
    2. modetest -M rockchip -s 70@66:240x320
      
      参数含义在你的场景中的具体作用
      modetestDRM测试工具本身调用用户态API (libdrm) 与内核通信
      -M rockchip指定驱动模块名强制直接打开Rockchip的DRM设备(如/dev/dri/card0),跳过之前那些漫长的i915/amdgpu等探测超时,实现瞬间执行
      -s设置显示模式 (Set Mode)核心动作:将指定的Connector(显示器接口)和CRTC(显示控制器)绑定起来,并启用显示输出
      70@66:240x320连接器ID @ CRTC ID : 分辨率70 是你的DPI屏幕接口,66 是显示控制器,强制以 240x320 分辨率输出
      -v详细模式 (Verbose)让程序在运行过程中打印每一帧的提交细节、像素格式转换、以及页面翻转(Fence)等内核事件
      trying to open device 'i915'...failed
      trying to open device 'amdgpu'...failed
      trying to open device 'radeon'...failed
      trying to open device 'nouveau'...failed
      trying to open device 'vmwgfx'...failed
      trying to open device 'omapdrm'...failed
      trying to open device 'exynos'...failed
      trying to open device 'tilcdc'...failed
      trying to open device 'msm'...failed
      trying to open device 'sti'...failed
      trying to open device 'tegra'...failed
      trying to open device 'imx-drm'...failed
      trying to open device 'rockchip'...done
      setting mode 240x320-68.98Hz on connectors 70, crtc 66
      failed to set gamma: Invalid argument
      

    看时钟

    # 挂载 debugfs(如果没挂载)
    mount -t debugfs none /sys/kernel/debug
    
    # 查看 VOP dclk 实际频率
    cat /sys/kernel/debug/clk/clk_summary | grep -i dclk
    
    # 或者看所有和显示相关的时钟
    cat /sys/kernel/debug/clk/clk_summary | grep -i "vop\|dclk\|rgb"
    

说明

SPI初始化+RGB显示:SPI负责初始化参数,RGB线路负责显示图像。可以通过SPI通信控制RGB显示,比如翻转,改变分辨率之类操作。

流程

  1. SPI初始化
  2. 开启背光
  3. RGB控制
# 挂载 debugfs(如果没挂载)
mount -t debugfs none /sys/kernel/debug

# 查看 VOP dclk 实际频率
cat /sys/kernel/debug/clk/clk_summary | grep -i dclk

# 或者看所有和显示相关的时钟
cat /sys/kernel/debug/clk/clk_summary | grep -i "vop\|dclk\|rgb"
/ {
    model = "JW Kernel Board V1";
    compatible = "jw,kernel-board-v1", "rockchip,rv1106";

    vcc_1v8: vcc-1v8 {
        compatible = "regulator-fixed";
        regulator-name = "vcc_1v8";
        regulator-always-on;
        regulator-boot-on;
        regulator-min-microvolt = <1800000>;
        regulator-max-microvolt = <1800000>;
    };

    vcc_3v3: vcc-3v3 {
        compatible = "regulator-fixed";
        regulator-name = "vcc_3v3";
        regulator-always-on;
        regulator-boot-on;
        regulator-min-microvolt = <3300000>;
        regulator-max-microvolt = <3300000>;
    };
    vcc5v0_usb: vcc5v0-usb {
		compatible = "regulator-fixed";
		regulator-name = "vcc5v0_usb";
		regulator-min-microvolt = <5000000>;
		regulator-max-microvolt = <5000000>;
		enable-active-high;
		gpio = <&gpio0 RK_PA2 GPIO_ACTIVE_HIGH>;
		pinctrl-names = "default";
		pinctrl-0 = <&usb_pwren>;
	};
};
写法实际控制硬件?干什么用
regulator-fixed 无 gpio❌ 不控制就是个标签,告诉内核"这个电压存在"
regulator-fixed 有 gpio✅ 控制一个 GPIO打开/关闭某个电源芯片的使能脚
pwm-regulator✅ 控制 PWM通过 PWM 占空比调压(CPU 核心电压)

cc_1v8 和 vcc_3v3——它们没有 gpio,不控制任何硬件,纯属为了让依赖的驱动能 probe 通过

vcc5v0_usb 它有一个 GPIO 脚去开 USB 口的 5V 供电。

设备树驱动支持的属性

内核源码路径:

Documentation/devicetree/bindings

概念

没有设备树之前。

要想建立一个/dev/xxx文件,用来操作硬件设备需要以下步骤。

  1. 创建xxx_device.c和xxx_device.h来描述设备。比如某个寄存器。
  2. 创建驱动xxx_driver.c和xxx_driver.h操作描述的设备。比如寄存器写入和读取。
  3. 内核编译和引入。(需补充详细过程)

有设备树之后

要想建立一个/dev/xxx文件,用来操作硬件设备需要以下步骤。

  1. 创建设备树,用来描述设备,代替前面的第一步。
  2. 创建驱动xxx_driver.c和xxx_driver.h操作描述的设备。比如寄存器写入和读取。更多时候编译成模块。
  3. 内核编译和引入。(需补充详细过程)

对于自己编写的驱动,设备树要描述这个驱动,设备树和驱动代码必须做关联联动。

而对于常见的驱动,比如iic,spi等。前辈们已经集成在linux内核中,只需要在设备树下描述硬件信息。就能自己识别到这些驱动。设备树最主要的compatible属性如何匹配的,还不是很明白。

如何与内核联动

设备树

hello {
    compatible = "mycompany,hello";
};

驱动代码

#include <linux/init.h>
#include <linux/module.h>
#include <linux/platform_device.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Lingma Assistant");
MODULE_DESCRIPTION("Hello World driver with Device Tree support");

// 匹配成功后调用的 probe 函数
static int hello_probe(struct platform_device *pdev)
{
    printk(KERN_INFO "Hello, World! Device matched: %s\n", pdev->name);
    // 这里可以获取设备树资源,如寄存器、GPIO 等
    return 0;
}

// 设备移除时调用
static int hello_remove(struct platform_device *pdev)
{
    printk(KERN_INFO "Goodbye, World! Device removed.\n");
    return 0;
}

// 定义支持的 compatible 列表
static const struct of_device_id hello_of_match[] = {
    { .compatible = "mycompany,hello" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, hello_of_match);

// 定义 platform 驱动结构体
static struct platform_driver hello_driver = {
    .probe    = hello_probe,
    .remove   = hello_remove,
    .driver   = {
        .name = "hello",
        .of_match_table = hello_of_match,
    },
};

module_platform_driver(hello_driver);

概念

设备树是一种硬件描述机制,它替代了内核中静态定义的 platform_device(以及其他总线设备描述)。平台总线(platform)负责管理 SoC 内部没有真实物理总线的设备。 传统 platform 驱动通过名称匹配设备,使用设备树后,主要依靠 compatible 属性匹配。匹配成功后调用 probe 函数,驱动在 probe 中通过 of.h API 直接读取设备树节点的硬件信息(属性可以有多个值)。设备树与 /dev 下的设备文件无直接关系,后者是驱动为提供用户空间接口而主动创建的。

编译工具路径

/root/rk-toots/kernel/scripts/dtc/dtc

可以修改环境变量

/etc/profile添加

export PATH=$PATH:/root/rk-toots/kernel/scripts/dtc/dtc
source /etc/profile

编译设备树1

dtc -I dts -O dtb -o xxx.dtb xxx.dts
  • dtc:工具
  • -I dts:输入的文件格式为dts
  • -O dtb:输出的文件格式为dtb
  • -o xxx.dtb:输出的文件名称
  • xxx.dts:输入的文件

编译设备树2

进入到kernel目录执行

make <dts文件名>.dtb

反编译

dtc -I dtb -O dts -o xxx.dts xxx.dtb
  • dtc:工具
  • -I dtb:输入的文件格式为dtb
  • -O dts:输出的文件格式为dts
  • -o xxx.dts:输出的文件名称
  • xxx.dtb:输入的文件

这里学习设备树用的是qemu模拟器环境,具体qemu具体使用方式参考:

embedded/qemu模拟环境.md

默认最原始的dts

/dts-v1/;

/ {
    model = "My Custom QEMU virt Board";
    compatible = "my-custom,virt-board", "linux,dummy-virt";
    interrupt-parent = <&gic>;
    #address-cells = <2>;
    #size-cells = <2>;

    /*
     * chosen: 传递给内核的参数
     * stdout-path 告诉内核控制台用哪个串口
     */
    chosen {
        bootargs = "console=ttyAMA0,115200";
        stdout-path = "/pl011@9000000";
    };

    /* 别名: 方便 U-Boot 和内核查找设备 */
    aliases {
        serial0 = &uart0;
    };

    /* ========== CPU ========== */
    cpus {
        #address-cells = <1>;
        #size-cells    = <0>;

        cpu@0 {
            compatible = "arm,cortex-a7";
            device_type = "cpu";
            reg = <0>;
            enable-method = "psci";
        };
    };

    /* ========== PSCI (电源管理) ========== */
    psci {
        compatible = "arm,psci-0.2", "arm,psci";
        method = "hvc";
        cpu_on   = <0x84000003>;
        cpu_off  = <0x84000002>;
        cpu_suspend = <0x84000001>;
        migrate  = <0x84000005>;
    };

    /* ========== 内存 ========== */
    memory@40000000 {
        device_type = "memory";
        reg = <0x0 0x40000000 0x0 0x20000000>;
    };

    /* ========== 定时器 ========== */
    timer {
        compatible = "arm,armv7-timer";
        interrupts = <1 13 0x104>,   /* PPI 13, 安全 */
                     <1 14 0x104>,   /* PPI 14, 非安全 */
                     <1 11 0x104>,   /* PPI 11, 虚拟 */
                     <1 10 0x104>;   /* PPI 10, Hypervisor */
        always-on;
    };

    /* ========== PL011 UART (控制台) ========== */
    uart0: pl011@9000000 {
        compatible = "arm,pl011", "arm,primecell";
        reg = <0x0 0x09000000 0x0 0x1000>;
        interrupts = <0 1 4>;        /* SPI 1, 高电平触发 */
        clocks = <&pclk &pclk>;
        clock-names = "uartclk", "apb_pclk";
    };

    /* ========== GICv2 中断控制器 ========== */
    gic: intc@8000000 {
        compatible = "arm,cortex-a7-gic";
        #interrupt-cells = <3>;
        #address-cells = <2>;
        #size-cells = <2>;
        interrupt-controller;
        reg = <0x0 0x08000000 0x0 0x10000>,   /* GIC 分配器 */
              <0x0 0x08010000 0x0 0x10000>;    /* GIC CPU 接口 */
        ranges;

        v2m@8020000 {
            compatible = "arm,gic-v2m-frame";
            msi-controller;
            reg = <0x0 0x08020000 0x0 0x1000>;
        };
    };

    /* ========== APB PCLK (24MHz 固定时钟) ========== */
    pclk: apb-pclk {
        compatible = "fixed-clock";
        #clock-cells = <0>;
        clock-frequency = <24000000>;
        clock-output-names = "clk24mhz";
    };

    /* ========== 平台总线 ========== */
    platform@c000000 {
        compatible = "qemu,platform", "simple-bus";
        ranges = <0 0 0x0c000000 0x2000000>;
        #address-cells = <1>;
        #size-cells = <1>;
        interrupt-parent = <&gic>;
    };
};

所有设备树的节点都会放到

/sys/firmware/devicetree/base
1

003. Pinctrl子系统

RK引脚宏定义在rk_gpio.h中,其他厂商SOC也类型

不同板子的pinctrl相关配置一般在名为:xxx-pinctrl.dtsi文件中

关于引脚驱动方式

pcfg_ 配置一览:

宏含义
pcfg_pull_up内部上拉
pcfg_pull_down内部下拉
pcfg_pull_none浮空(不拉)
pcfg_output_high初始输出高
pcfg_output_low初始输出低
pcfg_pull_up_drv_等级上拉 + 驱动强度

004. 平台总线驱动

平台总线时linux系统虚拟出来的总线。

将原来的驱动拆成两个部分进行解耦。device.c和driver.c

device.c:用来描述硬件信息。相当于后面要学习的设备树。

**driver.c:**用来控制硬件,即硬件的驱动。

先分离,后搭档。

如何分离

如何搭档

流程

make
insmod 001_helloworld_driver.ko #device和driver不分先后
insmod 001_helloworld_device.ko #device和driver不分先后
ls /sys/bus/platform/devices #注册设备时添加
#生成 my_device_test文件,my_device_test文件和device.c中platform_device.name一致
ls /sys/bus/platform/drivers
#生成 my_device_test文件,my_device_test文件和driver.c中platform_driver.name一致

linux系统会自动将名字一样的设备和驱动关联在一起。几乎是瞬间。

代码:001_helloworld_device.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>

static struct resource my_device_resource[]={
    [0]={
        .start = 0x0,
        .end = 0xA,
        .flags = IORESOURCE_MEM
    },
    [1]={
        .start = 0xB,
        .end = 0xF,
        .flags = IORESOURCE_IRQ
    }
};
void my_device_release(struct device *dev){
    printk("this is my_device_release");
}
struct platform_device my_device_test = {
    .name="my_device_test",
    .id = -1,
    .resource = my_device_resource,
    .dev={
        .release= my_device_release
    }
};
static int platform_init(void)
{
    platform_device_register(&my_device_test);
    printk("platform_init");
    return 0;
}
static void platform_exit(void)
{
    platform_device_unregister(&my_device_test);
    printk("platform_exit");
}

module_init(platform_init);
module_exit(platform_exit);

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("A simple Hello World Linux Driver");

代码:001_helloworld_driver.c

#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/mod_devicetable.h>
int my_probe(struct platform_device * dev){
    printk("my_probe");
    return 0;
}
int my_remove(struct platform_device * dev){
    printk("my_remove");
    return 0;
};
// const struct platform_device_id my_id_table[] = {
//     {.name = "my_device_test"},
//     {},
// };
struct platform_driver driver_test= {
    .probe = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "my_device_test", //与device设备描述名字一致!!!
        .owner = THIS_MODULE,

        // const struct platform_device_id *id_table; //非必须,匹配时优先级最高比上面的name要高
    }
};
static int platform_driver_init(void)
{
    platform_driver_register(&driver_test);
    printk("platform_init");
    return 0;
}
static void platform_driver_exit(void)
{
    platform_driver_unregister(&driver_test);
    printk("platform_exit");
}

module_init(platform_driver_init);
module_exit(platform_driver_exit);

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("A simple Hello World Linux Driver");

什么时候需要 id_table

  • 一个驱动支持多个不同名字的设备时(比如同一芯片不同型号)
  • 需要 driver_data 传递设备差异信息时(每个 id 入口可以带一个 kernel_ulong_t 的数据)
#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>

//设备资源(设备描述)
static struct resource led_resources[]={
    [0]={
        .start = 0x0,
        .end = 0xA,
        .flags = IORESOURCE_MEM
    },
    [1]={
        .start = 0xB,
        .end = 0xF,
        .flags = IORESOURCE_IRQ
    },
    [2]={
        .start = 0xAA,
        .end = 0xAF,
        .flags = IORESOURCE_IRQ
    }
};
static void led_device_release(struct device *dev)
{
    printk("led_device_release\n");
}

static struct platform_device led_device = {
    .name = "led_device",
    .id = -1,
    .resource = led_resources,
    .dev = {
        .release = led_device_release,
    },
};
static int led_module_init(void)
{
    platform_device_register(&led_device);
    printk("hello world\n");
    return 0;
}
static void led_module_exit(void)
{
    platform_device_unregister(&led_device);
    printk("goodbye world\n");
}

module_init(led_module_init);
module_exit(led_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("WYL");
MODULE_DESCRIPTION("led device");
#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/mod_devicetable.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>
dev_t dev_no; // 设备号
struct cdev cdev_test; // 字符设备结构体

struct class *class_test; // 类
struct device *device_test; // 设备
static int test_open(struct inode *inode,struct file *file)
{
    printk("open\n");
    return 0;
}
static ssize_t test_read(struct file *file,char __user *buf, size_t size,loff_t *off){
    printk("read\n");
    return 0;
}
static ssize_t test_write(struct file *file,const char __user *buf, size_t size,loff_t *off){
    printk("write\n");
    *off += size;
    return size;
}
static int test_release(struct inode *inode,struct file *file)
{
    printk("release\n");
    return 0;
}
struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = test_open,
    .read = test_read,
    .write = test_write,
    .release = test_release,
};
int my_probe(struct platform_device * dev){
    
    int ret;
    ret = alloc_chrdev_region(&dev_no, 0, 1, "led_device");
    if (ret<0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("major:%d, minor:%d\n", MAJOR(dev_no), MINOR(dev_no));
    cdev_init(&cdev_test, &fops);
    cdev_add(&cdev_test, dev_no, 1);

    class_test = class_create(THIS_MODULE, "led_device"); //创建类
    device_test = device_create(class_test, NULL, dev_no, NULL, "led_device");

    printk("my_probe"); //device和driver匹配成功时调用
    return 0;
}
int my_remove(struct platform_device * dev){
    
    printk("my_remove"); //移除设备或这个驱动被rmmod时调用
    return 0;
};
struct platform_driver driver_test= {
    .probe = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "led_device", //与device设备描述名字一致!!!
        .owner = THIS_MODULE,

        // const struct platform_device_id *id_table; //非必须,匹配时优先级最高比上面的name要高
    }
};
static int platform_driver_init(void)
{
    platform_driver_register(&driver_test);
    printk("platform_driver_init");
    return 0;
}
static void platform_driver_exit(void)
{
    // 1. 先销毁设备节点
    device_destroy(class_test, dev_no);
    
    // 2. 销毁类 (原代码缺失此步,必须添加)
    class_destroy(class_test);
    
    // 3. 删除字符设备
    cdev_del(&cdev_test);
    
    // 4. 最后注销设备号
    unregister_chrdev_region(dev_no, 1);
    platform_driver_unregister(&driver_test);
    printk("platform_driver_exit");
}

module_init(platform_driver_init);
module_exit(platform_driver_exit);

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("A simple Hello World Linux Driver");

005. Kobject&kset 设备模型框架

# ARM 交叉编译内核模块
# 用法: make                     (编译)
#       make clean               (清理)
#       make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
#          -C /path/to/kernel M=$(PWD) modules

KERNELDIR ?= ../../../kernel

PWD := $(shell pwd)

obj-m := 001_kobject.o

all:
	$(MAKE) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \
		-C $(KERNELDIR) M=$(PWD) modules
	@# 清理中间文件,只保留源文件 .c、模块 .ko 和 Makefile
	@find $(PWD) -maxdepth 1 -type f ! -name '*.ko' ! -name 'Makefile' ! \( -name '*.c' ! -name '*.mod.c' \) -delete 2>/dev/null || true
	@rm -rf $(PWD)/.tmp_versions 2>/dev/null || true

clean:
	$(MAKE) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \
		-C $(KERNELDIR) M=$(PWD) clean

kobject即kernel object,内核对象。

kobject在内核中是一个结构体

在/sys目录下全是kobject

struct kobject {
	const char		*name;
	struct list_head	entry;
	struct kobject		*parent;
	struct kset		*kset;
	struct kobj_type	*ktype;
	struct kernfs_node	*sd; /* sysfs directory entry */
	struct kref		kref;
#ifdef CONFIG_DEBUG_KOBJECT_RELEASE
	struct delayed_work	release;
#endif
	unsigned int state_initialized:1;
	unsigned int state_in_sysfs:1;
	unsigned int state_add_uevent_sent:1;
	unsigned int state_remove_uevent_sent:1;
	unsigned int uevent_suppress:1;
};

设备,驱动,总线,类

  • 设备:struct device
  • 驱动:struct device_driver
  • 类:struct class ,会在/sys/class/xxx_class
  • 总线:struct bus
  • 编译使用
insmod 001_kobject.ko #加载驱动,必须使用root用户
dmesg | tail #查看驱动加载情况
rmmod 001_kobject  #卸载驱动,必须使用root用户
dmesg | tail #查看驱动卸载情况
  • 查看挂载情况
lsmod
ls /sys

代码

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/kobject.h>

struct kobject *o1;
struct kobject *o2;

struct kobject *o3;
struct kobj_type kt;
// 模块加载时调用的函数
static int my_kobject_init(void)
{
    int ret;
    o1 = kobject_create_and_add("myobject01",NULL);
    o2 = kobject_create_and_add("myobject02",o1);
    //创建方式二
    o3 = kzalloc(sizeof(struct kobject),GFP_KERNEL);
    ret=kobject_init_and_add(o3,&kt,NULL,"%s","myobject03");

    return 0; // 返回 0 表示加载成功
}

// 模块卸载时调用的函数
static void my_kobject_exit(void)
{
    kobject_put(o1);
    kobject_put(o2);
    kobject_put(o3);
    printk("Goodbye, World! Driver unloaded.\n");
}

// 注册模块的入口和出口函数
module_init(my_kobject_init);
module_exit(my_kobject_exit);

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("kobject");

代码

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/kobject.h>

struct kobject *o1;
struct kobject *o2;

struct kset *ks;
struct kobj_type kt;
// 模块加载时调用的函数
static int my_kobject_init(void)
{
    int ret;
    ks = kset_create_and_add("mykset",NULL,NULL);

    o1 = kzalloc(sizeof(struct kobject),GFP_KERNEL);
    o1->kset = ks;
    ret = kobject_init_and_add(o1,&kt,NULL,"%s","mykobjcet01");

    o2 = kzalloc(sizeof(struct kobject),GFP_KERNEL);
    o2->kset = ks;
    ret = kobject_init_and_add(o2,&kt,NULL,"%s","mykobjcet02");
    return 0; // 返回 0 表示加载成功
}

// 模块卸载时调用的函数
static void my_kobject_exit(void)
{
    kobject_put(o1);
    kobject_put(o2);
    kset_put(ks);
    printk("Goodbye, World! Driver unloaded.\n");
}

// 注册模块的入口和出口函数
module_init(my_kobject_init);
module_exit(my_kobject_exit);

// 模块许可证声明,必须是 GPL 兼容的许可证才能加载到内核中
MODULE_LICENSE("GPL");
// 模块作者信息
MODULE_AUTHOR("wyl");
// 模块描述
MODULE_DESCRIPTION("kobject");

006. Mdev udev

什么是热插拔

带电插拔,不用关闭系统。

热插拔当内核发生某种热插拔事件时,内核会调用用户空间的程序实现交互。比如内核发生TF插入,调用用户空间程序,用户空间程序自动挂载等

机制

有devfs,udev,mdev,devfs基本不用了。mdev是udev简化版本。

udev是基于netlink机制实现的。通过监听内核发送的uevent来执行相应的热插拔操作。mdev是基于uevent_helper机制,内核产生的uevent会调用uevent_helper所指向的用户程序,mdev执行热插拔动作。

特性Netlink (NETLINK_KOBJECT_UEVENT)uevent_helper
通信方式基于套接字(Socket)的异步消息通信内核直接执行用户空间程序
实现方式内核广播消息,用户态进程监听接收内核为每个事件 fork() 一个新进程执行助手程序
效率高(消息传递,无进程创建开销)极低(每次事件都创建进程,开销巨大)
扩展性好,支持多播,可供多个程序同时监听差,一次只能指定一个助手程序
当前状态主流、推荐已废弃,现代系统默认不启用
典型使用者udev、systemd-udevd 等现代设备管理工具早期的 hotplug 脚本
  • 编译使用
insmod 001_helloworld.ko #加载驱动,必须使用root用户
dmesg | tail #查看驱动加载情况
rmmod 001_helloworld  #卸载驱动,必须使用root用户
dmesg | tail #查看驱动卸载情况
  • 查看挂载情况
lsmod

测试脚本

  • 罗列已有节点:

    ls /proc/device-tree/
    
  • 查看设备树的model

    cat /proc/device-tree/model
    

Linux驱动有多种实现方式

.ko文件insmod

  • 直接在运行中的系统执行insmod安装模块驱动
  • 放在文件系统,开机自启脚本执行insmod
  • 标准位置自动加载:/lib/modules/$(uname -r)/
  • 比如001-字符设备驱动 以及 002-平台总线驱动 下的驱动

linux目录下xxx.c + Kconfig + Makefile + DTS

内核直接加载模块

  1. 编译一堆自己的.ko文件,打包进文件系统里面。
  2. 由系统启动脚本insmod
  3. 匹配设备树/总线设备!
  4. 匹配成功才会probe()

uboot中初始化

基本不太属于linux驱动范畴,虽说用的是差不多的概念,但是不在linux驱动中。

完整关联表

文件/命令看什么和 DT 的关系
cat /proc/device-tree/model板子型号DT 根节点 model
ls /sys/firmware/devicetree/base/DT 所有节点原始 DT 数据
cat /sys/bus/platform/devices/xxx/uevent设备 OF 属性DT 节点的内核导出
cat /proc/devices所有已注册驱动的主设备号驱动调 register_chrdev() 产生
ls -l /dev/实际可用的设备文件主:次 = devtmpfs 根据驱动注册创建
cat /proc/interrupts中断号和绑定的驱动DT 中 interrupts 属性
cat /proc/iomem物理地址映射DT 中 reg 属性
cat /sys/class/*/*/dev主:次 设备号驱动注册时设定的
lsmod已加载的内核模块模块加载时匹配 DT compatible
`dmesggrep -i ofdt

Mem内存操作

C标准库操作内存(<string.h>)

linux动态操作内存(<stdlib.h>)

内存的分配和释放

linux高级内存操作

用户空间
内核空间

把一段内存的数据全部变成一个值

  • 清空数组数据
#include <stdio.h>
#include <string.h>

int main(void)
{
	int arr[] = {1,2,3,4};
    for (size_t i = 0; i < 4; i++) {
        printf("memset before values[%zu]=%d\n", i, values[i]);
    }
    memset(arr,0,sizeof(arr));
    return 0;
}
  • 初始化清零结构体
typedef struct Point{
	int x;
	int y;
}Point_t;
int main(void)
{
	Point_t p1 = {0};//通常采用的方式
    Point_t p2;
    memset(&p2,0,sizeof(p2)); //字节操作 开启-O2优化之后生成的机器码基本相同,没有明显的性能区别
}

把一段内存的数据复制到另一段内存,这个过程是cpu搬运。

  • 变量赋值
int a = 10;
int b;
memcpy(&b,&a,sizeof(a));
printf("b=%d\n",b);
  • 数组复制
int arr1[] = {1,2,3,4};
int arr2[4];
memcpy(arr2,arr1,sizeof(arr1));//将arr1内存中的数据长度为arr1的长度复制到arr2的内存
printf("arr2=%d %d %d %d\n", arr2[0], arr2[1], arr2[2], arr2[3]);
  • 结构体复制
typedef struct Point{
	int x;
	int y;
}Point_t;
int main(void)
{
    Point_t p1 = {1,2};
    Point_t p2 = {0};
    memcpy(&p2,&p1,sizeof(p1));
    printf("p2,(%d,%d)",p2.x,p2.y);
    return 0;
}

将当前内存数据复制n个某一个内存,这个是按照字符来的不是字节很多我玩法不支持

  • 数组复制
char s1[] = "111111";
char s2[] = "222222";
memmove(s2,s1,2); //将s1中从0-2复制到s2的0-2
printf("%s",s2);

分配堆内存,malloc只是分配一块具体大小的空间,返回首地址给到用户,而且不会初始化内容,如果用户不去做初始化的操作,里面的内容有可能是之前分配过又释放掉的。

  • 分配常用变量地址
int *arr = malloc(sizeof(int)*10);//长度为10的int数组 一般malloc不会初始化数组
arr[0] = 5;
printf("arr[0]=%d\n", arr[0]);
  • 为结构体分配内存
typedef Point{
	int x;
	int y;
}Point_t;
Point_t *point = malloc(sizeof(Point_t));
if(point){
	buf->x = 5;
    buf->y = 10;
}
printf("buf=(%d, %d)\n", buf->x, buf->y);

分配什么长度的内存多少个。并且初始化。

  • 分配数组
int *a = calloc(8, sizeof(int));
if(a){
    for (size_t i = 0; i < n; i++) {
    	printf("a[%zu]=%d\n", i, a[i]);
	}
}
  • 分配结构体
typedef struct Point {
    int x;
    int y;
} Point_t;
Point_t *points = calloc(3, sizeof(Point_t));
if (!points) {
    perror("calloc");
    return 1;
}
//赋值
points[0].x = 5;
points[0].y = 10;
for (size_t i = 0; i < n; i++) {
	printf("points[%zu]=(%d, %d)\n", i, points[i].x, points[i].y);
}

扩展现有的内存,而且前面分配的内存和后面扩展的内存虚拟地址必然是连续的

原地址后面有足够空间,直接扩展

原地址后面没有空间,分配器会申请一块新的、更大的连续区域,原来的地址都会集体迁移。

int *a = malloc(4 * sizeof(int));
int *b = realloc(a, 8 * sizeof(int));
//直接用b就好

普通文件读操作

应用程序:

read(fd, user_buffer, size);

1. 进入内核态

用户程序调用 read()
        ↓
系统调用进入内核态

这是一次用户态到内核态的模式切换,但进程通常还是同一个进程。

2. 内核检查 Page Cache

内核检查文件对应的数据页是否已经在 Page Cache

如果命中:

Page Cache
    ↓ copy_to_user
用户缓冲区

不需要访问磁盘。

3. Page Cache 未命中

内核会:

为文件分配或找到 Page Cache 页面
        ↓
构造块设备 I/O 请求
        ↓
存储控制器通过 DMA 把数据写入 Page Cache 页面

典型路径:

磁盘/存储设备
        ↓ DMA
Page Cache

通常不是:

磁盘缓冲区
    ↓ CPU复制
内核缓冲区
    ↓ CPU复制
Page Cache

块设备层通常直接让 DMA 将数据放入内核准备好的页面。

4. 复制到用户缓冲区

数据进入 Page Cache 后:

Page Cache
    ↓ copy_to_user
用户缓冲区

然后 read() 返回:

内核态 → 用户态

所以普通文件读取可以总结为:

Page Cache未命中:

1. 用户态 → 内核态
2. 存储设备通过DMA写入Page Cache
3. CPU将Page Cache复制到用户缓冲区
4. 内核态 → 用户态

如果 Page Cache 命中:

1. 用户态 → 内核态
2. CPU将Page Cache复制到用户缓冲区
3. 内核态 → 用户态

普通文件写操作

应用程序:

write(fd, user_buffer, size);

典型流程如下。

1. 进入内核态

用户程序调用 write()
        ↓
用户态 → 内核态

2. 数据复制到 Page Cache

用户缓冲区
    ↓ copy_from_user
Page Cache

对应 Page Cache 页面会被标记为脏页。

通常不是同时复制两份:

用户缓冲区 → Page Cache
用户缓冲区 → 另一个内核缓冲区

Page Cache 本身就是普通文件写入的主要内核缓存。

3. write() 返回

很多情况下,数据进入 Page Cache 后,write() 就可以返回:

内核态 → 用户态

此时数据不一定已经到达磁盘。

4. 后台回写

稍后由内核回写线程,或者由 fsync() 主动触发:

Page Cache
    ↓ DMA
存储设备

因此普通文件写入可以总结为:

1. 用户态 → 内核态
2. CPU将用户缓冲区复制到Page Cache
3. 将Page Cache页面标记为脏
4. 内核态 → 用户态
5. 内核稍后通过DMA将脏页写入存储设备

页缓存是Linux内核在内存中开辟的一块区域(属于内核),用于缓存最近访问过的文件数据。它主要解决了两个问题:

  1. 速度鸿沟:内存的读写速度远超硬盘(即使是顶级的固态硬盘,其速度也远慢于内存)。如果每次读取文件都要直接访问硬盘,程序将被极大地拖慢。
  2. 时间局部性原理:程序在短期内往往会重复访问相同的数据。页缓存利用这一点,一旦数据被读入,后续的访问就可以直接从内存中获取,避免了重复、缓慢的磁盘I/O操作。
存储设备
    ↓ DMA
Page Cache
    ↓ 页表映射
用户虚拟地址data
    ↓
CPU直接读取data[index]
//打开一个文件
#include <fctnl.h>
int fd = open("./test.txt",O_RDONLY);//只读的方式
//文件相关信息
#include <sys/stat.h>
struct stat file_stat;
if(fstat(fd,&file_stat)!=0){
	return 1;
}
//建立映射
#include <sys/mman.h>
size_t file_size = (size_t)file_stat.st_size;
const char *data = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);

//关闭文件,映射建立完成文件就不需要打开了
#include <unistd.h>
close(fd);

printf("%s",data);

//解除映射
munmap((void *)data, file_size)

cpu拷贝

由于mmap极为重要,所以单独拿出来作为一个注意点。

​ mmap 是 memory map 的缩写,它将内核中的一块内存(这里是 Framebuffer)映射到用户进程的地址空间,使得可以直接像访问普通数组一样读写这块内存,而不需要通过 read/write 系统调用。

说到mmap就不得不说零拷贝(zero-copy),举一些例子,但我们重点将mmap功能。

实现方式CPU拷贝次数系统调用适用场景硬件要求
mmap+write12小文件、需修改数据无
sendfile0~11静态文件传输(HTTP服务器)可选DMA
splice01~2通用内核态数据传输无
MSG_ZEROCOPY01大消息网络发送网卡支持
io_uring00 (共享内存)现代高性能异步I/O内核5.1+
RDMA00 (bypass)超低延迟、远程内存访问专用网卡

mmap应用场景

1. 高性能文件 I/O(替代 read/write)

这是 mmap 最经典的用途。

  • 传统方式 (read/write): 磁盘 -> 内核页缓存 (Page Cache) -> 用户缓冲区。数据需要在内核态和用户态之间拷贝两次。
  • mmap 方式: 磁盘 -> 内核页缓存 <-> 用户虚拟地址。用户进程直接访问内核页缓存映射的地址,零拷贝(Zero-Copy),减少了上下文切换和数据拷贝开销。
  • 适用场景:
    • 大文件的随机读写(如数据库索引文件)。
    • 需要频繁读取同一文件的不同部分。
    • 注意:对于小文件或顺序读写,mmap 的优势不明显,甚至可能因为缺页中断(Page Fault)而比 read 慢。

2. 进程间通信(IPC)- 共享内存

mmap 是实现共享内存最标准、最便携的方式。

  • 原理:两个或多个进程映射同一个文件(或匿名内存),它们会看到同一块物理内存。一个进程修改数据,另一个进程立即可见。

  • 优势:比管道(Pipe)、消息队列(Message Queue)快得多,因为不需要数据拷贝。

  • 代码示例:

    c// 进程 A 和 进程 B 都执行以下代码
    int fd = open("shared_file", O_RDWR | O_CREAT, 0666);
    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    // 现在 ptr 指向的内存是共享的
    

3. 设备驱动交互(如 Framebuffer)

这正是你当前代码 001_proj.c 的使用场景。

  • 原理:Linux 将硬件设备抽象为文件(如 /dev/fb0, /dev/gpu, /dev/mem)。通过 mmap 将这些特殊文件映射到用户空间,用户程序可以直接读写硬件寄存器或显存。
  • 常见设备:
    • Framebuffer (/dev/fb0):直接操作屏幕像素。
    • GPIO / 嵌入式外设:在嵌入式 Linux 中,直接映射物理内存地址来控制硬件寄存器。
    • GPU 显存:图形程序中上传纹理或顶点数据。

4. 动态链接库的加载

当你运行一个程序时,操作系统加载 .so (共享库) 或 .dll 文件时,底层通常也使用 mmap。

  • 原理:将库文件映射到内存,多个进程可以共享同一份库文件的物理页面(只读部分),节省内存。

5. 大内存分配(匿名映射)

当程序请求大块内存(例如 malloc 超过一定阈值,通常是 128KB 或 1MB)时,glibc 的 malloc 内部会使用 mmap 而不是 brk/sbrk。

  • 参数:fd 设为 -1,flags 设为 MAP_ANONYMOUS | MAP_PRIVATE。
  • 优势:释放方便(直接 munmap),不会造成堆内存碎片。

总结:什么时候该用 mmap?

场景推荐指数原因
大文件随机读写⭐⭐⭐⭐⭐避免多次 seek 和拷贝,利用 OS 页缓存机制。
进程间共享大数据⭐⭐⭐⭐⭐最快的 IPC 方式,零拷贝。
操作硬件设备/显存⭐⭐⭐⭐⭐用户态直接访问硬件内存的唯一标准方式。
小文件顺序读写⭐⭐read/write 更简单,且 mmap 有缺页中断开销。
网络传输⭐⭐⭐结合 sendfile 或 write 可实现零拷贝发送。

RK平台功能

什么是音视频开发

  • ALSA 音频框架(arecord/aplay/amixer)
  • V4L2 视频采集接口
  • DRM/KMS 显示框架
3.  media目录结构说明:
media
├── alsa-lib----------------------------------- Advanced Linux Sound Architecture (ALSA) library
├── cfg/cfg.mk--------------------------------- 针对不同SOC平台配置不同模块
├── common_algorithm--------------------------- 音频3A算法、移动检测、遮挡检测
├── isp---------------------------------------- isp图像处理算法
├── libdrm------------------------------------- Direct Rendering Manager
├── libv4l------------------------------------- video4linux2设备用户层接口
├── Makefile----------------------------------- media Makefile
├── Makefile.param----------------------------- media Makefile的配置文件
├── out---------------------------------------- media 编译输出目录
├── rga---------------------------------------- RGA是一个独立的2D硬件加速器,可用于加速点和线绘制
│                                               执行图像缩放、旋转、bitBlt、alpha混合等常见的2D图形操作
│
├── mpp---------------------------------------- 编解码接口,给rkmedia和rockit调用,不建议直接调用mpp
├── rkmedia------------------------------------ 多媒体处理框架,封装isp、rga、mpp等多媒体相关接口
|												适用部分旧平台,如rv1106/rv1103,rk3588不再支持
|
└── rockit------------------------------------- 多媒体处理框架,和rkmedia是两套对外的接口

RK各模块功能

rk功能模块

RK官方MPP库地址:https://github.com/rockchip-linux/mpp

瑞芯微提供的媒体处理软件平台(Media Process Platform,简称MPP)是适用于瑞芯微芯片系列的通用媒体处理软件平台。该平台对应用软件屏蔽了芯片相关的复杂底层处理,其目的是为了屏蔽不同芯片的差异,为使用者提供统一的视频媒体处理接口(Media Process Interface,缩写MPI)。MPP提供的功能包括:

视频解码H.265 / H.264 / H.263 / VP9 / VP8 / MPEG-4 / MPEG-2 / MPEG-1 / VC1 / MJPEG / AV1
视频编码H.265 / H.264 / VP8 / MJPEG
视频处理视频拷贝,缩放,色彩空间转换,场视频解交织(Deinterlace)

编译

进入aarch64相应的编译路径

cd mpp/build/linux/aarch64/

修改交叉编译配置文件,指定编译器gcc和g++(一般默认就好)

vim arm.linux.cross.cmake

SDK下的mpp

mpp1

第一段:

/* DRM fd 只用于缓冲分配/取物理地址,不需要 SET_MASTER(避免抢 fb0 显示) */
int drm_fd = open("dev/dri/card0");
if(drm_fd < 0){
	printf("打开drm设备失败\n");
}
//创建drm_mode_create_dumb向内核申请显存
struct drm_mode_create_dumb create;
memset(&create, 0, sizeof(create)); //清空物理内存
//设置要分配的内存大小,长 宽 像素密度 RGB565的bpp是16,NV12是12 alloc_h高度如果是NV12那么高度要*1.5
create.width = w; create.height = h; create.bpp = bpp;
ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &create); //ioctl去分配

struct drm_rockchip_gem_phys gp;
memset(&gp, 0, sizeof(gp));
gp.handle = create.handle; //拿到分配后的句柄
ioctl(drm_fd, DRM_ROCKCHIP_GEM_GET_PHYS, &gp);
// 拿到 32 位物理地址。无 IOMMU 的 RV1106 上,这是 RGA 硬件能直访内存的唯一途径
gp.phy_addr;

struct drm_mode_map_dumb map;
memset(&map, 0, sizeof(map));
map.handle = create.handle;
ioctl(drm_fd, DRM_IOCTL_MODE_MAP_DUMB, &map);
//拿到对应的虚拟内存,用于给应用通过memcpy直接复制使用,应用不能直接拿物理内存数据
int viturlt_addr = mmap(NULL, create.size, PROT_READ | PROT_WRITE, MAP_SHARED,
                 g_drm_fd, map.offset);
                 
// 3. 构造 RGA 输入/输出 buffer 分配内存
rga_buffer_t src = wrapbuffer_physicaladdr((void*)phy_addr, width, height, RK_FORMAT_YCbCr_420_SP);
rga_buffer_t dst = wrapbuffer_physicaladdr((void*)other_phy_addr, out_width, out_height, RK_FORMAT_RGB_565);

第二段:

rga_dma_buf_t g_src;      /* CMA 源缓冲:BGRA 屏幕帧中转 */
rga_dma_buf_t g_dst;      /* CMA 目的缓冲:NV12 转换结果 */

//构造 RGA 输入/输出 buffer
rga_buf_alloc(w, h, RK_FORMAT_BGRA_8888, &g_src);
rga_buf_alloc(w, h, RK_FORMAT_YCbCr_420_SP, &g_dst);

rga_buffer_t sb = rga_wrapbuffer(&g_src, g_w, g_h, RK_FORMAT_BGRA_8888,
                                     src_stride / 4, g_h);
rga_buffer_t db = rga_wrapbuffer(&g_dst, g_w, g_h, RK_FORMAT_YCbCr_420_SP,
                                     NV12_STRIDE, 0);
  • 第一段(物理地址 + memcpy):“事必躬亲”。直接调用 DRM 底层接口(drmModeCreateFB 之类)分配物理连续内存。需要手动记录物理地址(phy_addr)和虚拟地址(mmap 返回值),并且手动执行 memcpy 把源数据填进去。
  • 第二段(rga_dma_buf_t):“封装托管”。调用了 RGA 库自带的 rga_buf_alloc()。这个函数内部帮您完成了“DRM分配 + mmap”的全套流程,并将物理地址、虚拟地址、文件描述符(fd)打包进结构体 rga_dma_buf_t。
typedef struct {
    void* vir_addr;                     /* virtual address */
    void* phy_addr;                     /* physical address */
    int fd;                             /* shared fd */

    int width;                          /* width */
    int height;                         /* height */
    int wstride;                        /* wstride */
    int hstride;                        /* hstride */
    int format;                         /* format */

    int color_space_mode;               /* color_space_mode */
    int global_alpha;                   /* global_alpha, the default should be 0xff */
    int rd_mode;

    /* legacy */
    int color;                          /* color, used by color fill */
    im_colorkey_range colorkey_range;   /* range value of color key */
    im_nn_t nn;
    int rop_code;

    rga_buffer_handle_t handle;         /* buffer handle */
} rga_buffer_t;

Rockit 全部模块的通用套路 Init → Create → Start → Send/Get → Stop → Destroy → Exit,VENC/VPSS/VI/VO 都一样。

RGA (Raster Graphic Acceleration Unit)是一个独立的2D硬件加速器,可用于加速点/线绘制,执行图像缩放、旋转、格式转换等常见的2D图形操作。

本次demo将mipi摄像头,旋转90°并且缩放,YUYV转成RGBA,NV16/12转成RGBA。

1. 依赖的头文件

在SDK下,有文件夹:

/rv1106/media/rga/release_rga_rv1106_arm-rockchip830-linux-uclibcgnueabihf/include/rga

这个文件夹下有RGA所用到的头文件,共 18 个

2. 依赖的库

在SDK下,有文件夹:

/rv1106/media/rga/release_rga_rv1106_arm-rockchip830-linux-uclibcgnueabihf/lib

有两个文件:librga.a,librga.so,一个静态链接一个动态链接

3. 官方案例

在SDK,有文件夹:

/rv1106/media/rga/release_rga_rv1106_arm-rockchip830-linux-uclibcgnueabihf/rga_samples

这里面,各种案例可以借鉴。

4. 依赖到的DRM库

由于rv1106不能通过IOMMU实现连续物理内存分配。所以要通过DRM操作CMA拿到连续的物理内存。

/rv1106/media/libdrm

拿到交叉编译器对应的版本

  • libdrm.so
  • libdrm.so.2

5. 编写测试

流程

传感器(gc2145)
  │ MIPI 信号
  ▼
rkcif 控制器 ──DMA(硬件直写,CPU 不参与)──► V4L2 缓冲3(内核物理内存,960KB)
                                              │ DQBUF:所有权转移(数据原地不动)
                                              ▼
                                          memcpy ①(CPU)
                                              │ 960KB:V4L2 缓冲 → frame_buf(程序内存)
                                              │ (因为缓冲要尽快 QBUF 还给内核)
                                              ▼
                                          memcpy ②(CPU)
                                              │ 960KB:frame_buf → CMA 连续内存(g_src.va)
                                              │ (因为 RGA 硬件需要物理地址,只有
                                              │   DRM 申请的 CMA 缓冲能拿到物理地址)
                                              ▼
                                              
                                          RGA 处理(硬件,CPU 不参与)
                                              │ imcvtcolor(YUYV→RGB565)   sync=1 返回即完成
                                              │ imresize(→BGRA 240×240)   sync=1 返回即完成
                                              │ imrotate(ROT_270)         sync=1 返回即完成
                                              ▼
                                          memcpy ③(CPU)
                                              │ 230KB:CMA 缓冲(g_rot.va)→ canvas_buf
                                              │ (RGA 输出是 CMA 内存,LVGL 画布是普通内存,
                                              │   必须拷;且 sync=1 保证硬件写完才拷)
                                              ▼
                                          LVGL 渲染 → memcpy 到 /dev/fb0 显存 → 屏幕

优化(少一次cpu内存拷贝)

										RGA 处理(硬件)
                                              │ imcvtcolor / imresize / imrotate
                                              │ sync=1 返回即完成
                                              ▼
                              RGA 输出缓冲 = canvas 缓冲(同一块 CMA 内存)
                              g_rot.va 直接传给 lv_canvas_set_buffer()
                                              │
                                              ▼
                                          LVGL 直接读 g_rot.va 渲染 → fb0 → 屏幕

实现

1.drm分配连续的物理内存

/* DRM fd 只用于缓冲分配/取物理地址,不需要 SET_MASTER(避免抢 fb0 显示) */
int drm_fd = open("dev/dri/card0");
if(drm_fd < 0){
	printf("打开drm设备失败\n");
}
//创建drm_mode_create_dumb向内核申请显存
struct drm_mode_create_dumb create;
memset(&create, 0, sizeof(create)); //清空物理内存
//设置要分配的内存大小,长 宽 像素密度 RGB565的bpp是16,NV12是12 alloc_h高度如果是NV12那么高度要*1.5
create.width = w; create.height = h; create.bpp = bpp;
ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &create); //ioctl去分配

2.获取drm分配的物理地址

struct drm_rockchip_gem_phys gp;
memset(&gp, 0, sizeof(gp));
gp.handle = create.handle; //拿到分配后的句柄

ioctl(drm_fd, DRM_ROCKCHIP_GEM_GET_PHYS, &gp);
// 拿到 32 位物理地址。无 IOMMU 的 RV1106 上,这是 RGA 硬件能直访内存的唯一途径
gp.phy_addr;

3.把物理内存地址给到RGA

im_handle_param_t ip;
memset(&ip, 0, sizeof(ip));
//物理内存的大小,以及格式
ip.width  = (uint32_t)width;
ip.height = (uint32_t)height;
ip.format = (uint32_t)format;//格式

//将phy_addr传入,注册返回句柄
rga_buffer_handle_t rga_handle = importbuffer_physicaladdr((uint64_t)phy_addr, &ip);

4.drm_mode_map_dump + mmap虚拟内存

用户态想拿这个物理内存的数据,还是要通过虚拟内存拿的,所以还是要通过mmap拿到对应的虚拟内存地址。

struct drm_mode_map_dumb map;
memset(&map, 0, sizeof(map));

map.handle = create.handle;
ioctl(drm_fd, DRM_IOCTL_MODE_MAP_DUMB, &map);
//拿到对应的虚拟内存,用于给应用通过memcpy直接复制使用,应用不能直接拿物理内存数据
int viturlt_addr = mmap(NULL, create.size, PROT_READ | PROT_WRITE, MAP_SHARED,
                 g_drm_fd, map.offset);

5.使用

这一步是告诉RGA,物理内存地址,大小,然后要进行什么操作,然后写回到内存。

static pthread_mutex_t g_rga_mtx = PTHREAD_MUTEX_INITIALIZER;
// 1. 锁住 RGA
pthread_mutex_lock(&g_rga_mtx);

// 2. 准备输入数据(示例:将一张图像数据拷贝到映射的虚拟内存)
//    假设 input_data 是已经准备好的图像数据指针
memcpy(virt_addr, input_data, create.size);

// 3. 构造 RGA 输入/输出 buffer 分配内存
rga_buffer_t src = wrapbuffer_physicaladdr((void*)phy_addr, width, height, RK_FORMAT_YCbCr_420_SP);
rga_buffer_t dst = wrapbuffer_physicaladdr((void*)other_phy_addr, out_width, out_height, RK_FORMAT_RGB_565);

// 4. 执行 RGA 操作(例如缩放+格式转换)
im_rect src_rect = {0, 0, width, height};
im_rect dst_rect = {0, 0, out_width, out_height};
IM_STATUS ret = improcess(src, dst, {}, {}, src_rect, dst_rect, {}, IM_SYNC);

// 5. 操作完成后,可以从虚拟地址读取结果(如果 dst 也是映射的)
//    或者直接使用输出 buffer 的物理地址供其他硬件使用

// 6. 解锁
pthread_mutex_unlock(&g_rga_mtx);

分配

/* DRM fd 只用于缓冲分配/取物理地址,不需要 SET_MASTER(避免抢 fb0 显示) */
int drm_fd = open("dev/dri/card0");
if(drm_fd < 0){
	printf("打开drm设备失败\n");
}
//创建drm_mode_create_dumb向内核申请显存
struct drm_mode_create_dumb create;
memset(&create, 0, sizeof(create)); //清空物理内存
//设置要分配的内存大小,长 宽 像素密度 RGB565的bpp是16,NV12是12 alloc_h高度如果是NV12那么高度要*1.5
create.width = w; create.height = h; create.bpp = bpp;
ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &create); //ioctl去分配

获取

struct drm_rockchip_gem_phys gp;
memset(&gp, 0, sizeof(gp));
gp.handle = create.handle; //拿到分配后的句柄

ioctl(drm_fd, DRM_ROCKCHIP_GEM_GET_PHYS, &gp);
// 拿到 32 位物理地址。无 IOMMU 的 RV1106 上,这是 RGA 硬件能直访内存的唯一途径
gp.phy_addr;

如何分配的概念

DRM、CMA和DMA的联系,本质上是DRM框架通过GEM内存管理器,根据硬件是否具备IOMMU,来选择使用CMA或SHMEM作为底层内存分配器,以满足不同硬件对内存的连续性需求。

关键角色:硬件显示控制器 (VOP) 与 IOMMU

理解三者的关系,首先要明白瑞芯微的VOP(Video Output Processor),也就是显示控制器。

  • VOP的任务:VOP需要从内存中读取图像数据,然后转换成屏幕信号。
  • 关键变量:IOMMU:VOP读取内存时,是否支持IOMMU(输入输出内存管理单元) 是关键。
    • 有IOMMU:VOP可以通过IOMMU的页表,访问物理上不连续的内存。
    • 无IOMMU:VOP只能直接访问物理地址连续的内存。
联系一:DRM GEM 是“调度中心”

瑞芯微的DRM驱动使用GEM (Graphics Execution Manager) 来管理所有图形内存的分配和生命周期。

  • 当用户应用(如Wayland)通过ioctl请求分配显存时,DRM驱动会调用rockchip_gem_create_object()等函数进行处理。
  • 此时,DRM GEM扮演“决策者”的角色,根据当前平台的具体情况,决定将内存分配任务交给谁。
联系二:GEM 的两种分配策略:CMA vs SHMEM

瑞芯微的GEM驱动会根据有无IOMMU,选择两种不同的底层分配策略:

  1. CMA (Contiguous Memory Allocator) 策略:无IOMMU的选择
    • 适用场景:当VOP没有IOMMU支持时(例如RK3506平台),GEM会使用CMA来分配内存。
    • 工作原理:CMA从系统启动时就预留一大块物理连续的内存池。GEM通过调用CMA的API,从这个池子里申请内存。
    • 硬件需求:这种方式分配的内存,VOP无需IOMMU就能直接通过物理地址访问。
  2. SHMEM 策略:有IOMMU的选择
    • 适用场景:当VOP具备IOMMU支持时(例如RK3588等现代平台),GEM会优先使用基于SHMEM的内存分配方式。
    • 工作原理:SHMEM通过标准的内存页分配机制,获取的是物理上可能不连续的内存页。
    • 硬件需求:VOP需要通过IOMMU的地址映射功能,才能访问这些不连续的内存。

注意:内核中的DRM_GEM_CMA_HELPER和DRM_GEM_DMA_HELPER等术语,提供了实现这些策略的标准化接口。

联系三:CMA 是 DMA 框架的一部分

你需要知道,CMA本身就是Linux内核DMA(Direct Memory Access)框架的一部分。

  • 很多硬件(如摄像头ISP、视频编解码器VPU、2D图形加速器RGA)都需要物理连续的内存才能工作。
  • 因此,DRM GEM在需要连续内存时,实际上是借用了内核DMA框架下的CMA分配器来完成任务。所以,DRM与DMA的联系,通过CMA这个桥梁得以建立。
使用DRM分配两块连续的物理内存,一块放原始数据,一块放处理后的数据?也就是
rga_buffer_t src = wrapbuffer_physicaladdr(phy_addr_in,  w_in,  h_in,  fmt_in);
rga_buffer_t dst = wrapbuffer_physicaladdr(phy_addr_out, w_out, h_out, fmt_out);
录屏操作,也就是拿到RGB数据然后DRM分配一块内存(w*h*2),通过memcpy将RGB565数据写入到物理内存(通过mmap拿到了这个物理内存的虚拟内存就可以用memcpy)。然后DRM再分配一次内存(w*h*1.5),同时mmap拿到这个NV12的虚拟内存。有了这两个物理内存物理地址构建RGA的Buffer。再执行格式转换,将RGB对应的内存里数据给到RGA硬件RGA将这个RGB数据转换后写入到NV12对应的物理内存。然后用户态就可以拿到NV12里面的数据封装成MP4文件。
获取RGB数据 → DRM分配输入buf + mmap → memcpy写入RGB
          → DRM分配输出buf + mmap → 获取两个物理地址
          → 构造RGA src/dst → 加锁 → improcess转换
          → 解锁 → 从输出buf虚拟地址读取NV12 → 封装MP4
  1. 获取 RGB565 数据(例如从屏幕捕获、解码等,数据在 CPU 可访问的内存中)。

  2. DRM 分配输入缓冲区(大小 = width * height * 2,因为 RGB565 每像素 2 字节),并 mmap 得到虚拟地址。

  3. 将 RGB 数据 memcpy 到输入缓冲区的虚拟地址,这样输入缓冲区同时具备物理地址(供 RGA 读取)和虚拟地址(供 CPU 写入)。

  4. DRM 分配输出缓冲区(大小 = width * height * 1.5,NV12 每像素 1.5 字节),并 mmap 得到虚拟地址,供 CPU 后续读取结果。

  5. 获取两个缓冲区的物理地址,构造 RGA 描述符:

    rga_buffer_t src = wrapbuffer_physicaladdr(
        (void*)phy_addr,
        width,
        height,
        format,
        create.pitch,   // 使用 DRM 返回的 pitch 作为 stride
        height          // 垂直 stride 通常等于 height
    );
    rga_buffer_t dst = wrapbuffer_physicaladdr(
        (void*)other_phy_addr,
        c_width,
        c_height,
        format,
        create.pitch,   // 使用 DRM 返回的 pitch 作为 stride
        c_height          // 垂直 stride 通常等于 height
    );
    
  6. 加锁并执行 RGA 转换:

    pthread_mutex_lock(&g_rga_mtx);
    im_rect src_rect = {0, 0, w, h};
    im_rect dst_rect = {0, 0, w, h};
    IM_STATUS ret = improcess(src, dst, {}, {}, src_rect, dst_rect, {}, IM_SYNC);//同步
    pthread_mutex_unlock(&g_rga_mtx);
    
  7. 从输出缓冲区的虚拟地址读取 NV12 数据(可以直接使用指针,或 memcpy 到应用缓冲区),然后封装成 MP4 等。

VENC 模块,即视频编码模块。本模块支持多路实时编码,且每路编码独立,编码协议和编码 profile 可以不同。

  • 初始化 Rockit
//RK_MPI_SYS_Init()在`rk_mpi_sys.h`
if(RK_MPI_SYS_Init() != RK_SUCCESS){  //RK_SUCCESS 是 0
}
  • 创建通道

告诉硬件编 H264、240×320、CBR 800kbps、GOP=25等。

VENC_CHN_ATTR_S chn_attr;
memset(&chn_attr, 0, sizeof(chn_attr));
/*
*	省略配置chn_attr相关参数
*/
RK_S32 ret = RK_MPI_VENC_CreateChn(0, &chn_attr);//第0通道
  • 开启收帧
VENC_RECV_PIC_PARAM_S recv_param;
memset(&recv_param, 0, sizeof(recv_param));
recv_param.s32RecvPicNum = -1;      /* -1 = 持续接收,不限帧数 */

RK_MPI_VENC_StartRecvFrame(0, &recv_param)
  • RGA转换成NV12
rga_dma_buf_t g_src;      /* CMA 源缓冲:BGRA 屏幕帧中转 */
rga_dma_buf_t g_dst;      /* CMA 目的缓冲:NV12 转换结果 */

//构造 RGA 输入/输出 buffer
rga_buf_alloc(w, h, RK_FORMAT_BGRA_8888, &g_src);
rga_buf_alloc(w, h, RK_FORMAT_YCbCr_420_SP, &g_dst);

rga_buffer_t sb = rga_wrapbuffer(&g_src, g_w, g_h, RK_FORMAT_BGRA_8888,
                                     src_stride / 4, g_h);
rga_buffer_t db = rga_wrapbuffer(&g_dst, g_w, g_h, RK_FORMAT_YCbCr_420_SP,
                                     NV12_STRIDE, 0);
                                   
rga_lock(); //加锁
IM_STATUS st = imcvtcolor_t(sb, db, RK_FORMAT_BGRA_8888, RK_FORMAT_YCbCr_420_SP,
                                IM_RGB_TO_YUV_BT601_LIMIT, 1);
rga_unlock();
  • NV12 直接送 VENC

    SendFrame 只是把数据丢进编码器的输入队列,并不会直接返回编码后的数据。

const rga_dma_buf_t *dst = g_dst;//处理后的变成NV12的数据
/* 将 CMA DMA 缓冲包装成 VENC 能识别的 MB_BLK */
MB_EXT_CONFIG_S mb_cfg;
memset(&mb_cfg, 0, sizeof(mb_cfg));
/*
*	配置mb_cfg种种 ...
*/

// CMA NV12 缓冲包装成 MB_BLK
MB_BLK blk = NULL;
RK_S32 ret = RK_MPI_SYS_CreateMB(&blk, &mb_cfg);

/* 填充帧信息 */
VIDEO_FRAME_INFO_S frame_info;
memset(&frame_info, 0, sizeof(frame_info));
/*
*	配置frame_info种种 ...
*/

ret = RK_MPI_VENC_SendFrame(VENC_CHN_ID, &frame_info, 100);

/* 释放 MB_BLK 包装(底层 CMA 缓冲由 rga_buf 管理,不会被释放) */
RK_MPI_MB_ReleaseMB(blk);
  • 取流(GetStream)

    取队列中的数据

// ==============================================
// 接你上面的代码:已经成功 SendFrame 之后
// ==============================================

// 假设你要把编码后的数据存到 test.h264 文件
FILE *fp = fopen("test.h264", "wb");
if (!fp) {
    printf("open file failed\n");
    return -1;
}

// 循环编码,假设编码 1000 帧后退出(实际录屏是 while(1) 直到用户停止)
int frame_count = 0;
while (frame_count < 1000) {
    // 1. 获取编码后的码流包(阻塞等待,直到编码器输出一帧)
    VENC_STREAM_S stream;
    memset(&stream, 0, sizeof(VENC_STREAM_S));
    
    RK_S32 ret = RK_MPI_VENC_GetStream(0, &stream, 100); // 超时 100ms
    if (ret == RK_SUCCESS) {
        // 2. 遍历这一帧可能包含的多个 NALU(H264 分片)
        for (int i = 0; i < stream.u32PackCount; i++) {
            VENC_PACK_S *pkt = &stream.stPack[i];
            
            // 把编码数据写入文件(或通过网络发送)
            fwrite(pkt->pu8Addr, 1, pkt->u32Len, fp);
            fflush(fp); // 录屏建议及时刷新,防止断电丢数据
            
            // 打印调试:这一帧的大小
            printf("编码帧 #%d, 大小: %d bytes\n", frame_count, pkt->u32Len);
        }
        
        // 3. 【极其重要】释放该帧占用的编码器内部 buffer,告诉硬件可以复用这块内存
        RK_MPI_VENC_ReleaseStream(0, &stream);
        
        frame_count++;
    } else if (ret == RK_ERR_VENC_BUF_EMPTY) {
        // 编码器还没产出数据(可能帧率太高,硬件来不及),可以 sleep(1) 或 yield
        usleep(1000);
    } else {
        printf("GetStream 出错: %d\n", ret);
        break;
    }
}

fclose(fp);

整个流程

int main() {
    // 1. 系统初始化
    RK_MPI_SYS_Init();
    
    // 2. 创建编码通道 & 开启收帧
    VENC_CHN_ATTR_S chn_attr = {0};
    // ... 配置 H264、码率、GOP ...
    RK_MPI_VENC_CreateChn(0, &chn_attr);
    RK_MPI_VENC_StartRecvFrame(0, NULL); // recv_param 为 NULL 默认无限收帧
    
    // 3. 初始化 RGA 内存(分配 src 和 dst CMA 缓冲)
    rga_dma_buf_t g_src, g_dst;
    rga_buf_alloc(w, h, RK_FORMAT_BGRA_8888, &g_src);
    rga_buf_alloc(w, h, RK_FORMAT_YCbCr_420_SP, &g_dst);
    
    // 4. 进入主循环(录制中)
    while (running) {
        // 4.1 获取屏幕 RGB 数据到 g_src(通过 drm 或 fb 捕获,略)
        capture_screen_to_rga_buf(&g_src);
        
        // 4.2 RGA 色彩转换:BGRA -> NV12
        rga_buffer_t sb = rga_wrapbuffer(...);
        rga_buffer_t db = rga_wrapbuffer(...);
        rga_lock();
        imcvtcolor_t(sb, db, ...);
        rga_unlock();
        
        // 4.3 构造 MB_BLK(基于 g_dst 的 fd)并 SendFrame
        MB_BLK blk = create_mb_from_dma_fd(g_dst.dma_fd);
        VIDEO_FRAME_INFO_S frame = {0};
        frame.pMbBlk = blk;
        frame.u64PTS = get_current_time_ms();
        RK_MPI_VENC_SendFrame(0, &frame, 100);
        RK_MPI_MB_ReleaseMB(blk); // 立即释放包装
        
        // 4.4 【关键】取编码后的码流
        VENC_STREAM_S stream;
        if (RK_MPI_VENC_GetStream(0, &stream, 50) == RK_SUCCESS) {
            fwrite(stream.stPack[0].pu8Addr, 1, stream.stPack[0].u32Len, fp);
            RK_MPI_VENC_ReleaseStream(0, &stream);
        }
    }
    
    // 5. 销毁(收尾)
    RK_MPI_VENC_StopRecvFrame(0);
    RK_MPI_VENC_DestroyChn(0);
    rga_buf_free(&g_src);
    rga_buf_free(&g_dst);
    RK_MPI_SYS_Exit();
    fclose(fp);
    return 0;
}
#ifndef MINIMP4_H
#define MINIMP4_H
/*
    https://github.com/aspt/mp4
    https://github.com/lieff/minimp4
    To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide.
    This software is distributed without any warranty.
    See <http://creativecommons.org/publicdomain/zero/1.0/>.
*/

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <assert.h>

#ifdef __cplusplus
extern "C" {
#endif

#define MINIMP4_MIN(x, y) ((x) < (y) ? (x) : (y))

/************************************************************************/
/*                  Build configuration                                 */
/************************************************************************/

#define FIX_BAD_ANDROID_META_BOX  1

#define MAX_CHUNKS_DEPTH          64 // Max chunks nesting level

#define MINIMP4_MAX_SPS 32
#define MINIMP4_MAX_PPS 256

#define MINIMP4_TRANSCODE_SPS_ID  1

// Support indexing of MP4 files over 4 GB.
// If disabled, files with 64-bit offset fields is still supported,
// but error signaled if such field contains too big offset
// This switch affect return type of MP4D_frame_offset() function
#define MINIMP4_ALLOW_64BIT       1

#define MP4D_TRACE_SUPPORTED      0 // Debug trace
#define MP4D_TRACE_TIMESTAMPS     1
// Support parsing of supplementary information, not necessary for decoding:
// duration, language, bitrate, metadata tags, etc
#define MP4D_INFO_SUPPORTED       1

// Enable code, which prints to stdout supplementary MP4 information:
#define MP4D_PRINT_INFO_SUPPORTED 0

#define MP4D_AVC_SUPPORTED        1
#define MP4D_HEVC_SUPPORTED       1
#define MP4D_TIMESTAMPS_SUPPORTED 1

// Enable TrackFragmentBaseMediaDecodeTimeBox support
#define MP4D_TFDT_SUPPORT         0

/************************************************************************/
/*          Some values of MP4(E/D)_track_t->object_type_indication     */
/************************************************************************/
// MPEG-4 AAC (all profiles)
#define MP4_OBJECT_TYPE_AUDIO_ISO_IEC_14496_3                  0x40
// MPEG-2 AAC, Main profile
#define MP4_OBJECT_TYPE_AUDIO_ISO_IEC_13818_7_MAIN_PROFILE     0x66
// MPEG-2 AAC, LC profile
#define MP4_OBJECT_TYPE_AUDIO_ISO_IEC_13818_7_LC_PROFILE       0x67
// MPEG-2 AAC, SSR profile
#define MP4_OBJECT_TYPE_AUDIO_ISO_IEC_13818_7_SSR_PROFILE      0x68
// H.264 (AVC) video
#define MP4_OBJECT_TYPE_AVC                                    0x21
// H.265 (HEVC) video
#define MP4_OBJECT_TYPE_HEVC                                   0x23
// http://www.mp4ra.org/object.html 0xC0-E0  && 0xE2 - 0xFE are specified as "user private"
#define MP4_OBJECT_TYPE_USER_PRIVATE                           0xC0

/************************************************************************/
/*          API error codes                                             */
/************************************************************************/
#define MP4E_STATUS_OK                       0
#define MP4E_STATUS_BAD_ARGUMENTS           -1
#define MP4E_STATUS_NO_MEMORY               -2
#define MP4E_STATUS_FILE_WRITE_ERROR        -3
#define MP4E_STATUS_ONLY_ONE_DSI_ALLOWED    -4

/************************************************************************/
/*          Sample kind for MP4E_put_sample()                           */
/************************************************************************/
#define MP4E_SAMPLE_DEFAULT             0   // (beginning of) audio or video frame
#define MP4E_SAMPLE_RANDOM_ACCESS       1   // mark sample as random access point (key frame)
#define MP4E_SAMPLE_CONTINUATION        2   // Not a sample, but continuation of previous sample (new slice)

/************************************************************************/
/*                  Portable 64-bit type definition                     */
/************************************************************************/

#if MINIMP4_ALLOW_64BIT
    typedef uint64_t boxsize_t;
#else
    typedef unsigned int boxsize_t;
#endif
typedef boxsize_t MP4D_file_offset_t;

/************************************************************************/
/*          Some values of MP4D_track_t->handler_type              */
/************************************************************************/
// Video track : 'vide'
#define MP4D_HANDLER_TYPE_VIDE                                  0x76696465
// Audio track : 'soun'
#define MP4D_HANDLER_TYPE_SOUN                                  0x736F756E
// General MPEG-4 systems streams (without specific handler).
// Used for private stream, as suggested in http://www.mp4ra.org/handler.html
#define MP4E_HANDLER_TYPE_GESM                                  0x6765736D


#define HEVC_NAL_VPS 32
#define HEVC_NAL_SPS 33
#define HEVC_NAL_PPS 34
#define HEVC_NAL_BLA_W_LP 16
#define HEVC_NAL_CRA_NUT  21

/************************************************************************/
/*          Data structures                                             */
/************************************************************************/

typedef struct MP4E_mux_tag MP4E_mux_t;

typedef enum
{
    e_audio,
    e_video,
    e_private
} track_media_kind_t;

typedef struct
{
    // MP4 object type code, which defined codec class for the track.
    // See MP4E_OBJECT_TYPE_* values for some codecs
    unsigned object_type_indication;

    // Track language: 3-char ISO 639-2T code: "und", "eng", "rus", "jpn" etc...
    unsigned char language[4];

    track_media_kind_t track_media_kind;

    // 90000 for video, sample rate for audio
    unsigned time_scale;
    unsigned default_duration;

    union
    {
        struct
        {
            // number of channels in the audio track.
            unsigned channelcount;
        } a;

        struct
        {
            int width;
            int height;
        } v;
    } u;

} MP4E_track_t;

typedef struct MP4D_sample_to_chunk_t_tag MP4D_sample_to_chunk_t;

typedef struct
{
    /************************************************************************/
    /*                 mandatory public data                                */
    /************************************************************************/
    // How many 'samples' in the track
    // The 'sample' is MP4 term, denoting audio or video frame
    unsigned sample_count;

    // Decoder-specific info (DSI) data
    unsigned char *dsi;

    // DSI data size
    unsigned dsi_bytes;

    // MP4 object type code
    // case 0x00: return "Forbidden";
    // case 0x01: return "Systems ISO/IEC 14496-1";
    // case 0x02: return "Systems ISO/IEC 14496-1";
    // case 0x20: return "Visual ISO/IEC 14496-2";
    // case 0x40: return "Audio ISO/IEC 14496-3";
    // case 0x60: return "Visual ISO/IEC 13818-2 Simple Profile";
    // case 0x61: return "Visual ISO/IEC 13818-2 Main Profile";
    // case 0x62: return "Visual ISO/IEC 13818-2 SNR Profile";
    // case 0x63: return "Visual ISO/IEC 13818-2 Spatial Profile";
    // case 0x64: return "Visual ISO/IEC 13818-2 High Profile";
    // case 0x65: return "Visual ISO/IEC 13818-2 422 Profile";
    // case 0x66: return "Audio ISO/IEC 13818-7 Main Profile";
    // case 0x67: return "Audio ISO/IEC 13818-7 LC Profile";
    // case 0x68: return "Audio ISO/IEC 13818-7 SSR Profile";
    // case 0x69: return "Audio ISO/IEC 13818-3";
    // case 0x6A: return "Visual ISO/IEC 11172-2";
    // case 0x6B: return "Audio ISO/IEC 11172-3";
    // case 0x6C: return "Visual ISO/IEC 10918-1";
    unsigned object_type_indication;

#if MP4D_INFO_SUPPORTED
    /************************************************************************/
    /*                 informational public data                            */
    /************************************************************************/
    // handler_type when present in a media box, is an integer containing one of
    // the following values, or a value from a derived specification:
    // 'vide' Video track
    // 'soun' Audio track
    // 'hint' Hint track
    unsigned handler_type;

    // Track duration: 64-bit value split into 2 variables
    unsigned duration_hi;
    unsigned duration_lo;

    // duration scale: duration = timescale*seconds
    unsigned timescale;

    // Average bitrate, bits per second
    unsigned avg_bitrate_bps;

    // Track language: 3-char ISO 639-2T code: "und", "eng", "rus", "jpn" etc...
    unsigned char language[4];

    // MP4 stream type
    // case 0x00: return "Forbidden";
    // case 0x01: return "ObjectDescriptorStream";
    // case 0x02: return "ClockReferenceStream";
    // case 0x03: return "SceneDescriptionStream";
    // case 0x04: return "VisualStream";
    // case 0x05: return "AudioStream";
    // case 0x06: return "MPEG7Stream";
    // case 0x07: return "IPMPStream";
    // case 0x08: return "ObjectContentInfoStream";
    // case 0x09: return "MPEGJStream";
    unsigned stream_type;

    union
    {
        // for handler_type == 'soun' tracks
        struct
        {
            unsigned channelcount;
            unsigned samplerate_hz;
        } audio;

        // for handler_type == 'vide' tracks
        struct
        {
            unsigned width;
            unsigned height;
        } video;
    } SampleDescription;
#endif

    /************************************************************************/
    /*                 private data: MP4 indexes                            */
    /************************************************************************/
    unsigned *entry_size;

    unsigned sample_to_chunk_count;
    struct MP4D_sample_to_chunk_t_tag *sample_to_chunk;

    unsigned chunk_count;
    MP4D_file_offset_t *chunk_offset;

    // sample_to_chunk() resume point for the last query, valid whenever a
    // caller queries nsample in non-decreasing order (the only pattern any
    // caller in this codebase uses -- see sample_to_chunk()'s comment).
    unsigned stc_cache_group;
    unsigned stc_cache_nc;
    unsigned stc_cache_sum;

    // MP4D_frame_offset()'s in-chunk byte-offset resume point -- see that
    // function's comment. Independent of stc_cache_* above: this caches
    // the accumulated offset *within whatever chunk the previous query
    // landed in*, which matters even when sample_to_chunk() never has to
    // rescan (e.g. a single giant chunk holding every sample).
    unsigned fo_cache_valid;
    unsigned fo_cache_nsample;
    MP4D_file_offset_t fo_cache_offset;

#if MP4D_TIMESTAMPS_SUPPORTED
    unsigned *timestamp;
    unsigned *duration;
    // Allocated length of timestamp/duration, tracked separately from any
    // publicly exposed sample count: a malformed file's stsz sample_count
    // can exceed the total sample count its own stts box declared, and
    // MP4D_frame_offset() must bound-check against what was actually
    // allocated, not what stsz merely claims.
    unsigned timestamp_count;
#endif

} MP4D_track_t;

typedef struct MP4D_demux_tag
{
    /************************************************************************/
    /*                 mandatory public data                                */
    /************************************************************************/
    int64_t read_pos;
    int64_t read_size;
    MP4D_track_t *track;
    int (*read_callback)(int64_t offset, void *buffer, size_t size, void *token);
    void *token;

    unsigned track_count; // number of tracks in the movie

#if MP4D_INFO_SUPPORTED
    /************************************************************************/
    /*                 informational public data                            */
    /************************************************************************/
    // Movie duration: 64-bit value split into 2 variables
    unsigned duration_hi;
    unsigned duration_lo;

    // duration scale: duration = timescale*seconds
    unsigned timescale;

    // Metadata tag (optional)
    // Tags provided 'as-is', without any re-encoding
    struct
    {
        unsigned char *title;
        unsigned char *artist;
        unsigned char *album;
        unsigned char *year;
        unsigned char *comment;
        unsigned char *genre;
    } tag;
#endif

} MP4D_demux_t;

struct MP4D_sample_to_chunk_t_tag
{
    unsigned first_chunk;
    unsigned samples_per_chunk;
};

typedef struct
{
    void *sps_cache[MINIMP4_MAX_SPS];
    void *pps_cache[MINIMP4_MAX_PPS];
    int sps_bytes[MINIMP4_MAX_SPS];
    int pps_bytes[MINIMP4_MAX_PPS];

    int map_sps[MINIMP4_MAX_SPS];
    int map_pps[MINIMP4_MAX_PPS];

} h264_sps_id_patcher_t;

typedef struct mp4_h26x_writer_tag
{
#if MINIMP4_TRANSCODE_SPS_ID
    h264_sps_id_patcher_t sps_patcher;
#endif
    MP4E_mux_t *mux;
    int mux_track_id, is_hevc, need_vps, need_sps, need_pps, need_idr;
} mp4_h26x_writer_t;

int mp4_h26x_write_init(mp4_h26x_writer_t *h, MP4E_mux_t *mux, int width, int height, int is_hevc);
void mp4_h26x_write_close(mp4_h26x_writer_t *h);
int mp4_h26x_write_nal(mp4_h26x_writer_t *h, const unsigned char *nal, int length, unsigned timeStamp90kHz_next);

/************************************************************************/
/*          API                                                         */
/************************************************************************/

/**
*   Parse given input stream as MP4 file. Allocate and store data indexes.
*   return 1 on success, 0 on failure
*   The MP4 indexes may be stored at the end of stream, so this
*   function may parse all stream.
*   It is guaranteed that function will read/seek sequentially,
*   and will never jump back.
*/
int MP4D_open(MP4D_demux_t *mp4, int (*read_callback)(int64_t offset, void *buffer, size_t size, void *token), void *token, int64_t file_size);

/**
*   Return position and size for given sample from given track. The 'sample' is a
*   MP4 term for 'frame'
*
*   frame_bytes [OUT]   - return coded frame size in bytes
*   timestamp [OUT]     - return frame timestamp (in mp4->timescale units)
*   duration [OUT]      - return frame duration (in mp4->timescale units)
*
*   function return offset for the frame
*/
MP4D_file_offset_t MP4D_frame_offset(const MP4D_demux_t *mp4, unsigned int ntrack, unsigned int nsample, unsigned int *frame_bytes, unsigned *timestamp, unsigned *duration);

/**
*   De-allocated memory
*/
void MP4D_close(MP4D_demux_t *mp4);

/**
*   Helper functions to parse mp4.track[ntrack].dsi for H.264 SPS/PPS
*   Return pointer to internal mp4 memory, it must not be free()-ed
*
*   Example: process all SPS in MP4 file:
*       while (sps = MP4D_read_sps(mp4, num_of_avc_track, sps_count, &sps_bytes))
*       {
*           process(sps, sps_bytes);
*           sps_count++;
*       }
*/
const void *MP4D_read_sps(const MP4D_demux_t *mp4, unsigned int ntrack, int nsps, int *sps_bytes);
const void *MP4D_read_pps(const MP4D_demux_t *mp4, unsigned int ntrack, int npps, int *pps_bytes);

#if MP4D_PRINT_INFO_SUPPORTED
/**
*   Print MP4 information to stdout.
*   Uses printf() as well as floating-point functions
*   Given as implementation example and for test purposes
*/
void MP4D_printf_info(const MP4D_demux_t *mp4);
#endif

/**
*   Allocates and initialize mp4 multiplexor
*   Given file handler is transparent to the MP4 library, and used only as
*   argument for given fwrite_callback() function.  By appropriate definition
*   of callback function application may use any other file output API (for
*   example C++ streams, or Win32 file functions)
*
*   return multiplexor handle on success; NULL on failure
*/
MP4E_mux_t *MP4E_open(int sequential_mode_flag, int enable_fragmentation, void *token,
    int (*write_callback)(int64_t offset, const void *buffer, size_t size, void *token));

/**
*   Add new track
*   The track_data parameter does not referred by the multiplexer after function
*   return, and may be allocated in short-time memory. The dsi member of
*   track_data parameter is mandatory.
*
*   return ID of added track, or error code MP4E_STATUS_*
*/
int MP4E_add_track(MP4E_mux_t *mux, const MP4E_track_t *track_data);

/**
*   Add new sample to specified track
*   The tracks numbered starting with 0, according to order of MP4E_add_track() calls
*   'kind' is one of MP4E_SAMPLE_... defines
*
*   return error code MP4E_STATUS_*
*
*   Example:
*       MP4E_put_sample(mux, 0, data, data_bytes, duration, MP4E_SAMPLE_DEFAULT);
*/
int MP4E_put_sample(MP4E_mux_t *mux, int track_num, const void *data, int data_bytes, int duration, int kind);

/**
*   Finalize MP4 file, de-allocated memory, and closes MP4 multiplexer.
*   The close operation takes a time and disk space, since it writes MP4 file
*   indexes.  Please note that this function does not closes file handle,
*   which was passed to open function.
*
*   return error code MP4E_STATUS_*
*/
int MP4E_close(MP4E_mux_t *mux);

/**
*   Set Decoder Specific Info (DSI)
*   Can be used for audio and private tracks.
*   MUST be used for AAC track.
*   Only one DSI can be set. It is an error to set DSI again
*
*   return error code MP4E_STATUS_*
*/
int MP4E_set_dsi(MP4E_mux_t *mux, int track_id, const void *dsi, int bytes);

/**
*   Set VPS data. MUST be used for HEVC (H.265) track.
*
*   return error code MP4E_STATUS_*
*/
int MP4E_set_vps(MP4E_mux_t *mux, int track_id, const void *vps, int bytes);

/**
*   Set SPS data. MUST be used for AVC (H.264) track. Up to 32 different SPS can be used in one track.
*
*   return error code MP4E_STATUS_*
*/
int MP4E_set_sps(MP4E_mux_t *mux, int track_id, const void *sps, int bytes);

/**
*   Set PPS data. MUST be used for AVC (H.264) track. Up to 256 different PPS can be used in one track.
*
*   return error code MP4E_STATUS_*
*/
int MP4E_set_pps(MP4E_mux_t *mux, int track_id, const void *pps, int bytes);

/**
*   Set or replace ASCII test comment for the file. Set comment to NULL to remove comment.
*
*   return error code MP4E_STATUS_*
*/
int MP4E_set_text_comment(MP4E_mux_t *mux, const char *comment);

#ifdef __cplusplus
}
#endif
#endif //MINIMP4_H

#if defined(MINIMP4_IMPLEMENTATION) && !defined(MINIMP4_IMPLEMENTATION_GUARD)
#define MINIMP4_IMPLEMENTATION_GUARD

#define FOUR_CHAR_INT(a, b, c, d) (((uint32_t)(a) << 24) | ((b) << 16) | ((c) << 8) | (d))
enum
{
    BOX_co64    = FOUR_CHAR_INT( 'c', 'o', '6', '4' ),//ChunkLargeOffsetAtomType
    BOX_stco    = FOUR_CHAR_INT( 's', 't', 'c', 'o' ),//ChunkOffsetAtomType
    BOX_crhd    = FOUR_CHAR_INT( 'c', 'r', 'h', 'd' ),//ClockReferenceMediaHeaderAtomType
    BOX_ctts    = FOUR_CHAR_INT( 'c', 't', 't', 's' ),//CompositionOffsetAtomType
    BOX_cprt    = FOUR_CHAR_INT( 'c', 'p', 'r', 't' ),//CopyrightAtomType
    BOX_url_    = FOUR_CHAR_INT( 'u', 'r', 'l', ' ' ),//DataEntryURLAtomType
    BOX_urn_    = FOUR_CHAR_INT( 'u', 'r', 'n', ' ' ),//DataEntryURNAtomType
    BOX_dinf    = FOUR_CHAR_INT( 'd', 'i', 'n', 'f' ),//DataInformationAtomType
    BOX_dref    = FOUR_CHAR_INT( 'd', 'r', 'e', 'f' ),//DataReferenceAtomType
    BOX_stdp    = FOUR_CHAR_INT( 's', 't', 'd', 'p' ),//DegradationPriorityAtomType
    BOX_edts    = FOUR_CHAR_INT( 'e', 'd', 't', 's' ),//EditAtomType
    BOX_elst    = FOUR_CHAR_INT( 'e', 'l', 's', 't' ),//EditListAtomType
    BOX_uuid    = FOUR_CHAR_INT( 'u', 'u', 'i', 'd' ),//ExtendedAtomType
    BOX_free    = FOUR_CHAR_INT( 'f', 'r', 'e', 'e' ),//FreeSpaceAtomType
    BOX_hdlr    = FOUR_CHAR_INT( 'h', 'd', 'l', 'r' ),//HandlerAtomType
    BOX_hmhd    = FOUR_CHAR_INT( 'h', 'm', 'h', 'd' ),//HintMediaHeaderAtomType
    BOX_hint    = FOUR_CHAR_INT( 'h', 'i', 'n', 't' ),//HintTrackReferenceAtomType
    BOX_mdia    = FOUR_CHAR_INT( 'm', 'd', 'i', 'a' ),//MediaAtomType
    BOX_mdat    = FOUR_CHAR_INT( 'm', 'd', 'a', 't' ),//MediaDataAtomType
    BOX_mdhd    = FOUR_CHAR_INT( 'm', 'd', 'h', 'd' ),//MediaHeaderAtomType
    BOX_minf    = FOUR_CHAR_INT( 'm', 'i', 'n', 'f' ),//MediaInformationAtomType
    BOX_moov    = FOUR_CHAR_INT( 'm', 'o', 'o', 'v' ),//MovieAtomType
    BOX_mvhd    = FOUR_CHAR_INT( 'm', 'v', 'h', 'd' ),//MovieHeaderAtomType
    BOX_stsd    = FOUR_CHAR_INT( 's', 't', 's', 'd' ),//SampleDescriptionAtomType
    BOX_stsz    = FOUR_CHAR_INT( 's', 't', 's', 'z' ),//SampleSizeAtomType
    BOX_stz2    = FOUR_CHAR_INT( 's', 't', 'z', '2' ),//CompactSampleSizeAtomType
    BOX_stbl    = FOUR_CHAR_INT( 's', 't', 'b', 'l' ),//SampleTableAtomType
    BOX_stsc    = FOUR_CHAR_INT( 's', 't', 's', 'c' ),//SampleToChunkAtomType
    BOX_stsh    = FOUR_CHAR_INT( 's', 't', 's', 'h' ),//ShadowSyncAtomType
    BOX_skip    = FOUR_CHAR_INT( 's', 'k', 'i', 'p' ),//SkipAtomType
    BOX_smhd    = FOUR_CHAR_INT( 's', 'm', 'h', 'd' ),//SoundMediaHeaderAtomType
    BOX_stss    = FOUR_CHAR_INT( 's', 't', 's', 's' ),//SyncSampleAtomType
    BOX_stts    = FOUR_CHAR_INT( 's', 't', 't', 's' ),//TimeToSampleAtomType
    BOX_trak    = FOUR_CHAR_INT( 't', 'r', 'a', 'k' ),//TrackAtomType
    BOX_tkhd    = FOUR_CHAR_INT( 't', 'k', 'h', 'd' ),//TrackHeaderAtomType
    BOX_tref    = FOUR_CHAR_INT( 't', 'r', 'e', 'f' ),//TrackReferenceAtomType
    BOX_udta    = FOUR_CHAR_INT( 'u', 'd', 't', 'a' ),//UserDataAtomType
    BOX_vmhd    = FOUR_CHAR_INT( 'v', 'm', 'h', 'd' ),//VideoMediaHeaderAtomType
    BOX_url     = FOUR_CHAR_INT( 'u', 'r', 'l', ' ' ),
    BOX_urn     = FOUR_CHAR_INT( 'u', 'r', 'n', ' ' ),

    BOX_gnrv    = FOUR_CHAR_INT( 'g', 'n', 'r', 'v' ),//GenericVisualSampleEntryAtomType
    BOX_gnra    = FOUR_CHAR_INT( 'g', 'n', 'r', 'a' ),//GenericAudioSampleEntryAtomType

    //V2 atoms
    BOX_ftyp    = FOUR_CHAR_INT( 'f', 't', 'y', 'p' ),//FileTypeAtomType
    BOX_padb    = FOUR_CHAR_INT( 'p', 'a', 'd', 'b' ),//PaddingBitsAtomType

    //MP4 Atoms
    BOX_sdhd    = FOUR_CHAR_INT( 's', 'd', 'h', 'd' ),//SceneDescriptionMediaHeaderAtomType
    BOX_dpnd    = FOUR_CHAR_INT( 'd', 'p', 'n', 'd' ),//StreamDependenceAtomType
    BOX_iods    = FOUR_CHAR_INT( 'i', 'o', 'd', 's' ),//ObjectDescriptorAtomType
    BOX_odhd    = FOUR_CHAR_INT( 'o', 'd', 'h', 'd' ),//ObjectDescriptorMediaHeaderAtomType
    BOX_mpod    = FOUR_CHAR_INT( 'm', 'p', 'o', 'd' ),//ODTrackReferenceAtomType
    BOX_nmhd    = FOUR_CHAR_INT( 'n', 'm', 'h', 'd' ),//MPEGMediaHeaderAtomType
    BOX_esds    = FOUR_CHAR_INT( 'e', 's', 'd', 's' ),//ESDAtomType
    BOX_sync    = FOUR_CHAR_INT( 's', 'y', 'n', 'c' ),//OCRReferenceAtomType
    BOX_ipir    = FOUR_CHAR_INT( 'i', 'p', 'i', 'r' ),//IPIReferenceAtomType
    BOX_mp4s    = FOUR_CHAR_INT( 'm', 'p', '4', 's' ),//MPEGSampleEntryAtomType
    BOX_mp4a    = FOUR_CHAR_INT( 'm', 'p', '4', 'a' ),//MPEGAudioSampleEntryAtomType
    BOX_mp4v    = FOUR_CHAR_INT( 'm', 'p', '4', 'v' ),//MPEGVisualSampleEntryAtomType

    // http://www.itscj.ipsj.or.jp/sc29/open/29view/29n7644t.doc
    BOX_avc1    = FOUR_CHAR_INT( 'a', 'v', 'c', '1' ),
    BOX_avc2    = FOUR_CHAR_INT( 'a', 'v', 'c', '2' ),
    BOX_svc1    = FOUR_CHAR_INT( 's', 'v', 'c', '1' ),
    BOX_avcC    = FOUR_CHAR_INT( 'a', 'v', 'c', 'C' ),
    BOX_svcC    = FOUR_CHAR_INT( 's', 'v', 'c', 'C' ),
    BOX_btrt    = FOUR_CHAR_INT( 'b', 't', 'r', 't' ),
    BOX_m4ds    = FOUR_CHAR_INT( 'm', '4', 'd', 's' ),
    BOX_seib    = FOUR_CHAR_INT( 's', 'e', 'i', 'b' ),

    // H264/HEVC
    BOX_hev1    = FOUR_CHAR_INT( 'h', 'e', 'v', '1' ),
    BOX_hvc1    = FOUR_CHAR_INT( 'h', 'v', 'c', '1' ),
    BOX_hvcC    = FOUR_CHAR_INT( 'h', 'v', 'c', 'C' ),

    //3GPP atoms
    BOX_samr    = FOUR_CHAR_INT( 's', 'a', 'm', 'r' ),//AMRSampleEntryAtomType
    BOX_sawb    = FOUR_CHAR_INT( 's', 'a', 'w', 'b' ),//WB_AMRSampleEntryAtomType
    BOX_damr    = FOUR_CHAR_INT( 'd', 'a', 'm', 'r' ),//AMRConfigAtomType
    BOX_s263    = FOUR_CHAR_INT( 's', '2', '6', '3' ),//H263SampleEntryAtomType
    BOX_d263    = FOUR_CHAR_INT( 'd', '2', '6', '3' ),//H263ConfigAtomType

    //V2 atoms - Movie Fragments
    BOX_mvex    = FOUR_CHAR_INT( 'm', 'v', 'e', 'x' ),//MovieExtendsAtomType
    BOX_trex    = FOUR_CHAR_INT( 't', 'r', 'e', 'x' ),//TrackExtendsAtomType
    BOX_moof    = FOUR_CHAR_INT( 'm', 'o', 'o', 'f' ),//MovieFragmentAtomType
    BOX_mfhd    = FOUR_CHAR_INT( 'm', 'f', 'h', 'd' ),//MovieFragmentHeaderAtomType
    BOX_traf    = FOUR_CHAR_INT( 't', 'r', 'a', 'f' ),//TrackFragmentAtomType
    BOX_tfhd    = FOUR_CHAR_INT( 't', 'f', 'h', 'd' ),//TrackFragmentHeaderAtomType
    BOX_tfdt    = FOUR_CHAR_INT( 't', 'f', 'd', 't' ),//TrackFragmentBaseMediaDecodeTimeBox
    BOX_trun    = FOUR_CHAR_INT( 't', 'r', 'u', 'n' ),//TrackFragmentRunAtomType
    BOX_mehd    = FOUR_CHAR_INT( 'm', 'e', 'h', 'd' ),//MovieExtendsHeaderBox

    // Object Descriptors (OD) data coding
    // These takes only 1 byte; this implementation translate <od_tag> to
    // <od_tag> + OD_BASE to keep API uniform and safe for string functions
    OD_BASE    = FOUR_CHAR_INT( '$', '$', '$', '0' ),//
    OD_ESD     = FOUR_CHAR_INT( '$', '$', '$', '3' ),//SDescriptor_Tag
    OD_DCD     = FOUR_CHAR_INT( '$', '$', '$', '4' ),//DecoderConfigDescriptor_Tag
    OD_DSI     = FOUR_CHAR_INT( '$', '$', '$', '5' ),//DecoderSpecificInfo_Tag
    OD_SLC     = FOUR_CHAR_INT( '$', '$', '$', '6' ),//SLConfigDescriptor_Tag

    BOX_meta   = FOUR_CHAR_INT( 'm', 'e', 't', 'a' ),
    BOX_ilst   = FOUR_CHAR_INT( 'i', 'l', 's', 't' ),

    // Metagata tags, see http://atomicparsley.sourceforge.net/mpeg-4files.html
    BOX_calb    = FOUR_CHAR_INT( '\xa9', 'a', 'l', 'b'),    // album
    BOX_cart    = FOUR_CHAR_INT( '\xa9', 'a', 'r', 't'),    // artist
    BOX_aART    = FOUR_CHAR_INT( 'a', 'A', 'R', 'T' ),      // album artist
    BOX_ccmt    = FOUR_CHAR_INT( '\xa9', 'c', 'm', 't'),    // comment
    BOX_cday    = FOUR_CHAR_INT( '\xa9', 'd', 'a', 'y'),    // year (as string)
    BOX_cnam    = FOUR_CHAR_INT( '\xa9', 'n', 'a', 'm'),    // title
    BOX_cgen    = FOUR_CHAR_INT( '\xa9', 'g', 'e', 'n'),    // custom genre (as string or as byte!)
    BOX_trkn    = FOUR_CHAR_INT( 't', 'r', 'k', 'n'),       // track number (byte)
    BOX_disk    = FOUR_CHAR_INT( 'd', 'i', 's', 'k'),       // disk number (byte)
    BOX_cwrt    = FOUR_CHAR_INT( '\xa9', 'w', 'r', 't'),    // composer
    BOX_ctoo    = FOUR_CHAR_INT( '\xa9', 't', 'o', 'o'),    // encoder
    BOX_tmpo    = FOUR_CHAR_INT( 't', 'm', 'p', 'o'),       // bpm (byte)
    BOX_cpil    = FOUR_CHAR_INT( 'c', 'p', 'i', 'l'),       // compilation (byte)
    BOX_covr    = FOUR_CHAR_INT( 'c', 'o', 'v', 'r'),       // cover art (JPEG/PNG)
    BOX_rtng    = FOUR_CHAR_INT( 'r', 't', 'n', 'g'),       // rating/advisory (byte)
    BOX_cgrp    = FOUR_CHAR_INT( '\xa9', 'g', 'r', 'p'),    // grouping
    BOX_stik    = FOUR_CHAR_INT( 's', 't', 'i', 'k'),       // stik (byte)  0 = Movie   1 = Normal  2 = Audiobook  5 = Whacked Bookmark  6 = Music Video  9 = Short Film  10 = TV Show  11 = Booklet  14 = Ringtone
    BOX_pcst    = FOUR_CHAR_INT( 'p', 'c', 's', 't'),       // podcast (byte)
    BOX_catg    = FOUR_CHAR_INT( 'c', 'a', 't', 'g'),       // category
    BOX_keyw    = FOUR_CHAR_INT( 'k', 'e', 'y', 'w'),       // keyword
    BOX_purl    = FOUR_CHAR_INT( 'p', 'u', 'r', 'l'),       // podcast URL (byte)
    BOX_egid    = FOUR_CHAR_INT( 'e', 'g', 'i', 'd'),       // episode global unique ID (byte)
    BOX_desc    = FOUR_CHAR_INT( 'd', 'e', 's', 'c'),       // description
    BOX_clyr    = FOUR_CHAR_INT( '\xa9', 'l', 'y', 'r'),    // lyrics (may be > 255 bytes)
    BOX_tven    = FOUR_CHAR_INT( 't', 'v', 'e', 'n'),       // tv episode number
    BOX_tves    = FOUR_CHAR_INT( 't', 'v', 'e', 's'),       // tv episode (byte)
    BOX_tvnn    = FOUR_CHAR_INT( 't', 'v', 'n', 'n'),       // tv network name
    BOX_tvsh    = FOUR_CHAR_INT( 't', 'v', 's', 'h'),       // tv show name
    BOX_tvsn    = FOUR_CHAR_INT( 't', 'v', 's', 'n'),       // tv season (byte)
    BOX_purd    = FOUR_CHAR_INT( 'p', 'u', 'r', 'd'),       // purchase date
    BOX_pgap    = FOUR_CHAR_INT( 'p', 'g', 'a', 'p'),       // Gapless Playback (byte)

    //BOX_aart   = FOUR_CHAR_INT( 'a', 'a', 'r', 't' ),     // Album artist
    BOX_cART    = FOUR_CHAR_INT( '\xa9', 'A', 'R', 'T'),    // artist
    BOX_gnre    = FOUR_CHAR_INT( 'g', 'n', 'r', 'e'),

    // 3GPP metatags  (http://cpansearch.perl.org/src/JHAR/MP4-Info-1.12/Info.pm)
    BOX_auth    = FOUR_CHAR_INT( 'a', 'u', 't', 'h'),       // author
    BOX_titl    = FOUR_CHAR_INT( 't', 'i', 't', 'l'),       // title
    BOX_dscp    = FOUR_CHAR_INT( 'd', 's', 'c', 'p'),       // description
    BOX_perf    = FOUR_CHAR_INT( 'p', 'e', 'r', 'f'),       // performer
    BOX_mean    = FOUR_CHAR_INT( 'm', 'e', 'a', 'n'),       //
    BOX_name    = FOUR_CHAR_INT( 'n', 'a', 'm', 'e'),       //
    BOX_data    = FOUR_CHAR_INT( 'd', 'a', 't', 'a'),       //

    // these from http://lists.mplayerhq.hu/pipermail/ffmpeg-devel/2008-September/053151.html
    BOX_albm    = FOUR_CHAR_INT( 'a', 'l', 'b', 'm'),      // album
    BOX_yrrc    = FOUR_CHAR_INT( 'y', 'r', 'r', 'c')       // album
};

// Video track : 'vide'
#define MP4E_HANDLER_TYPE_VIDE                                  0x76696465
// Audio track : 'soun'
#define MP4E_HANDLER_TYPE_SOUN                                  0x736F756E
// General MPEG-4 systems streams (without specific handler).
// Used for private stream, as suggested in http://www.mp4ra.org/handler.html
#define MP4E_HANDLER_TYPE_GESM                                  0x6765736D

typedef struct
{
    boxsize_t size;
    boxsize_t offset;
    unsigned duration;
    unsigned flag_random_access;
} sample_t;

typedef struct {
    unsigned char *data;
    int bytes;
    int capacity;
} minimp4_vector_t;

typedef struct
{
    MP4E_track_t info;
    minimp4_vector_t smpl;  // sample descriptor
    minimp4_vector_t pending_sample;

    minimp4_vector_t vsps;  // or dsi for audio
    minimp4_vector_t vpps;  // not used for audio
    minimp4_vector_t vvps;  // used for HEVC

} track_t;

typedef struct MP4E_mux_tag
{
    minimp4_vector_t tracks;

    int64_t write_pos;
    int (*write_callback)(int64_t offset, const void *buffer, size_t size, void *token);
    void *token;
    char *text_comment;

    int sequential_mode_flag;
    int enable_fragmentation; // flag, indicating streaming-friendly 'fragmentation' mode
    int fragments_count;      // # of fragments in 'fragmentation' mode

} MP4E_mux_t;

static const unsigned char box_ftyp[] = {
#if 1
    0,0,0,0x18,'f','t','y','p',
    'm','p','4','2',0,0,0,0,
    'm','p','4','2','i','s','o','m',
#else
    // as in ffmpeg
    0,0,0,0x20,'f','t','y','p',
    'i','s','o','m',0,0,2,0,
    'm','p','4','1','i','s','o','m',
    'i','s','o','2','a','v','c','1',
#endif
};

/**
*   Endian-independent byte-write macros
*/
#define WR(x, n) *p++ = (unsigned char)((x) >> 8*n)
#define WRITE_1(x) WR(x, 0);
#define WRITE_2(x) WR(x, 1); WR(x, 0);
#define WRITE_3(x) WR(x, 2); WR(x, 1); WR(x, 0);
#define WRITE_4(x) WR(x, 3); WR(x, 2); WR(x, 1); WR(x, 0);
#define WR4(p, x) (p)[0] = (char)((x) >> 8*3); (p)[1] = (char)((x) >> 8*2); (p)[2] = (char)((x) >> 8*1); (p)[3] = (char)((x));

// Finish atom: update atom size field
#define END_ATOM --stack; WR4((unsigned char*)*stack, p - *stack);

// Initiate atom: save position of size field on stack
#define ATOM(x)  *stack++ = p; p += 4; WRITE_4(x);

// Atom with 'FullAtomVersionFlags' field
#define ATOM_FULL(x, flag)  ATOM(x); WRITE_4(flag);

#define ERR(func) { int err = func; if (err) return err; }

/**
    Allocate vector with given size, return 1 on success, 0 on fail
*/
static int minimp4_vector_init(minimp4_vector_t *h, int capacity)
{
    h->bytes = 0;
    h->capacity = capacity;
    h->data = capacity ? (unsigned char *)malloc(capacity) : NULL;
    return !capacity || !!h->data;
}

/**
    Deallocates vector memory
*/
static void minimp4_vector_reset(minimp4_vector_t *h)
{
    if (h->data)
        free(h->data);
    memset(h, 0, sizeof(minimp4_vector_t));
}

/**
    Reallocate vector memory to the given size
*/
static int minimp4_vector_grow(minimp4_vector_t *h, int bytes)
{
    void *p;
    int new_size = h->capacity*2 + 1024;
    if (new_size < h->capacity + bytes)
        new_size = h->capacity + bytes + 1024;
    p = realloc(h->data, new_size);
    if (!p)
        return 0;
    h->data = (unsigned char*)p;
    h->capacity = new_size;
    return 1;
}

/**
    Allocates given number of bytes at the end of vector data, increasing
    vector memory if necessary.
    Return allocated memory.
*/
static unsigned char *minimp4_vector_alloc_tail(minimp4_vector_t *h, int bytes)
{
    unsigned char *p;
    if (!h->data && !minimp4_vector_init(h, 2*bytes + 1024))
        return NULL;
    if ((h->capacity - h->bytes) < bytes && !minimp4_vector_grow(h, bytes))
        return NULL;
    assert(h->data);
    assert((h->capacity - h->bytes) >= bytes);
    p = h->data + h->bytes;
    h->bytes += bytes;
    return p;
}

/**
    Append data to the end of the vector (accumulate ot enqueue)
*/
static unsigned char *minimp4_vector_put(minimp4_vector_t *h, const void *buf, int bytes)
{
    unsigned char *tail = minimp4_vector_alloc_tail(h, bytes);
    if (tail)
        memcpy(tail, buf, bytes);
    return tail;
}

/**
*   Allocates and initialize mp4 multiplexer
*   return multiplexor handle on success; NULL on failure
*/
MP4E_mux_t *MP4E_open(int sequential_mode_flag, int enable_fragmentation, void *token,
    int (*write_callback)(int64_t offset, const void *buffer, size_t size, void *token))
{
    if (write_callback(0, box_ftyp, sizeof(box_ftyp), token)) // Write fixed header: 'ftyp' box
        return 0;
    MP4E_mux_t *mux = (MP4E_mux_t*)malloc(sizeof(MP4E_mux_t));
    if (!mux)
        return mux;
    mux->sequential_mode_flag = sequential_mode_flag || enable_fragmentation;
    mux->enable_fragmentation = enable_fragmentation;
    mux->fragments_count = 0;
    mux->write_callback = write_callback;
    mux->token = token;
    mux->text_comment = NULL;
    mux->write_pos = sizeof(box_ftyp);

    if (!mux->sequential_mode_flag)
    {   // Write filler, which would be updated later
        if (mux->write_callback(mux->write_pos, box_ftyp, 8, mux->token))
        {
            free(mux);
            return 0;
        }
        mux->write_pos += 16; // box_ftyp + box_free for 32bit or 64bit size encoding
    }
    minimp4_vector_init(&mux->tracks, 2*sizeof(track_t));
    return mux;
}

/**
*   Add new track
*/
int MP4E_add_track(MP4E_mux_t *mux, const MP4E_track_t *track_data)
{
    track_t *tr;
    int ntr = mux->tracks.bytes / sizeof(track_t);

    if (!mux || !track_data)
        return MP4E_STATUS_BAD_ARGUMENTS;

    tr = (track_t*)minimp4_vector_alloc_tail(&mux->tracks, sizeof(track_t));
    if (!tr)
        return MP4E_STATUS_NO_MEMORY;
    memset(tr, 0, sizeof(track_t));
    memcpy(&tr->info, track_data, sizeof(*track_data));
    if (!minimp4_vector_init(&tr->smpl, 256))
        return MP4E_STATUS_NO_MEMORY;
    minimp4_vector_init(&tr->vsps, 0);
    minimp4_vector_init(&tr->vpps, 0);
    minimp4_vector_init(&tr->pending_sample, 0);
    return ntr;
}

static const unsigned char *next_dsi(const unsigned char *p, const unsigned char *end, int *bytes)
{
    if (p < end + 2)
    {
        *bytes = p[0]*256 + p[1];
        return p + 2;
    } else
        return NULL;
}

static int append_mem(minimp4_vector_t *v, const void *mem, int bytes)
{
    int i;
    unsigned char size[2];
    const unsigned char *p = v->data;
    for (i = 0; i + 2 < v->bytes;)
    {
        int cb = p[i]*256 + p[i + 1];
        if (cb == bytes && !memcmp(p + i + 2, mem, cb))
            return 1;
        i += 2 + cb;
    }
    size[0] = bytes >> 8;
    size[1] = bytes;
    return minimp4_vector_put(v, size, 2) && minimp4_vector_put(v, mem, bytes);
}

static int items_count(minimp4_vector_t *v)
{
    int i, count = 0;
    const unsigned char *p = v->data;
    for (i = 0; i + 2 < v->bytes;)
    {
        int cb = p[i]*256 + p[i + 1];
        count++;
        i += 2 + cb;
    }
    return count;
}

int MP4E_set_dsi(MP4E_mux_t *mux, int track_id, const void *dsi, int bytes)
{
    track_t* tr = ((track_t*)mux->tracks.data) + track_id;
    assert(tr->info.track_media_kind == e_audio ||
           tr->info.track_media_kind == e_private);
    if (tr->vsps.bytes)
        return MP4E_STATUS_ONLY_ONE_DSI_ALLOWED;   // only one DSI allowed
    return append_mem(&tr->vsps, dsi, bytes) ? MP4E_STATUS_OK : MP4E_STATUS_NO_MEMORY;
}

int MP4E_set_vps(MP4E_mux_t *mux, int track_id, const void *vps, int bytes)
{
    track_t* tr = ((track_t*)mux->tracks.data) + track_id;
    assert(tr->info.track_media_kind == e_video);
    return append_mem(&tr->vvps, vps, bytes) ? MP4E_STATUS_OK : MP4E_STATUS_NO_MEMORY;
}

int MP4E_set_sps(MP4E_mux_t *mux, int track_id, const void *sps, int bytes)
{
    track_t* tr = ((track_t*)mux->tracks.data) + track_id;
    assert(tr->info.track_media_kind == e_video);
    return append_mem(&tr->vsps, sps, bytes) ? MP4E_STATUS_OK : MP4E_STATUS_NO_MEMORY;
}

int MP4E_set_pps(MP4E_mux_t *mux, int track_id, const void *pps, int bytes)
{
    track_t* tr = ((track_t*)mux->tracks.data) + track_id;
    assert(tr->info.track_media_kind == e_video);
    return append_mem(&tr->vpps, pps, bytes) ? MP4E_STATUS_OK : MP4E_STATUS_NO_MEMORY;
}

static unsigned get_duration(const track_t *tr)
{
    unsigned i, sum_duration = 0;
    const sample_t *s = (const sample_t *)tr->smpl.data;
    for (i = 0; i < tr->smpl.bytes/sizeof(sample_t); i++)
    {
        sum_duration += s[i].duration;
    }
    return sum_duration;
}

static int write_pending_data(MP4E_mux_t *mux, track_t *tr)
{
    // if have pending sample && have at least one sample in the index
    if (tr->pending_sample.bytes > 0 && tr->smpl.bytes >= sizeof(sample_t))
    {
        // Complete pending sample
        sample_t *smpl_desc;
        unsigned char base[8], *p = base;

        assert(mux->sequential_mode_flag);

        // Write each sample to a separate atom
        assert(mux->sequential_mode_flag);      // Separate atom needed for sequential_mode only
        WRITE_4(tr->pending_sample.bytes + 8);
        WRITE_4(BOX_mdat);
        ERR(mux->write_callback(mux->write_pos, base, p - base, mux->token));
        mux->write_pos += p - base;

        // Update sample descriptor with size and offset
        smpl_desc = ((sample_t*)minimp4_vector_alloc_tail(&tr->smpl, 0)) - 1;
        smpl_desc->size = tr->pending_sample.bytes;
        smpl_desc->offset = (boxsize_t)mux->write_pos;

        // Write data
        ERR(mux->write_callback(mux->write_pos, tr->pending_sample.data, tr->pending_sample.bytes, mux->token));
        mux->write_pos += tr->pending_sample.bytes;

        // reset buffer
        tr->pending_sample.bytes = 0;
    }
    return MP4E_STATUS_OK;
}

static int add_sample_descriptor(MP4E_mux_t *mux, track_t *tr, int data_bytes, int duration, int kind)
{
    sample_t smp;
    smp.size = data_bytes;
    smp.offset = (boxsize_t)mux->write_pos;
    smp.duration = (duration ? duration : tr->info.default_duration);
    smp.flag_random_access = (kind == MP4E_SAMPLE_RANDOM_ACCESS);
    return NULL != minimp4_vector_put(&tr->smpl, &smp, sizeof(sample_t));
}

static int mp4e_flush_index(MP4E_mux_t *mux);

/**
*   Write Movie Fragment: 'moof' box
*/
static int mp4e_write_fragment_header(MP4E_mux_t *mux, int track_num, int data_bytes, int duration, int kind
#if MP4D_TFDT_SUPPORT
, uint64_t timestamp
#endif
)
{
    unsigned char base[888], *p = base;
    unsigned char *stack_base[20]; // atoms nesting stack
    unsigned char **stack = stack_base;
    unsigned char *pdata_offset;
    unsigned flags;
    enum
    {
        default_sample_duration_present = 0x000008,
        default_sample_flags_present = 0x000020,
    } e;

    track_t *tr = ((track_t*)mux->tracks.data) + track_num;

    ATOM(BOX_moof)
        ATOM_FULL(BOX_mfhd, 0)
            WRITE_4(mux->fragments_count);  // start from 1
        END_ATOM
        ATOM(BOX_traf)
            flags = 0;
            if (tr->info.track_media_kind == e_video)
                flags |= 0x20;          // default-sample-flags-present
            else
                flags |= 0x08;          // default-sample-duration-present
            flags =  (tr->info.track_media_kind == e_video) ? 0x20020 : 0x20008;

            ATOM_FULL(BOX_tfhd, flags)
                WRITE_4(track_num + 1); // track_ID
                if (tr->info.track_media_kind == e_video)
                {
                    WRITE_4(0x1010000); // default_sample_flags
                } else
                {
                    WRITE_4(duration);
                }
            END_ATOM
            #if MP4D_TFDT_SUPPORT
            ATOM_FULL(BOX_tfdt, 0x01000000) // version 1
                WRITE_4(timestamp >> 32); // upper timestamp
                WRITE_4(timestamp & 0xffffffff); // lower timestamp
            END_ATOM
            #endif
            if (tr->info.track_media_kind == e_audio)
            {
                flags  = 0;
                flags |= 0x001;         // data-offset-present
                flags |= 0x200;         // sample-size-present
                ATOM_FULL(BOX_trun, flags)
                    WRITE_4(1);         // sample_count
                    pdata_offset = p; p += 4;  // save ptr to data_offset
                    WRITE_4(data_bytes);// sample_size
                END_ATOM
            } else if (kind == MP4E_SAMPLE_RANDOM_ACCESS)
            {
                flags  = 0;
                flags |= 0x001;         // data-offset-present
                flags |= 0x004;         // first-sample-flags-present
                flags |= 0x100;         // sample-duration-present
                flags |= 0x200;         // sample-size-present
                ATOM_FULL(BOX_trun, flags)
                    WRITE_4(1);         // sample_count
                    pdata_offset = p; p += 4;   // save ptr to data_offset
                    WRITE_4(0x2000000); // first_sample_flags
                    WRITE_4(duration);  // sample_duration
                    WRITE_4(data_bytes);// sample_size
                END_ATOM
            } else
            {
                flags  = 0;
                flags |= 0x001;         // data-offset-present
                flags |= 0x100;         // sample-duration-present
                flags |= 0x200;         // sample-size-present
                ATOM_FULL(BOX_trun, flags)
                    WRITE_4(1);         // sample_count
                    pdata_offset = p; p += 4;   // save ptr to data_offset
                    WRITE_4(duration);  // sample_duration
                    WRITE_4(data_bytes);// sample_size
                END_ATOM
            }
        END_ATOM
    END_ATOM
    WR4(pdata_offset, (p - base) + 8);

    ERR(mux->write_callback(mux->write_pos, base, p - base, mux->token));
    mux->write_pos += p - base;
    return MP4E_STATUS_OK;
}

static int mp4e_write_mdat_box(MP4E_mux_t *mux, uint32_t size)
{
    unsigned char base[8], *p = base;
    WRITE_4(size);
    WRITE_4(BOX_mdat);
    ERR(mux->write_callback(mux->write_pos, base, p - base, mux->token));
    mux->write_pos += p - base;
    return MP4E_STATUS_OK;
}

/**
*   Add new sample to specified track
*/
int MP4E_put_sample(MP4E_mux_t *mux, int track_num, const void *data, int data_bytes, int duration, int kind)
{
    track_t *tr;
    if (!mux || !data)
        return MP4E_STATUS_BAD_ARGUMENTS;
    tr = ((track_t*)mux->tracks.data) + track_num;

    if (mux->enable_fragmentation)
    {
        #if MP4D_TFDT_SUPPORT
        // NOTE: assume a constant `duration` to calculate current timestamp
        uint64_t timestamp = (uint64_t)mux->fragments_count * duration;
        #endif
        if (!mux->fragments_count++)
            ERR(mp4e_flush_index(mux)); // write file headers before 1st sample
        // write MOOF + MDAT + sample data
        #if MP4D_TFDT_SUPPORT
        ERR(mp4e_write_fragment_header(mux, track_num, data_bytes, duration, kind, timestamp));
        #else
        ERR(mp4e_write_fragment_header(mux, track_num, data_bytes, duration, kind));
        #endif
        // write MDAT box for each sample
        ERR(mp4e_write_mdat_box(mux, data_bytes + 8));
        ERR(mux->write_callback(mux->write_pos, data, data_bytes, mux->token));
        mux->write_pos += data_bytes;
        return MP4E_STATUS_OK;
    }

    if (kind != MP4E_SAMPLE_CONTINUATION)
    {
        if (mux->sequential_mode_flag)
            ERR(write_pending_data(mux, tr));
        if (!add_sample_descriptor(mux, tr, data_bytes, duration, kind))
            return MP4E_STATUS_NO_MEMORY;
    } else
    {
        if (!mux->sequential_mode_flag)
        {
            sample_t *smpl_desc;
            if (tr->smpl.bytes < sizeof(sample_t))
                return MP4E_STATUS_NO_MEMORY; // write continuation, but there are no samples in the index
            // Accumulate size of the continuation in the sample descriptor
            smpl_desc = (sample_t*)(tr->smpl.data + tr->smpl.bytes) - 1;
            smpl_desc->size += data_bytes;
        }
    }

    if (mux->sequential_mode_flag)
    {
        if (!minimp4_vector_put(&tr->pending_sample, data, data_bytes))
            return MP4E_STATUS_NO_MEMORY;
    } else
    {
        ERR(mux->write_callback(mux->write_pos, data, data_bytes, mux->token));
        mux->write_pos += data_bytes;
    }
    return MP4E_STATUS_OK;
}

/**
*   calculate size of length field of OD box
*/
static int od_size_of_size(int size)
{
    int i, size_of_size = 1;
    for (i = size; i > 0x7F; i -= 0x7F)
        size_of_size++;
    return size_of_size;
}

/**
*   Add or remove MP4 file text comment according to Apple specs:
*   https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW1
*   http://atomicparsley.sourceforge.net/mpeg-4files.html
*   note that ISO did not specify comment format.
*/
int MP4E_set_text_comment(MP4E_mux_t *mux, const char *comment)
{
    if (!mux || !comment)
        return MP4E_STATUS_BAD_ARGUMENTS;
    if (mux->text_comment)
        free(mux->text_comment);
    mux->text_comment = strdup(comment);
    if (!mux->text_comment)
        return MP4E_STATUS_NO_MEMORY;
    return MP4E_STATUS_OK;
}

/**
*   Write file index 'moov' box with all its boxes and indexes
*/
static int mp4e_flush_index(MP4E_mux_t *mux)
{
    unsigned char *stack_base[20]; // atoms nesting stack
    unsigned char **stack = stack_base;
    unsigned char *base, *p;
    unsigned int ntr, index_bytes, ntracks = mux->tracks.bytes / sizeof(track_t);
    int i, err;

    // How much memory needed for indexes
    // Experimental data:
    // file with 1 track = 560 bytes
    // file with 2 tracks = 972 bytes
    // track size = 412 bytes;
    // file header size = 148 bytes
#define FILE_HEADER_BYTES 256
#define TRACK_HEADER_BYTES 512
    index_bytes = FILE_HEADER_BYTES;
    if (mux->text_comment)
        index_bytes += 128 + strlen(mux->text_comment);
    for (ntr = 0; ntr < ntracks; ntr++)
    {
        track_t *tr = ((track_t*)mux->tracks.data) + ntr;
        index_bytes += TRACK_HEADER_BYTES;          // fixed amount (implementation-dependent)
        // may need extra 4 bytes for duration field + 4 bytes for worst-case random access box
        index_bytes += tr->smpl.bytes * (sizeof(sample_t) + 4 + 4) / sizeof(sample_t);
        index_bytes += tr->vsps.bytes;
        index_bytes += tr->vpps.bytes;

        ERR(write_pending_data(mux, tr));
    }

    base = (unsigned char*)malloc(index_bytes);
    if (!base)
        return MP4E_STATUS_NO_MEMORY;
    p = base;

    if (!mux->sequential_mode_flag)
    {
        // update size of mdat box.
        // One of 2 points, which requires random file access.
        // Second is optional duration update at beginning of file in fragmentation mode.
        // This can be avoided using "till eof" size code, but in this case indexes must be
        // written before the mdat....
        int64_t size = mux->write_pos - sizeof(box_ftyp);
        const int64_t size_limit = (int64_t)(uint64_t)0xfffffffe;
        if (size > size_limit)
        {
            WRITE_4(1);
            WRITE_4(BOX_mdat);
            WRITE_4((size >> 32) & 0xffffffff);
            WRITE_4(size & 0xffffffff);
        } else
        {
            WRITE_4(8);
            WRITE_4(BOX_free);
            WRITE_4(size - 8);
            WRITE_4(BOX_mdat);
        }
        ERR(mux->write_callback(sizeof(box_ftyp), base, p - base, mux->token));
        p = base;
    }

    // Write index atoms; order taken from Table 1 of [1]
#define MOOV_TIMESCALE 1000
    ATOM(BOX_moov);
        ATOM_FULL(BOX_mvhd, 0);
        WRITE_4(0); // creation_time
        WRITE_4(0); // modification_time

        if (ntracks)
        {
            track_t *tr = ((track_t*)mux->tracks.data) + 0;    // take 1st track
            unsigned duration = get_duration(tr);
            duration = (unsigned)(duration * 1LL * MOOV_TIMESCALE / tr->info.time_scale);
            WRITE_4(MOOV_TIMESCALE); // duration
            WRITE_4(duration); // duration
        }

        WRITE_4(0x00010000); // rate
        WRITE_2(0x0100); // volume
        WRITE_2(0); // reserved
        WRITE_4(0); // reserved
        WRITE_4(0); // reserved

        // matrix[9]
        WRITE_4(0x00010000); WRITE_4(0); WRITE_4(0);
        WRITE_4(0); WRITE_4(0x00010000); WRITE_4(0);
        WRITE_4(0); WRITE_4(0); WRITE_4(0x40000000);

        // pre_defined[6]
        WRITE_4(0); WRITE_4(0); WRITE_4(0);
        WRITE_4(0); WRITE_4(0); WRITE_4(0);

        //next_track_ID is a non-zero integer that indicates a value to use for the track ID of the next track to be
        //added to this presentation. Zero is not a valid track ID value. The value of next_track_ID shall be
        //larger than the largest track-ID in use.
        WRITE_4(ntracks + 1);
        END_ATOM;

    for (ntr = 0; ntr < ntracks; ntr++)
    {
        track_t *tr = ((track_t*)mux->tracks.data) + ntr;
        unsigned duration = get_duration(tr);
        int samples_count = tr->smpl.bytes / sizeof(sample_t);
        const sample_t *sample = (const sample_t *)tr->smpl.data;
        unsigned handler_type;
        const char *handler_ascii = NULL;

        if (mux->enable_fragmentation)
            samples_count = 0;
        else if (samples_count <= 0)
            continue;   // skip empty track

        switch (tr->info.track_media_kind)
        {
            case e_audio:
                handler_type = MP4E_HANDLER_TYPE_SOUN;
                handler_ascii = "SoundHandler";
                break;
            case e_video:
                handler_type = MP4E_HANDLER_TYPE_VIDE;
                handler_ascii = "VideoHandler";
                break;
            case e_private:
                handler_type = MP4E_HANDLER_TYPE_GESM;
                break;
            default:
                return MP4E_STATUS_BAD_ARGUMENTS;
        }

        ATOM(BOX_trak);
            ATOM_FULL(BOX_tkhd, 7); // flag: 1=trak enabled; 2=track in movie; 4=track in preview
            WRITE_4(0);             // creation_time
            WRITE_4(0);             // modification_time
            WRITE_4(ntr + 1);       // track_ID
            WRITE_4(0);             // reserved
            WRITE_4((unsigned)(duration * 1LL * MOOV_TIMESCALE / tr->info.time_scale));
            WRITE_4(0); WRITE_4(0); // reserved[2]
            WRITE_2(0);             // layer
            WRITE_2(0);             // alternate_group
            WRITE_2(0x0100);        // volume {if track_is_audio 0x0100 else 0};
            WRITE_2(0);             // reserved

            // matrix[9]
            WRITE_4(0x00010000); WRITE_4(0); WRITE_4(0);
            WRITE_4(0); WRITE_4(0x00010000); WRITE_4(0);
            WRITE_4(0); WRITE_4(0); WRITE_4(0x40000000);

            if (tr->info.track_media_kind == e_audio || tr->info.track_media_kind == e_private)
            {
                WRITE_4(0); // width
                WRITE_4(0); // height
            } else
            {
                WRITE_4(tr->info.u.v.width*0x10000);  // width
                WRITE_4(tr->info.u.v.height*0x10000); // height
            }
            END_ATOM;

            ATOM(BOX_mdia);
                ATOM_FULL(BOX_mdhd, 0);
                WRITE_4(0); // creation_time
                WRITE_4(0); // modification_time
                WRITE_4(tr->info.time_scale);
                WRITE_4(duration); // duration
                {
                    int lang_code = ((tr->info.language[0] & 31) << 10) | ((tr->info.language[1] & 31) << 5) | (tr->info.language[2] & 31);
                    WRITE_2(lang_code); // language
                }
                WRITE_2(0); // pre_defined
                END_ATOM;

                ATOM_FULL(BOX_hdlr, 0);
                WRITE_4(0); // pre_defined
                WRITE_4(handler_type); // handler_type
                WRITE_4(0); WRITE_4(0); WRITE_4(0); // reserved[3]
                // name is a null-terminated string in UTF-8 characters which gives a human-readable name for the track type (for debugging and inspection purposes).
                // set mdia hdlr name field to what quicktime uses.
                // Sony smartphone may fail to decode short files w/o handler name
                if (handler_ascii)
                {
                    for (i = 0; i < (int)strlen(handler_ascii) + 1; i++)
                    {
                        WRITE_1(handler_ascii[i]);
                    }
                } else
                {
                    WRITE_4(0);
                }
                END_ATOM;

                ATOM(BOX_minf);

                    if (tr->info.track_media_kind == e_audio)
                    {
                        // Sound Media Header Box
                        ATOM_FULL(BOX_smhd, 0);
                        WRITE_2(0);   // balance
                        WRITE_2(0);   // reserved
                        END_ATOM;
                    }
                    if (tr->info.track_media_kind == e_video)
                    {
                        // mandatory Video Media Header Box
                        ATOM_FULL(BOX_vmhd, 1);
                        WRITE_2(0); // graphicsmode
                        WRITE_2(0); WRITE_2(0); WRITE_2(0); // opcolor[3]
                        END_ATOM;
                    }

                    ATOM(BOX_dinf);
                        ATOM_FULL(BOX_dref, 0);
                        WRITE_4(1); // entry_count
                            // If the flag is set indicating that the data is in the same file as this box, then no string (not even an empty one)
                            // shall be supplied in the entry field.

                            // ASP the correct way to avoid supply the string, is to use flag 1
                            // otherwise ISO reference demux crashes
                            ATOM_FULL(BOX_url, 1);
                            END_ATOM;
                        END_ATOM;
                    END_ATOM;

                    ATOM(BOX_stbl);
                        ATOM_FULL(BOX_stsd, 0);
                        WRITE_4(1); // entry_count;

                        if (tr->info.track_media_kind == e_audio || tr->info.track_media_kind == e_private)
                        {
                            // AudioSampleEntry() assume MP4E_HANDLER_TYPE_SOUN
                            if (tr->info.track_media_kind == e_audio)
                            {
                                ATOM(BOX_mp4a);
                            } else
                            {
                                ATOM(BOX_mp4s);
                            }

                            // SampleEntry
                            WRITE_4(0); WRITE_2(0); // reserved[6]
                            WRITE_2(1); // data_reference_index; - this is a tag for descriptor below

                            if (tr->info.track_media_kind == e_audio)
                            {
                                // AudioSampleEntry
                                WRITE_4(0); WRITE_4(0); // reserved[2]
                                WRITE_2(tr->info.u.a.channelcount); // channelcount
                                WRITE_2(16); // samplesize
                                WRITE_4(0);  // pre_defined+reserved
                                WRITE_4((tr->info.time_scale << 16));  // samplerate == = {timescale of media}<<16;
                            }

                                ATOM_FULL(BOX_esds, 0);
                                if (tr->vsps.bytes > 0)
                                {
                                    int dsi_bytes = tr->vsps.bytes - 2; //  - two bytes size field
                                    int dsi_size_size = od_size_of_size(dsi_bytes);
                                    int dcd_bytes = dsi_bytes + dsi_size_size + 1 + (1 + 1 + 3 + 4 + 4);
                                    int dcd_size_size = od_size_of_size(dcd_bytes);
                                    int esd_bytes = dcd_bytes + dcd_size_size + 1 + 3;

#define WRITE_OD_LEN(size) if (size > 0x7F) do { size -= 0x7F; WRITE_1(0x00ff); } while (size > 0x7F); WRITE_1(size)
                                    WRITE_1(3); // OD_ESD
                                    WRITE_OD_LEN(esd_bytes);
                                    WRITE_2(0); // ES_ID(2) // TODO - what is this?
                                    WRITE_1(0); // flags(1)

                                    WRITE_1(4); // OD_DCD
                                    WRITE_OD_LEN(dcd_bytes);
                                    if (tr->info.track_media_kind == e_audio)
                                    {
                                        WRITE_1(MP4_OBJECT_TYPE_AUDIO_ISO_IEC_14496_3); // OD_DCD
                                        WRITE_1(5 << 2); // stream_type == AudioStream
                                    } else
                                    {
                                        // http://xhelmboyx.tripod.com/formats/mp4-layout.txt
                                        WRITE_1(208); // 208 = private video
                                        WRITE_1(32 << 2); // stream_type == user private
                                    }
                                    WRITE_3(tr->info.u.a.channelcount * 6144/8); // bufferSizeDB in bytes, constant as in reference decoder
                                    WRITE_4(0); // maxBitrate TODO
                                    WRITE_4(0); // avg_bitrate_bps TODO

                                    WRITE_1(5); // OD_DSI
                                    WRITE_OD_LEN(dsi_bytes);
                                    for (i = 0; i < dsi_bytes; i++)
                                    {
                                        WRITE_1(tr->vsps.data[2 + i]);
                                    }
                                }
                                END_ATOM;
                            END_ATOM;
                        }

                        if (tr->info.track_media_kind == e_video && (MP4_OBJECT_TYPE_AVC == tr->info.object_type_indication || MP4_OBJECT_TYPE_HEVC == tr->info.object_type_indication))
                        {
                            int numOfSequenceParameterSets = items_count(&tr->vsps);
                            int numOfPictureParameterSets  = items_count(&tr->vpps);
                            if (MP4_OBJECT_TYPE_AVC == tr->info.object_type_indication)
                            {
                                ATOM(BOX_avc1);
                            } else
                            {
                                ATOM(BOX_hvc1);
                            }
                            // VisualSampleEntry  8.16.2
                            // extends SampleEntry
                            WRITE_2(0); // reserved
                            WRITE_2(0); // reserved
                            WRITE_2(0); // reserved
                            WRITE_2(1); // data_reference_index

                            WRITE_2(0); // pre_defined
                            WRITE_2(0); // reserved
                            WRITE_4(0); // pre_defined
                            WRITE_4(0); // pre_defined
                            WRITE_4(0); // pre_defined
                            WRITE_2(tr->info.u.v.width);
                            WRITE_2(tr->info.u.v.height);
                            WRITE_4(0x00480000); // horizresolution = 72 dpi
                            WRITE_4(0x00480000); // vertresolution  = 72 dpi
                            WRITE_4(0); // reserved
                            WRITE_2(1); // frame_count
                            for (i = 0; i < 32; i++)
                            {
                                WRITE_1(0); //  compressorname
                            }
                            WRITE_2(24); // depth
                            WRITE_2(-1); // pre_defined

                            if (MP4_OBJECT_TYPE_AVC == tr->info.object_type_indication)
                            {
                                ATOM(BOX_avcC);
                                // AVCDecoderConfigurationRecord 5.2.4.1.1
                                WRITE_1(1); // configurationVersion
                                WRITE_1(tr->vsps.data[2 + 1]);
                                WRITE_1(tr->vsps.data[2 + 2]);
                                WRITE_1(tr->vsps.data[2 + 3]);
                                WRITE_1(255); // 0xfc + NALU_len - 1
                                WRITE_1(0xe0 | numOfSequenceParameterSets);
                                for (i = 0; i < tr->vsps.bytes; i++)
                                {
                                    WRITE_1(tr->vsps.data[i]);
                                }
                                WRITE_1(numOfPictureParameterSets);
                                for (i = 0; i < tr->vpps.bytes; i++)
                                {
                                    WRITE_1(tr->vpps.data[i]);
                                }
                            } else
                            {
                                int numOfVPS  = items_count(&tr->vpps);
                                ATOM(BOX_hvcC);
                                // TODO: read actual params from stream
                                WRITE_1(1);    // configurationVersion
                                WRITE_1(1);    // Profile Space (2), Tier (1), Profile (5)
                                WRITE_4(0x60000000); // Profile Compatibility
                                WRITE_2(0);    // progressive, interlaced, non packed constraint, frame only constraint flags
                                WRITE_4(0);    // constraint indicator flags
                                WRITE_1(0);    // level_idc
                                WRITE_2(0xf000); // Min Spatial Segmentation
                                WRITE_1(0xfc); // Parallelism Type
                                WRITE_1(0xfc); // Chroma Format
                                WRITE_1(0xf8); // Luma Depth
                                WRITE_1(0xf8); // Chroma Depth
                                WRITE_2(0);    // Avg Frame Rate
                                WRITE_1(3);    // ConstantFrameRate (2), NumTemporalLayers (3), TemporalIdNested (1), LengthSizeMinusOne (2)

                                WRITE_1(3);    // Num Of Arrays
                                WRITE_1((1 << 7) | (HEVC_NAL_VPS & 0x3f)); // Array Completeness + NAL Unit Type
                                WRITE_2(numOfVPS);
                                for (i = 0; i < tr->vvps.bytes; i++)
                                {
                                    WRITE_1(tr->vvps.data[i]);
                                }
                                WRITE_1((1 << 7) | (HEVC_NAL_SPS & 0x3f));
                                WRITE_2(numOfSequenceParameterSets);
                                for (i = 0; i < tr->vsps.bytes; i++)
                                {
                                    WRITE_1(tr->vsps.data[i]);
                                }
                                WRITE_1((1 << 7) | (HEVC_NAL_PPS & 0x3f));
                                WRITE_2(numOfPictureParameterSets);
                                for (i = 0; i < tr->vpps.bytes; i++)
                                {
                                    WRITE_1(tr->vpps.data[i]);
                                }
                            }

                            END_ATOM;
                            END_ATOM;
                        }
                        END_ATOM;

                        /************************************************************************/
                        /*      indexes                                                         */
                        /************************************************************************/

                        // Time to Sample Box
                        ATOM_FULL(BOX_stts, 0);
                        {
                            unsigned char *pentry_count = p;
                            int cnt = 1, entry_count = 0;
                            WRITE_4(0);
                            for (i = 0; i < samples_count; i++, cnt++)
                            {
                                if (i == (samples_count - 1) || sample[i].duration != sample[i + 1].duration)
                                {
                                    WRITE_4(cnt);
                                    WRITE_4(sample[i].duration);
                                    cnt = 0;
                                    entry_count++;
                                }
                            }
                            WR4(pentry_count, entry_count);
                        }
                        END_ATOM;

                        // Sample To Chunk Box
                        ATOM_FULL(BOX_stsc, 0);
                        if (mux->enable_fragmentation)
                        {
                            WRITE_4(0); // entry_count
                        } else
                        {
                            WRITE_4(1); // entry_count
                            WRITE_4(1); // first_chunk;
                            WRITE_4(1); // samples_per_chunk;
                            WRITE_4(1); // sample_description_index;
                        }
                        END_ATOM;

                        // Sample Size Box
                        ATOM_FULL(BOX_stsz, 0);
                        WRITE_4(0); // sample_size  If this field is set to 0, then the samples have different sizes, and those sizes
                                    //  are stored in the sample size table.
                        WRITE_4(samples_count);  // sample_count;
                        for (i = 0; i < samples_count; i++)
                        {
                            WRITE_4(sample[i].size);
                        }
                        END_ATOM;

                        // Chunk Offset Box
                        int is_64_bit = 0;
                        if (samples_count && sample[samples_count - 1].offset > 0xffffffff)
                            is_64_bit = 1;
                        if (!is_64_bit)
                        {
                            ATOM_FULL(BOX_stco, 0);
                            WRITE_4(samples_count);
                            for (i = 0; i < samples_count; i++)
                            {
                                WRITE_4(sample[i].offset);
                            }
                        } else
                        {
                            ATOM_FULL(BOX_co64, 0);
                            WRITE_4(samples_count);
                            for (i = 0; i < samples_count; i++)
                            {
                                WRITE_4((sample[i].offset >> 32) & 0xffffffff);
                                WRITE_4(sample[i].offset & 0xffffffff);
                            }
                        }
                        END_ATOM;

                        // Sync Sample Box
                        {
                            int ra_count = 0;
                            for (i = 0; i < samples_count; i++)
                            {
                                ra_count += !!sample[i].flag_random_access;
                            }
                            if (ra_count != samples_count)
                            {
                                // If the sync sample box is not present, every sample is a random access point.
                                ATOM_FULL(BOX_stss, 0);
                                WRITE_4(ra_count);
                                for (i = 0; i < samples_count; i++)
                                {
                                    if (sample[i].flag_random_access)
                                    {
                                        WRITE_4(i + 1);
                                    }
                                }
                                END_ATOM;
                            }
                        }
                    END_ATOM;
                END_ATOM;
            END_ATOM;
        END_ATOM;
    } // tracks loop

    if (mux->text_comment)
    {
        ATOM(BOX_udta);
            ATOM_FULL(BOX_meta, 0);
                ATOM_FULL(BOX_hdlr, 0);
                    WRITE_4(0); // pre_defined
#define MP4E_HANDLER_TYPE_MDIR  0x6d646972
                    WRITE_4(MP4E_HANDLER_TYPE_MDIR); // handler_type
                    WRITE_4(0); WRITE_4(0); WRITE_4(0); // reserved[3]
                    WRITE_4(0); // name is a null-terminated string in UTF-8 characters which gives a human-readable name for the track type (for debugging and inspection purposes).
                END_ATOM;
                ATOM(BOX_ilst);
                    ATOM(BOX_ccmt);
                        ATOM(BOX_data);
                            WRITE_4(1); // type
                            WRITE_4(0); // lang
                            for (i = 0; i < (int)strlen(mux->text_comment) + 1; i++)
                            {
                                WRITE_1(mux->text_comment[i]);
                            }
                        END_ATOM;
                    END_ATOM;
                END_ATOM;
            END_ATOM;
        END_ATOM;
    }

    if (mux->enable_fragmentation)
    {
        track_t *tr = ((track_t*)mux->tracks.data) + 0;
        uint32_t movie_duration = get_duration(tr);

        ATOM(BOX_mvex);
            ATOM_FULL(BOX_mehd, 0);
                WRITE_4(movie_duration); // duration
            END_ATOM;
        for (ntr = 0; ntr < ntracks; ntr++)
        {
            ATOM_FULL(BOX_trex, 0);
                WRITE_4(ntr + 1);        // track_ID
                WRITE_4(1);              // default_sample_description_index
                WRITE_4(0);              // default_sample_duration
                WRITE_4(0);              // default_sample_size
                WRITE_4(0);              // default_sample_flags
            END_ATOM;
        }
        END_ATOM;
    }
    END_ATOM;   // moov atom

    assert((unsigned)(p - base) <= index_bytes);

    err = mux->write_callback(mux->write_pos, base, p - base, mux->token);
    mux->write_pos += p - base;
    free(base);
    return err;
}

int MP4E_close(MP4E_mux_t *mux)
{
    int err = MP4E_STATUS_OK;
    unsigned ntr, ntracks;
    if (!mux)
        return MP4E_STATUS_BAD_ARGUMENTS;
    if (!mux->enable_fragmentation)
        err = mp4e_flush_index(mux);
    if (mux->text_comment)
        free(mux->text_comment);
    ntracks = mux->tracks.bytes / sizeof(track_t);
    for (ntr = 0; ntr < ntracks; ntr++)
    {
        track_t *tr = ((track_t*)mux->tracks.data) + ntr;
        minimp4_vector_reset(&tr->vsps);
        minimp4_vector_reset(&tr->vpps);
        minimp4_vector_reset(&tr->smpl);
        minimp4_vector_reset(&tr->pending_sample);
    }
    minimp4_vector_reset(&mux->tracks);
    free(mux);
    return err;
}

typedef uint32_t bs_item_t;
#define BS_BITS 32

typedef struct
{
    // Look-ahead bit cache: MSB aligned, 17 bits guaranteed, zero stuffing
    unsigned int cache;

    // Bit counter = 16 - (number of bits in wCache)
    // cache refilled when cache_free_bits >= 0
    int cache_free_bits;

    // Current read position
    const uint16_t *buf;

    // original data buffer
    const uint16_t *origin;

    // original data buffer length, bytes
    unsigned origin_bytes;
} bit_reader_t;


#define LOAD_SHORT(x) ((uint16_t)(x << 8) | (x >> 8))

static unsigned int show_bits(bit_reader_t *bs, int n)
{
    unsigned int retval;
    assert(n > 0 && n <= 16);
    retval = (unsigned int)(bs->cache >> (32 - n));
    return retval;
}

static void flush_bits(bit_reader_t *bs, int n)
{
    assert(n >= 0 && n <= 16);
    bs->cache <<= n;
    bs->cache_free_bits += n;
    if (bs->cache_free_bits >= 0)
    {
        bs->cache |= ((uint32_t)LOAD_SHORT(*bs->buf)) << bs->cache_free_bits;
        bs->buf++;
        bs->cache_free_bits -= 16;
    }
}

static unsigned int get_bits(bit_reader_t *bs, int n)
{
    unsigned int retval = show_bits(bs, n);
    flush_bits(bs, n);
    return retval;
}

static void set_pos_bits(bit_reader_t *bs, unsigned pos_bits)
{
    assert((int)pos_bits >= 0);

    bs->buf = bs->origin + pos_bits/16;
    bs->cache = 0;
    bs->cache_free_bits = 16;
    flush_bits(bs, 0);
    flush_bits(bs, pos_bits & 15);
}

static unsigned get_pos_bits(const bit_reader_t *bs)
{
    // Current bitbuffer position =
    // position of next wobits in the internal buffer
    // minus bs, available in bit cache wobits
    unsigned pos_bits = (unsigned)(bs->buf - bs->origin)*16;
    pos_bits -= 16 - bs->cache_free_bits;
    assert((int)pos_bits >= 0);
    return pos_bits;
}

static int remaining_bits(const bit_reader_t *bs)
{
    return bs->origin_bytes * 8 - get_pos_bits(bs);
}

static void init_bits(bit_reader_t *bs, const void *data, unsigned data_bytes)
{
    bs->origin = (const uint16_t *)data;
    bs->origin_bytes = data_bytes;
    set_pos_bits(bs, 0);
}

#define GetBits(n) get_bits(bs, n)

/**
*   Unsigned Golomb code
*/
static int ue_bits(bit_reader_t *bs)
{
    int clz;
    int val;
    for (clz = 0; !get_bits(bs, 1); clz++) {}
    //get_bits(bs, clz + 1);
    val = (1 << clz) - 1 + (clz ? get_bits(bs, clz) : 0);
    return val;
}

#if MINIMP4_TRANSCODE_SPS_ID

/**
*   Output bitstream
*/
typedef struct
{
    int        shift;    // bit position in the cache
    uint32_t   cache;    // bit cache
    bs_item_t  *buf;     // current position
    bs_item_t  *origin;  // initial position
} bs_t;

#define SWAP32(x) (uint32_t)((((x) >> 24) & 0xFF) | (((x) >> 8) & 0xFF00) | (((x) << 8) & 0xFF0000) | ((x & 0xFF) << 24))

static void h264e_bs_put_bits(bs_t *bs, unsigned n, unsigned val)
{
    assert(!(val >> n));
    bs->shift -= n;
    assert((unsigned)n <= 32);
    if (bs->shift < 0)
    {
        assert(-bs->shift < 32);
        bs->cache |= val >> -bs->shift;
        *bs->buf++ = SWAP32(bs->cache);
        bs->shift = 32 + bs->shift;
        bs->cache = 0;
    }
    bs->cache |= val << bs->shift;
}

static void h264e_bs_flush(bs_t *bs)
{
    *bs->buf = SWAP32(bs->cache);
}

static unsigned h264e_bs_get_pos_bits(const bs_t *bs)
{
    unsigned pos_bits = (unsigned)((bs->buf - bs->origin)*BS_BITS);
    pos_bits += BS_BITS - bs->shift;
    assert((int)pos_bits >= 0);
    return pos_bits;
}

static unsigned h264e_bs_byte_align(bs_t *bs)
{
    int pos = h264e_bs_get_pos_bits(bs);
    h264e_bs_put_bits(bs, -pos & 7, 0);
    return pos + (-pos & 7);
}

/**
*   Golomb code
*   0 => 1
*   1 => 01 0
*   2 => 01 1
*   3 => 001 00
*   4 => 001 01
*
*   [0]     => 1
*   [1..2]  => 01x
*   [3..6]  => 001xx
*   [7..14] => 0001xxx
*
*/
static void h264e_bs_put_golomb(bs_t *bs, unsigned val)
{
    int size = 0;
    unsigned t = val + 1;
    do
    {
        size++;
    } while (t >>= 1);

    h264e_bs_put_bits(bs, 2*size - 1, val + 1);
}

static void h264e_bs_init_bits(bs_t *bs, void *data)
{
    bs->origin = (bs_item_t*)data;
    bs->buf = bs->origin;
    bs->shift = BS_BITS;
    bs->cache = 0;
}

static int find_mem_cache(void *cache[], int cache_bytes[], int cache_size, void *mem, int bytes)
{
    int i;
    if (!bytes)
        return -1;
    for (i = 0; i < cache_size; i++)
    {
        if (cache_bytes[i] == bytes && !memcmp(mem, cache[i], bytes))
            return i;   // found
    }
    for (i = 0; i < cache_size; i++)
    {
        if (!cache_bytes[i])
        {
            cache[i] = malloc(bytes);
            if (cache[i])
            {
                memcpy(cache[i], mem, bytes);
                cache_bytes[i] = bytes;
            }
            return i;   // put in
        }
    }
    return -1;  // no room
}

/**
*   7.4.1.1. "Encapsulation of an SODB within an RBSP"
*/
static int remove_nal_escapes(unsigned char *dst, const unsigned char *src, int h264_data_bytes)
{
    int i = 0, j = 0, zero_cnt = 0;
    for (j = 0; j < h264_data_bytes; j++)
    {
        if (zero_cnt == 2 && src[j] <= 3)
        {
            if (src[j] == 3)
            {
                if (j == h264_data_bytes - 1)
                {
                    // cabac_zero_word: no action
                } else if (src[j + 1] <= 3)
                {
                    j++;
                    zero_cnt = 0;
                } else
                {
                    // TODO: assume end-of-nal
                    //return 0;
                }
            } else
                return 0;
        }
        dst[i++] = src[j];
        if (src[j])
            zero_cnt = 0;
        else
            zero_cnt++;
    }
    //while (--j > i) src[j] = 0;
    return i;
}

/**
*   Put NAL escape codes to the output bitstream
*/
static int nal_put_esc(uint8_t *d, const uint8_t *s, int n)
{
    int i, j = 4, cntz = 0;
    d[0] = d[1] = d[2] = 0; d[3] = 1; // start code
    for (i = 0; i < n; i++)
    {
        uint8_t byte = *s++;
        if (cntz == 2 && byte <= 3)
        {
            d[j++] = 3;
            cntz = 0;
        }
        if (byte)
            cntz = 0;
        else
            cntz++;
        d[j++] = byte;
    }
    return j;
}

static void copy_bits(bit_reader_t *bs, bs_t *bd)
{
    unsigned cb, bits;
    int bit_count = remaining_bits(bs);
    while (bit_count > 7)
    {
        cb = MINIMP4_MIN(bit_count - 7, 8);
        bits = GetBits(cb);
        h264e_bs_put_bits(bd, cb, bits);
        bit_count -= cb;
    }

    // cut extra zeros after stop-bit
    bits = GetBits(bit_count);
    for (; bit_count && ~bits & 1; bit_count--)
    {
        bits >>= 1;
    }
    if (bit_count)
    {
        h264e_bs_put_bits(bd, bit_count, bits);
    }
}

static int change_sps_id(bit_reader_t *bs, bs_t *bd, int new_id, int *old_id)
{
    unsigned bits, sps_id, i, bytes;
    for (i = 0; i < 3; i++)
    {
        bits = GetBits(8);
        h264e_bs_put_bits(bd, 8, bits);
    }
    sps_id = ue_bits(bs);               // max = 31

    *old_id = sps_id;
    sps_id = new_id;
    assert(sps_id <= 31);

    h264e_bs_put_golomb(bd, sps_id);
    copy_bits(bs, bd);

    bytes = h264e_bs_byte_align(bd) / 8;
    h264e_bs_flush(bd);
    return bytes;
}

static int patch_pps(h264_sps_id_patcher_t *h, bit_reader_t *bs, bs_t *bd, int new_pps_id, int *old_id)
{
    int bytes;
    unsigned pps_id = ue_bits(bs);  // max = 255
    unsigned sps_id = ue_bits(bs);  // max = 31

    *old_id = pps_id;
    sps_id = h->map_sps[sps_id];
    pps_id = new_pps_id;

    assert(sps_id <= 31);
    assert(pps_id <= 255);

    h264e_bs_put_golomb(bd, pps_id);
    h264e_bs_put_golomb(bd, sps_id);
    copy_bits(bs, bd);

    bytes = h264e_bs_byte_align(bd) / 8;
    h264e_bs_flush(bd);
    return bytes;
}

static void patch_slice_header(h264_sps_id_patcher_t *h, bit_reader_t *bs, bs_t *bd)
{
    unsigned first_mb_in_slice = ue_bits(bs);
    unsigned slice_type = ue_bits(bs);
    unsigned pps_id = ue_bits(bs);

    pps_id = h->map_pps[pps_id];

    assert(pps_id <= 255);

    h264e_bs_put_golomb(bd, first_mb_in_slice);
    h264e_bs_put_golomb(bd, slice_type);
    h264e_bs_put_golomb(bd, pps_id);
    copy_bits(bs, bd);
}

static int transcode_nalu(h264_sps_id_patcher_t *h, const unsigned char *src, int nalu_bytes, unsigned char *dst)
{
    int old_id;

    bit_reader_t bst[1];
    bs_t bdt[1];

    bit_reader_t bs[1];
    bs_t bd[1];
    int payload_type = src[0] & 31;

    *dst = *src;
    h264e_bs_init_bits(bd, dst + 1);
    init_bits(bs, src + 1, nalu_bytes - 1);
    h264e_bs_init_bits(bdt, dst + 1);
    init_bits(bst, src + 1, nalu_bytes - 1);

    switch(payload_type)
    {
    case 7:
        {
            int cb = change_sps_id(bst, bdt, 0, &old_id);
            int id = find_mem_cache(h->sps_cache, h->sps_bytes, MINIMP4_MAX_SPS, dst + 1, cb);
            if (id == -1)
                return 0;
            h->map_sps[old_id] = id;
            change_sps_id(bs, bd, id, &old_id);
        }
        break;
    case 8:
        {
            int cb = patch_pps(h, bst, bdt, 0, &old_id);
            int id = find_mem_cache(h->pps_cache, h->pps_bytes, MINIMP4_MAX_PPS, dst + 1, cb);
            if (id == -1)
                return 0;
            h->map_pps[old_id] = id;
            patch_pps(h, bs, bd, id, &old_id);
        }
        break;
    case 1:
    case 2:
    case 5:
        patch_slice_header(h, bs, bd);
        break;
    default:
        memcpy(dst, src, nalu_bytes);
        return nalu_bytes;
    }

    nalu_bytes = 1 + h264e_bs_byte_align(bd) / 8;
    h264e_bs_flush(bd);

    return nalu_bytes;
}

#endif

/**
*   Set pointer just after start code (00 .. 00 01), or to EOF if not found:
*
*   NZ NZ ... NZ 00 00 00 00 01 xx xx ... xx (EOF)
*                               ^            ^
*   non-zero head.............. here ....... or here if no start code found
*
*/
static const uint8_t *find_start_code(const uint8_t *h264_data, int h264_data_bytes, int *zcount)
{
    const uint8_t *eof = h264_data + h264_data_bytes;
    const uint8_t *p = h264_data;
    do
    {
        int zero_cnt = 1;
        const uint8_t* found = (uint8_t*)memchr(p, 0, eof - p);
        p = found ? found : eof;
        while (p + zero_cnt < eof && !p[zero_cnt]) zero_cnt++;
        if (zero_cnt >= 2 && p[zero_cnt] == 1)
        {
            *zcount = zero_cnt + 1;
            return p + zero_cnt + 1;
        }
        p += zero_cnt;
    } while (p < eof);
    *zcount = 0;
    return eof;
}

/**
*   Locate NAL unit in given buffer, and calculate it's length
*/
static const uint8_t *find_nal_unit(const uint8_t *h264_data, int h264_data_bytes, int *pnal_unit_bytes)
{
    const uint8_t *eof = h264_data + h264_data_bytes;
    int zcount;
    const uint8_t *start = find_start_code(h264_data, h264_data_bytes, &zcount);
    const uint8_t *stop = start;
    if (start)
    {
        stop = find_start_code(start, (int)(eof - start), &zcount);
        while (stop > start && !stop[-1])
        {
            stop--;
        }
    }

    *pnal_unit_bytes = (int)(stop - start - zcount);
    return start;
}

int mp4_h26x_write_init(mp4_h26x_writer_t *h, MP4E_mux_t *mux, int width, int height, int is_hevc)
{
    MP4E_track_t tr;
    tr.track_media_kind = e_video;
    tr.language[0] = 'u';
    tr.language[1] = 'n';
    tr.language[2] = 'd';
    tr.language[3] = 0;
    tr.object_type_indication = is_hevc ? MP4_OBJECT_TYPE_HEVC : MP4_OBJECT_TYPE_AVC;
    tr.time_scale = 90000;
    tr.default_duration = 0;
    tr.u.v.width = width;
    tr.u.v.height = height;
    h->mux_track_id = MP4E_add_track(mux, &tr);
    h->mux = mux;

    h->is_hevc  = is_hevc;
    h->need_vps = is_hevc;
    h->need_sps = 1;
    h->need_pps = 1;
    h->need_idr = 1;
#if MINIMP4_TRANSCODE_SPS_ID
    memset(&h->sps_patcher, 0, sizeof(h264_sps_id_patcher_t));
#endif
    return MP4E_STATUS_OK;
}

void mp4_h26x_write_close(mp4_h26x_writer_t *h)
{
#if MINIMP4_TRANSCODE_SPS_ID
    h264_sps_id_patcher_t *p = &h->sps_patcher;
    int i;
    for (i = 0; i < MINIMP4_MAX_SPS; i++)
    {
        if (p->sps_cache[i])
            free(p->sps_cache[i]);
    }
    for (i = 0; i < MINIMP4_MAX_PPS; i++)
    {
        if (p->pps_cache[i])
            free(p->pps_cache[i]);
    }
#endif
    memset(h, 0, sizeof(*h));
}

static int mp4_h265_write_nal(mp4_h26x_writer_t *h, const unsigned char *nal, int sizeof_nal, unsigned timeStamp90kHz_next)
{
    int payload_type = (nal[0] >> 1) & 0x3f;
    int is_intra = payload_type >= HEVC_NAL_BLA_W_LP && payload_type <= HEVC_NAL_CRA_NUT;
    int err = MP4E_STATUS_OK;
    //printf("payload_type=%d, intra=%d\n", payload_type, is_intra);

    if (is_intra && !h->need_sps && !h->need_pps && !h->need_vps)
        h->need_idr = 0;
    switch (payload_type)
    {
    case HEVC_NAL_VPS:
        MP4E_set_vps(h->mux, h->mux_track_id, nal, sizeof_nal);
        h->need_vps = 0;
        break;
    case HEVC_NAL_SPS:
        MP4E_set_sps(h->mux, h->mux_track_id, nal, sizeof_nal);
        h->need_sps = 0;
        break;
    case HEVC_NAL_PPS:
        MP4E_set_pps(h->mux, h->mux_track_id, nal, sizeof_nal);
        h->need_pps = 0;
        break;
    default:
        if (h->need_vps || h->need_sps || h->need_pps || h->need_idr)
            return MP4E_STATUS_BAD_ARGUMENTS;
        {
            unsigned char *tmp = (unsigned char *)malloc(4 + sizeof_nal);
            if (!tmp)
                return MP4E_STATUS_NO_MEMORY;
            int sample_kind = MP4E_SAMPLE_DEFAULT;
            tmp[0] = (unsigned char)(sizeof_nal >> 24);
            tmp[1] = (unsigned char)(sizeof_nal >> 16);
            tmp[2] = (unsigned char)(sizeof_nal >>  8);
            tmp[3] = (unsigned char)(sizeof_nal);
            memcpy(tmp + 4, nal, sizeof_nal);
            if (is_intra)
                sample_kind = MP4E_SAMPLE_RANDOM_ACCESS;
            err = MP4E_put_sample(h->mux, h->mux_track_id, tmp, 4 + sizeof_nal, timeStamp90kHz_next, sample_kind);
            free(tmp);
        }
        break;
    }
    return err;
}

int mp4_h26x_write_nal(mp4_h26x_writer_t *h, const unsigned char *nal, int length, unsigned timeStamp90kHz_next)
{
    const unsigned char *eof = nal + length;
    int payload_type, sizeof_nal, err = MP4E_STATUS_OK;
    for (;;nal++)
    {
#if MINIMP4_TRANSCODE_SPS_ID
        unsigned char *nal1, *nal2;
#endif
        nal = find_nal_unit(nal, (int)(eof - nal), &sizeof_nal);
        if (!sizeof_nal)
            break;
        if (h->is_hevc)
        {
            ERR(mp4_h265_write_nal(h, nal, sizeof_nal, timeStamp90kHz_next));
            continue;
        }
        payload_type = nal[0] & 31;
        if (9 == payload_type)
            continue;  // access unit delimiter, nothing to be done
#if MINIMP4_TRANSCODE_SPS_ID
        // Transcode SPS, PPS and slice headers, reassigning ID's for SPS and  PPS:
        // - assign unique ID's to different SPS and PPS
        // - assign same ID's to equal (except ID) SPS and PPS
        // - save all different SPS and PPS
        nal1 = (unsigned char *)malloc(sizeof_nal*17/16 + 32);
        if (!nal1)
            return MP4E_STATUS_NO_MEMORY;
        nal2 = (unsigned char *)malloc(sizeof_nal*17/16 + 32);
        if (!nal2)
        {
            free(nal1);
            return MP4E_STATUS_NO_MEMORY;
        }
        sizeof_nal = remove_nal_escapes(nal2, nal, sizeof_nal);
        if (!sizeof_nal)
        {
exit_with_free:
            free(nal1);
            free(nal2);
            return MP4E_STATUS_BAD_ARGUMENTS;
        }

        sizeof_nal = transcode_nalu(&h->sps_patcher, nal2, sizeof_nal, nal1);
        sizeof_nal = nal_put_esc(nal2, nal1, sizeof_nal);

        switch (payload_type) {
        case 7:
            MP4E_set_sps(h->mux, h->mux_track_id, nal2 + 4, sizeof_nal - 4);
            h->need_sps = 0;
            break;
        case 8:
            if (h->need_sps)
                goto exit_with_free;
            MP4E_set_pps(h->mux, h->mux_track_id, nal2 + 4, sizeof_nal - 4);
            h->need_pps = 0;
            break;
        case 5:
            if (h->need_sps)
                goto exit_with_free;
            h->need_idr = 0;
            // flow through
        default:
            if (h->need_sps)
                goto exit_with_free;
            if (!h->need_pps && !h->need_idr)
            {
                bit_reader_t bs[1];
                init_bits(bs, nal + 1, sizeof_nal - 4 - 1);
                unsigned first_mb_in_slice = ue_bits(bs);
                //unsigned slice_type = ue_bits(bs);
                int sample_kind = MP4E_SAMPLE_DEFAULT;
                nal2[0] = (unsigned char)((sizeof_nal - 4) >> 24);
                nal2[1] = (unsigned char)((sizeof_nal - 4) >> 16);
                nal2[2] = (unsigned char)((sizeof_nal - 4) >>  8);
                nal2[3] = (unsigned char)((sizeof_nal - 4));
                if (first_mb_in_slice)
                    sample_kind = MP4E_SAMPLE_CONTINUATION;
                else if (payload_type == 5)
                    sample_kind = MP4E_SAMPLE_RANDOM_ACCESS;
                err = MP4E_put_sample(h->mux, h->mux_track_id, nal2, sizeof_nal, timeStamp90kHz_next, sample_kind);
            }
            break;
        }
        free(nal1);
        free(nal2);
#else
        // No SPS/PPS transcoding
        // This branch assumes that encoder use correct SPS/PPS ID's
        switch (payload_type) {
            case 7:
                MP4E_set_sps(h->mux, h->mux_track_id, nal, sizeof_nal);
                h->need_sps = 0;
                break;
            case 8:
                MP4E_set_pps(h->mux, h->mux_track_id, nal, sizeof_nal);
                h->need_pps = 0;
                break;
            case 5:
                if (h->need_sps)
                    return MP4E_STATUS_BAD_ARGUMENTS;
                h->need_idr = 0;
                // flow through
            default:
                if (h->need_sps)
                    return MP4E_STATUS_BAD_ARGUMENTS;
                if (!h->need_pps && !h->need_idr)
                {
                    bit_reader_t bs[1];
                    unsigned char *tmp = (unsigned char *)malloc(4 + sizeof_nal);
                    if (!tmp)
                        return MP4E_STATUS_NO_MEMORY;
                    init_bits(bs, nal + 1, sizeof_nal - 1);
                    unsigned first_mb_in_slice = ue_bits(bs);
                    int sample_kind = MP4E_SAMPLE_DEFAULT;
                    tmp[0] = (unsigned char)(sizeof_nal >> 24);
                    tmp[1] = (unsigned char)(sizeof_nal >> 16);
                    tmp[2] = (unsigned char)(sizeof_nal >>  8);
                    tmp[3] = (unsigned char)(sizeof_nal);
                    memcpy(tmp + 4, nal, sizeof_nal);
                    if (first_mb_in_slice)
                        sample_kind = MP4E_SAMPLE_CONTINUATION;
                    else if (payload_type == 5)
                        sample_kind = MP4E_SAMPLE_RANDOM_ACCESS;
                    err = MP4E_put_sample(h->mux, h->mux_track_id, tmp, 4 + sizeof_nal, timeStamp90kHz_next, sample_kind);
                    free(tmp);
                }
                break;
        }
#endif
        if (err)
            break;
    }
    return err;
}

#if MP4D_TRACE_SUPPORTED
#   define TRACE(x) printf x
#else
#   define TRACE(x)
#endif

#define NELEM(x)  (sizeof(x) / sizeof((x)[0]))

static int minimp4_fgets(MP4D_demux_t *mp4)
{
    uint8_t c;
    if (mp4->read_callback(mp4->read_pos, &c, 1, mp4->token))
        return -1;
    mp4->read_pos++;
    return c;
}

/**
*   Read given number of bytes from input stream
*   Used to read box headers
*/
static unsigned minimp4_read(MP4D_demux_t *mp4, int nb, int *eof_flag)
{
    uint32_t v = 0; int last_byte;
    switch (nb)
    {
    case 4: v = (v << 8) | minimp4_fgets(mp4);
    case 3: v = (v << 8) | minimp4_fgets(mp4);
    case 2: v = (v << 8) | minimp4_fgets(mp4);
    default:
    case 1: v = (v << 8) | (last_byte = minimp4_fgets(mp4));
    }
    if (last_byte < 0)
    {
        *eof_flag = 1;
    }
    return v;
}

/**
*   Read given number of bytes, but no more than *payload_bytes specifies...
*   Used to read box payload
*/
static uint32_t read_payload(MP4D_demux_t *mp4, unsigned nb, boxsize_t *payload_bytes, int *eof_flag)
{
    if (*payload_bytes < nb)
    {
        *eof_flag = 1;
        nb = (int)*payload_bytes;
    }
    *payload_bytes -= nb;

    return minimp4_read(mp4, nb, eof_flag);
}

/**
*   Skips given number of bytes.
*   Avoid math operations with fpos_t
*/
static void my_fseek(MP4D_demux_t *mp4, boxsize_t pos, int *eof_flag)
{
    mp4->read_pos += pos;
    if (mp4->read_pos >= mp4->read_size)
        *eof_flag = 1;
}

#define READ(n) read_payload(mp4, n, &payload_bytes, &eof_flag)
#define SKIP(n) { boxsize_t t = MINIMP4_MIN(payload_bytes, n); my_fseek(mp4, t, &eof_flag); payload_bytes -= t; }

/**
*   Several box handlers (stsc, stts, stco/co64, DSI/tag buffers, ...) use a
*   count or size field read directly from the file as a malloc()/realloc()
*   size, with no bound against how much data the file could actually back.
*   Fuzzing the demuxer's open/parse path found multiple ~3GB and ~16GB
*   allocation requests derived from a few hundred bytes of crafted input.
*   Route every parse-time (re)allocation through one sanity ceiling instead
*   of auditing (and re-auditing) each call site individually: no legitimate
*   Hap MOV needs anywhere near this much memory for a single metadata
*   table.
*/
enum { MINIMP4_MAX_ALLOC_BYTES = 256u * 1024u * 1024u };

static void *minimp4_bounded_malloc(size_t size)
{
    if (size > MINIMP4_MAX_ALLOC_BYTES)
        return NULL;
    return malloc(size);
}

// A malformed file can carry the same table box (stsc, stco/co64, ...)
// twice for one track; re-running a MALLOC for a field that's already
// populated would leak the earlier block. free() is a no-op on NULL, so
// this is free for the (common) single-occurrence case.
#define MALLOC(t, p, size) { free(p); p = (t)minimp4_bounded_malloc(size); if (!(p)) { ERROR("out of memory"); } }

/*
*   On error: release resources.
*/
#define RETURN_ERROR(mess) {        \
    TRACE(("\nMP4 ERROR: " mess));  \
    MP4D_close(mp4);                \
    return 0;                       \
}

/*
*   Any errors, occurred on top-level hierarchy is passed to exit check: 'if (!mp4->track_count) ... '
*/
#define ERROR(mess)  \
    if (!depth)      \
        break;       \
    else             \
        RETURN_ERROR(mess);

typedef enum { BOX_ATOM, BOX_OD } boxtype_t;

int MP4D_open(MP4D_demux_t *mp4, int (*read_callback)(int64_t offset, void *buffer, size_t size, void *token), void *token, int64_t file_size)
{
    // box stack size
    int depth = 0;

    struct
    {
        // remaining bytes for box in the stack
        boxsize_t bytes;

        // kind of box children's: OD chunks handled in the same manner as name chunks
        boxtype_t format;

    } stack[MAX_CHUNKS_DEPTH];

#if MP4D_TRACE_SUPPORTED
    // path of current element: List0/List1/... etc
    uint32_t box_path[MAX_CHUNKS_DEPTH];
#endif

    int eof_flag = 0;
    unsigned i;
    MP4D_track_t *tr = NULL;

    if (!mp4 || !read_callback)
    {
        TRACE(("\nERROR: invlaid arguments!"));
        return 0;
    }

    memset(mp4, 0, sizeof(MP4D_demux_t));
    mp4->read_callback = read_callback;
    mp4->token = token;
    mp4->read_size = file_size;

    stack[0].format = BOX_ATOM;   // start with atom box
    stack[0].bytes = 0;           // never accessed

    do
    {
        // List of boxes, derived from 'FullBox'
        //                ~~~~~~~~~~~~~~~~~~~~~
        // need read version field and check version for these boxes
        static const struct
        {
            uint32_t name;
            unsigned max_version;
            unsigned use_track_flag;
        } g_fullbox[] =
        {
#if MP4D_INFO_SUPPORTED
            {BOX_mdhd, 1, 1},
            {BOX_mvhd, 1, 0},
            {BOX_hdlr, 0, 0},
            {BOX_meta, 0, 0},   // Android can produce meta box without 'FullBox' field, comment this line to simulate the bug
#endif
#if MP4D_TRACE_TIMESTAMPS
            {BOX_stts, 0, 0},
            {BOX_ctts, 0, 0},
#endif
            {BOX_stz2, 0, 1},
            {BOX_stsz, 0, 1},
            {BOX_stsc, 0, 1},
            {BOX_stco, 0, 1},
            {BOX_co64, 0, 1},
            {BOX_stsd, 0, 0},
            {BOX_esds, 0, 1}    // esds does not use track, but switches to OD mode. Check here, to avoid OD check
        };

        // List of boxes, which contains other boxes ('envelopes')
        // Parser will descend down for boxes in this list, otherwise parsing will proceed to
        // the next sibling box
        // OD boxes handled in the same way as atom boxes...
        static const struct
        {
            uint32_t name;
            boxtype_t type;
        } g_envelope_box[] =
        {
            {BOX_esds, BOX_OD},     // TODO: BOX_esds can be used for both audio and video, but this code supports audio only!
            {OD_ESD,   BOX_OD},
            {OD_DCD,   BOX_OD},
            {OD_DSI,   BOX_OD},
            {BOX_trak, BOX_ATOM},
            {BOX_moov, BOX_ATOM},
            //{BOX_moof, BOX_ATOM},
            {BOX_mdia, BOX_ATOM},
            {BOX_tref, BOX_ATOM},
            {BOX_minf, BOX_ATOM},
            {BOX_dinf, BOX_ATOM},
            {BOX_stbl, BOX_ATOM},
            {BOX_stsd, BOX_ATOM},
            {BOX_mp4a, BOX_ATOM},
            {BOX_mp4s, BOX_ATOM},
#if MP4D_AVC_SUPPORTED
            {BOX_mp4v, BOX_ATOM},
            {BOX_avc1, BOX_ATOM},
            //{BOX_avc2, BOX_ATOM},
            //{BOX_svc1, BOX_ATOM},
#endif
#if MP4D_HEVC_SUPPORTED
            {BOX_hvc1, BOX_ATOM},
#endif
            {BOX_udta, BOX_ATOM},
            {BOX_meta, BOX_ATOM},
            {BOX_ilst, BOX_ATOM}
        };

        uint32_t FullAtomVersionAndFlags = 0;
        boxsize_t payload_bytes;
        boxsize_t box_bytes;
        uint32_t box_name;
#if MP4D_INFO_SUPPORTED
        unsigned char **ptag = NULL;
#endif
        int read_bytes = 0;

        // Read header box type and it's length
        if (stack[depth].format == BOX_ATOM)
        {
            box_bytes = minimp4_read(mp4, 4, &eof_flag);
#if FIX_BAD_ANDROID_META_BOX
broken_android_meta_hack:
#endif
            if (eof_flag)
                break;  // normal exit

            if (box_bytes >= 2 && box_bytes < 8)
            {
                ERROR("invalid box size (broken file?)");
            }

            box_name  = minimp4_read(mp4, 4, &eof_flag);
            read_bytes = 8;

            // Decode box size
            if (box_bytes == 0 ||                         // standard indication of 'till eof' size
                box_bytes == (boxsize_t)0xFFFFFFFFU       // some files uses non-standard 'till eof' signaling
                )
            {
                box_bytes = ~(boxsize_t)0;
            }

            payload_bytes = box_bytes - 8;

            if (box_bytes == 1)           // 64-bit sizes
            {
                TRACE(("\n64-bit chunk encountered"));

                box_bytes = minimp4_read(mp4, 4, &eof_flag);
#if MP4D_64BIT_SUPPORTED
                box_bytes <<= 32;
                box_bytes |= minimp4_read(mp4, 4, &eof_flag);
#else
                if (box_bytes)
                {
                    ERROR("UNSUPPORTED FEATURE: MP4BoxHeader(): 64-bit boxes not supported!");
                }
                box_bytes = minimp4_read(mp4, 4, &eof_flag);
#endif
                if (box_bytes < 16)
                {
                    ERROR("invalid box size (broken file?)");
                }
                payload_bytes = box_bytes - 16;
            }

            // Read and check box version for some boxes
            for (i = 0; i < NELEM(g_fullbox); i++)
            {
                if (box_name == g_fullbox[i].name)
                {
                    FullAtomVersionAndFlags = READ(4);
                    read_bytes += 4;

#if FIX_BAD_ANDROID_META_BOX
                    // Fix invalid BOX_meta, found in some Android-produced MP4
                    // This branch is optional: bad box would be skipped
                    if (box_name == BOX_meta)
                    {
                        if (FullAtomVersionAndFlags >= 8 &&  FullAtomVersionAndFlags < payload_bytes)
                        {
                            if (box_bytes > stack[depth].bytes)
                            {
                                ERROR("broken file structure!");
                            }
                            stack[depth].bytes -= box_bytes;;
                            depth++;
                            stack[depth].bytes = payload_bytes + 4; // +4 need for missing header
                            stack[depth].format = BOX_ATOM;
                            box_bytes = FullAtomVersionAndFlags;
                            TRACE(("Bad metadata box detected (Android bug?)!\n"));
                            goto broken_android_meta_hack;
                        }
                    }
#endif // FIX_BAD_ANDROID_META_BOX

                    if ((FullAtomVersionAndFlags >> 24) > g_fullbox[i].max_version)
                    {
                        ERROR("unsupported box version!");
                    }
                    if (g_fullbox[i].use_track_flag && !tr)
                    {
                        ERROR("broken file structure!");
                    }
                }
            }
        } else // stack[depth].format == BOX_OD
        {
            int val;
            box_name = OD_BASE + minimp4_read(mp4, 1, &eof_flag);     // 1-byte box type
            read_bytes += 1;
            if (eof_flag)
                break;

            payload_bytes = 0;
            box_bytes = 1;
            do
            {
                val = minimp4_read(mp4, 1, &eof_flag);
                read_bytes += 1;
                if (eof_flag)
                {
                    ERROR("premature EOF!");
                }
                payload_bytes = (payload_bytes << 7) | (val & 0x7F);
                box_bytes++;
            } while (val & 0x80);
            box_bytes += payload_bytes;
        }

#if MP4D_TRACE_SUPPORTED
        box_path[depth] = (box_name >> 24) | (box_name << 24) | ((box_name >> 8) & 0x0000FF00) | ((box_name << 8) & 0x00FF0000);
        TRACE(("%2d  %8d %.*s  (%d bytes remains for sibilings) \n", depth, (int)box_bytes, depth*4, (char*)box_path, (int)stack[depth].bytes));
#endif

        // Check that box size <= parent size
        if (depth)
        {
            // Skip box with bad size
            assert(box_bytes > 0);
            if (box_bytes > stack[depth].bytes)
            {
                TRACE(("Wrong %c%c%c%c box size: broken file?\n", (box_name >> 24)&255, (box_name >> 16)&255, (box_name >> 8)&255, box_name&255));
                box_bytes = stack[depth].bytes;
                box_name = 0;
                payload_bytes = box_bytes - read_bytes;
            }
            stack[depth].bytes -= box_bytes;
        }

        // Read box header
        switch(box_name)
        {
        case BOX_stz2:  //ISO/IEC 14496-1 Page 38. Section 8.17.2 - Sample Size Box.
        case BOX_stsz:
            {
                int size = 0;
                uint32_t sample_size;
                // Hardening fix: g_fullbox[]'s use_track_flag check only
                // hard-aborts via RETURN_ERROR at depth > 0 -- at depth 0
                // (this box appearing with no enclosing moov/trak) ERROR()
                // just breaks out of that unrelated lookup loop and parsing
                // continues into this case with tr still NULL. Fuzzer
                // found this as a null-pointer deref from a lone top-level
                // "stsz" box.
                if (!tr)
                    break;
                sample_size = READ(4);
                tr->sample_count = READ(4);
                // (uint64_t) cast: sample_count*4 as native 32-bit
                // arithmetic can itself overflow/wrap before reaching
                // minimp4_bounded_malloc's size check, allocating far
                // fewer bytes than the loop below then writes -- a
                // fuzzer-found heap-buffer-overflow.
                MALLOC(unsigned int*, tr->entry_size, (uint64_t)tr->sample_count*4);
                for (i = 0; i < tr->sample_count; i++)
                {
                    if (box_name == BOX_stsz)
                    {
                       tr->entry_size[i] = (sample_size?sample_size:READ(4));
                    } else
                    {
                        switch (sample_size & 0xFF)
                        {
                        case 16:
                            tr->entry_size[i] = READ(2);
                            break;
                        case  8:
                            tr->entry_size[i] = READ(1);
                            break;
                        case  4:
                            if (i & 1)
                            {
                                tr->entry_size[i] = size & 15;
                            } else
                            {
                                size = READ(1);
                                tr->entry_size[i] = (size >> 4);
                            }
                            break;
                        }
                    }
                }
            }
            break;

        case BOX_stsc:  //ISO/IEC 14496-12 Page 38. Section 8.18 - Sample To Chunk Box.
            // Same depth-0 use_track_flag gap as BOX_stsz above.
            if (!tr)
                break;
            tr->sample_to_chunk_count = READ(4);
            MALLOC(MP4D_sample_to_chunk_t*, tr->sample_to_chunk, tr->sample_to_chunk_count*sizeof(tr->sample_to_chunk[0]));
            for (i = 0; i < tr->sample_to_chunk_count; i++)
            {
                tr->sample_to_chunk[i].first_chunk = READ(4);
                tr->sample_to_chunk[i].samples_per_chunk = READ(4);
                SKIP(4);    // sample_description_index
            }
            break;
#if MP4D_TRACE_TIMESTAMPS || MP4D_TIMESTAMPS_SUPPORTED
        case BOX_stts:
            {
                unsigned count, j, k = 0, ts = 0, ts_capacity;
                // Hardening fix: g_fullbox[] registers BOX_stts with
                // use_track_flag=0 (no active-track requirement enforced
                // before this case runs), but the MP4D_TIMESTAMPS_SUPPORTED
                // branch below unconditionally writes through tr -- a
                // top-level "stts" box with no enclosing trak (tr still
                // NULL) reaches this with no track allocated yet. Fuzzer
                // found this as a null-pointer deref from an 8-byte file.
                if (!tr)
                    break;
                count = READ(4);
                ts_capacity = count;
#if MP4D_TIMESTAMPS_SUPPORTED
                // (uint64_t) cast: same native-arithmetic-overflow gap as
                // BOX_stsz's entry_size MALLOC above, on this box's own
                // unbounded, file-declared count.
                MALLOC(unsigned int*, tr->timestamp, (uint64_t)ts_capacity*4);
                MALLOC(unsigned int*, tr->duration, (uint64_t)ts_capacity*4);
                tr->timestamp_count = 0;
#endif

                for (i = 0; i < count; i++)
                {
                    unsigned sc = READ(4);
                    int d =  READ(4);
                    uint64_t new_k = (uint64_t)k + sc; // avoid 32-bit wraparound below
                    TRACE(("sample %8d count %8d duration %8d\n", i, sc, d));
#if MP4D_TIMESTAMPS_SUPPORTED
                    if (new_k > ts_capacity)
                    {
                        // sc is an attacker-controlled per-entry sample
                        // count; k + sc as plain "unsigned" arithmetic can
                        // wrap past ts_capacity, skipping this resize while
                        // the write loop below still walks `sc` entries --
                        // a fuzzer-found heap-buffer-overflow. Widening the
                        // addition avoids the wrap.
                        //
                        // Growing to exactly new_k on every entry (rather
                        // than with slack) meant a file with many entries
                        // that each grow the running total by only a
                        // little forced a full realloc -- and full copy --
                        // on every single one: a fuzzer-found multi-second
                        // "realloc thrashing" hang distinct from a single
                        // oversized allocation. Double capacity instead
                        // (still bounded to MINIMP4_MAX_ALLOC_BYTES, which
                        // is what catches the ~16GB single-request case
                        // fuzzing also found), same amortized-growth
                        // rationale as std::vector.
                        //
                        // Checking the bound *before* reallocating matters
                        // too: rejecting after the fact would mean
                        // overwriting one perfectly good pointer (from the
                        // MALLOC above) with the other realloc's NULL
                        // result, leaking the one that had succeeded.
                        // Leaving both pointers untouched on rejection lets
                        // MP4D_close() free them normally, same as any
                        // other error path.
                        uint64_t new_capacity = (uint64_t)ts_capacity * 2;
                        if (new_capacity < new_k)
                            new_capacity = new_k;
                        if (new_capacity * sizeof(unsigned) > MINIMP4_MAX_ALLOC_BYTES)
                            new_capacity = MINIMP4_MAX_ALLOC_BYTES / sizeof(unsigned);
                        if (new_capacity < new_k)
                        {
                            ERROR("out of memory");
                        }
                        ts_capacity = (unsigned)new_capacity;
                        tr->timestamp = (unsigned int*)realloc(tr->timestamp, ts_capacity * sizeof(unsigned));
                        tr->duration  = (unsigned int*)realloc(tr->duration,  ts_capacity * sizeof(unsigned));
                        if (!tr->timestamp || !tr->duration)
                        {
                            ERROR("out of memory");
                        }
                    }
                    for (j = 0; j < sc; j++)
                    {
                        tr->duration[k] = d;
                        tr->timestamp[k++] = ts;
                        ts += d;
                    }
                    tr->timestamp_count = k;
#endif
                }
            }
            break;
        case BOX_ctts:
            {
                unsigned count = READ(4);
                for (i = 0; i < count; i++)
                {
                    int sc, d;
                    // count is an unbounded, file-declared value with no
                    // backing allocation to size-check against (unlike
                    // stsz/stsc/stco/stts above); once the box's actual
                    // payload is exhausted, READ() keeps returning
                    // zero-padding forever rather than stopping the loop,
                    // so a file can claim ~4 billion entries in a few
                    // bytes on disk. Fuzzer found this as a multi-second
                    // hang (pure wasted CPU, not memory-unsafe on its own).
                    if (payload_bytes == 0)
                        break;
                    sc = READ(4);
                    d =  READ(4);
                    (void)sc;
                    (void)d;
                    TRACE(("sample %8d count %8d decoding to composition offset %8d\n", i, sc, d));
                }
            }
            break;
#endif
        case BOX_stco:  //ISO/IEC 14496-12 Page 39. Section 8.19 - Chunk Offset Box.
        case BOX_co64:
            // Same depth-0 use_track_flag gap as BOX_stsz above.
            if (!tr)
                break;
            tr->chunk_count = READ(4);
            MALLOC(MP4D_file_offset_t*, tr->chunk_offset, tr->chunk_count*sizeof(MP4D_file_offset_t));
            for (i = 0; i < tr->chunk_count; i++)
            {
                tr->chunk_offset[i] = READ(4);
                if (box_name == BOX_co64)
                {
#if !MP4D_64BIT_SUPPORTED
                    if (tr->chunk_offset[i])
                    {
                        ERROR("UNSUPPORTED FEATURE: 64-bit chunk_offset not supported!");
                    }
#endif
                    tr->chunk_offset[i] <<= 32;
                    tr->chunk_offset[i] |= READ(4);
                }
            }
            break;

#if MP4D_INFO_SUPPORTED
        case BOX_mvhd:
            SKIP(((FullAtomVersionAndFlags >> 24) == 1) ? 8 + 8 : 4 + 4);
            mp4->timescale = READ(4);
            mp4->duration_hi = ((FullAtomVersionAndFlags >> 24) == 1) ? READ(4) : 0;
            mp4->duration_lo = READ(4);
            SKIP(4 + 2 + 2 + 4*2 + 4*9 + 4*6 + 4);
            break;

        case BOX_mdhd:
            // Same depth-0 use_track_flag gap as BOX_stsz above.
            if (!tr)
                break;
            SKIP(((FullAtomVersionAndFlags >> 24) == 1) ? 8 + 8 : 4 + 4);
            tr->timescale = READ(4);
            tr->duration_hi = ((FullAtomVersionAndFlags >> 24) == 1) ? READ(4) : 0;
            tr->duration_lo = READ(4);

            {
                int ISO_639_2_T = READ(2);
                tr->language[2] = (ISO_639_2_T & 31) + 0x60; ISO_639_2_T >>= 5;
                tr->language[1] = (ISO_639_2_T & 31) + 0x60; ISO_639_2_T >>= 5;
                tr->language[0] = (ISO_639_2_T & 31) + 0x60;
            }
            // the rest of this box is skipped by default ...
            break;

        case BOX_hdlr:
            if (tr) // When this box is within 'meta' box, the track may not be avaialable
            {
                SKIP(4); // pre_defined
                tr->handler_type = READ(4);
            }
            // typically hdlr box does not contain any useful info.
            // the rest of this box is skipped by default ...
            break;

        case BOX_btrt:
            if (!tr)
            {
                ERROR("broken file structure!");
            }

            SKIP(4 + 4);
            tr->avg_bitrate_bps = READ(4);
            break;

            // Set pointer to tag to be read...
        case BOX_calb: ptag = &mp4->tag.album;   break;
        case BOX_cART: ptag = &mp4->tag.artist;  break;
        case BOX_cnam: ptag = &mp4->tag.title;   break;
        case BOX_cday: ptag = &mp4->tag.year;    break;
        case BOX_ccmt: ptag = &mp4->tag.comment; break;
        case BOX_cgen: ptag = &mp4->tag.genre;   break;

#endif

        case BOX_stsd:
            SKIP(4); // entry_count, BOX_mp4a & BOX_mp4v boxes follows immediately
            break;

        case BOX_mp4s:  // private stream
            if (!tr)
            {
                ERROR("broken file structure!");
            }
            SKIP(6*1 + 2/*Base SampleEntry*/);
            break;

        case BOX_mp4a:
            if (!tr)
            {
                ERROR("broken file structure!");
            }
#if MP4D_INFO_SUPPORTED
            SKIP(6*1+2/*Base SampleEntry*/  + 4*2);
            tr->SampleDescription.audio.channelcount = READ(2);
            SKIP(2/*samplesize*/ + 2 + 2);
            tr->SampleDescription.audio.samplerate_hz = READ(4) >> 16;
#else
            SKIP(28);
#endif
            break;

#if MP4D_AVC_SUPPORTED
        case BOX_avc1:  // AVCSampleEntry extends VisualSampleEntry
//         case BOX_avc2:   - no test
//         case BOX_svc1:   - no test
        case BOX_mp4v:
            if (!tr)
            {
                ERROR("broken file structure!");
            }
#if MP4D_INFO_SUPPORTED
            SKIP(6*1 + 2/*Base SampleEntry*/ + 2 + 2 + 4*3);
            tr->SampleDescription.video.width  = READ(2);
            tr->SampleDescription.video.height = READ(2);
            // frame_count is always 1
            // compressorname is rarely set..
            SKIP(4 + 4 + 4 + 2/*frame_count*/ + 32/*compressorname*/ + 2 + 2);
#else
            SKIP(78);
#endif
            // ^^^ end of VisualSampleEntry
            // now follows for BOX_avc1:
            //      BOX_avcC
            //      BOX_btrt (optional)
            //      BOX_m4ds (optional)
            // for BOX_mp4v:
            //      BOX_esds
            break;

        case BOX_avcC:  // AVCDecoderConfigurationRecord()
            // hack: AAC-specific DSI field reused (for it have same purpoose as sps/pps)
            // TODO: check this hack if BOX_esds co-exist with BOX_avcC
            // Hardening fix: unlike its sibling cases (mp4s/mp4a/avc1/mp4v),
            // this one had no tr-null guard at all -- a lone top-level
            // "avcC" box reaches this with tr still NULL.
            if (!tr)
                break;
            tr->object_type_indication = MP4_OBJECT_TYPE_AVC;
            tr->dsi = (unsigned char*)minimp4_bounded_malloc((size_t)box_bytes);
            if (!tr->dsi)
            {
                ERROR("out of memory");
            }
            tr->dsi_bytes = (unsigned)box_bytes;
            {
                int spspps;
                unsigned char *p = tr->dsi;
                unsigned int configurationVersion = READ(1);
                unsigned int AVCProfileIndication = READ(1);
                unsigned int profile_compatibility = READ(1);
                unsigned int AVCLevelIndication = READ(1);
                //bit(6) reserved =
                unsigned int lengthSizeMinusOne = READ(1) & 3;

                (void)configurationVersion;
                (void)AVCProfileIndication;
                (void)profile_compatibility;
                (void)AVCLevelIndication;
                (void)lengthSizeMinusOne;

                for (spspps = 0; spspps < 2; spspps++)
                {
                    unsigned int numOfSequenceParameterSets= READ(1);
                    if (!spspps)
                    {
                         numOfSequenceParameterSets &= 31;  // clears 3 msb for SPS
                    }
                    *p++ = numOfSequenceParameterSets;
                    for (i = 0; i < numOfSequenceParameterSets; i++)
                    {
                        unsigned k, sequenceParameterSetLength = READ(2);
                        *p++ = sequenceParameterSetLength >> 8;
                        *p++ = sequenceParameterSetLength ;
                        for (k = 0; k < sequenceParameterSetLength; k++)
                        {
                            *p++ = READ(1);
                        }
                    }
                }
            }
            break;
#endif  // MP4D_AVC_SUPPORTED

        case OD_ESD:
            {
                unsigned flags = READ(3);   // ES_ID(2) + flags(1)

                if (flags & 0x80)       // steamdependflag
                {
                    SKIP(2);            // dependsOnESID
                }
                if (flags & 0x40)       // urlflag
                {
                    unsigned bytecount = READ(1);
                    SKIP(bytecount);    // skip URL
                }
                if (flags & 0x20)       // ocrflag (was reserved in MPEG-4 v.1)
                {
                    SKIP(2);            // OCRESID
                }
                break;
            }

        case OD_DCD:        //ISO/IEC 14496-1 Page 28. Section 8.6.5 - DecoderConfigDescriptor.
            // g_fullbox[] doesn't actually gate OD_DCD/OD_DSI (that table
            // covers ISOBMFF full boxes, not MPEG-4 object descriptors), so
            // a DecoderConfigDescriptor/DecSpecificInfo can be reached with
            // no active track (asserts are compiled out under NDEBUG) --
            // hardening fix for a fuzzer-found null-pointer deref.
            if (!tr)
                break;
            tr->object_type_indication = READ(1);
#if MP4D_INFO_SUPPORTED
            tr->stream_type = READ(1) >> 2;
            SKIP(3/*bufferSizeDB*/ + 4/*maxBitrate*/);
            tr->avg_bitrate_bps = READ(4);
#else
            SKIP(1+3+4+4);
#endif
            break;

        case OD_DSI:        //ISO/IEC 14496-1 Page 28. Section 8.6.5 - DecoderConfigDescriptor.
            if (!tr)
                break;
            if (!tr->dsi && payload_bytes)
            {
                // (uint64_t) cast, not (int): truncating a large
                // payload_bytes to int before minimp4_bounded_malloc's
                // size check can produce a small (or, if negative,
                // implementation-defined-but-typically-huge-when-
                // reinterpreted-as-size_t) allocation while the write loop
                // below still walks the untruncated payload_bytes count --
                // a fuzzer-found heap-buffer-overflow.
                MALLOC(unsigned char*, tr->dsi, (uint64_t)payload_bytes);
                for (i = 0; i < payload_bytes; i++)
                {
                    tr->dsi[i] = minimp4_read(mp4, 1, &eof_flag);    // These bytes available due to check above
                }
                tr->dsi_bytes = i;
                payload_bytes -= i;
                break;
            }

        default:
            TRACE(("[%c%c%c%c]  %d\n", box_name >> 24, box_name >> 16, box_name >> 8, box_name, (int)payload_bytes));
        }

#if MP4D_INFO_SUPPORTED
        // Read tag is tag pointer is set
        if (ptag && !*ptag && payload_bytes > 16)
        {
#if 0
            uint32_t size = READ(4);
            uint32_t data = READ(4);
            uint32_t class = READ(4);
            uint32_t x1 = READ(4);
            TRACE(("%2d  %2d %2d ", size, class, x1));
#else
            SKIP(4 + 4 + 4 + 4);
#endif
            // (uint64_t) cast: same truncate-before-size-check gap as
            // OD_DSI's dsi buffer above.
            MALLOC(unsigned char*, *ptag, (uint64_t)payload_bytes + 1);
            for (i = 0; payload_bytes != 0; i++)
            {
                (*ptag)[i] = READ(1);
            }
            (*ptag)[i] = 0; // zero-terminated string
        }
#endif

        if (box_name == BOX_trak)
        {
            // New track found: allocate memory using realloc()
            // Typically there are 1 audio track for AAC audio file,
            // 4 tracks for movie file,
            // 3-5 tracks for scalable audio (CELP+AAC)
            // and up to 50 tracks for BSAC scalable audio
            void *mem = realloc(mp4->track, (mp4->track_count + 1)*sizeof(MP4D_track_t));
            if (!mem)
            {
                // if realloc fails, it does not deallocate old pointer!
                ERROR("out of memory");
            }
            mp4->track = (MP4D_track_t*)mem;
            tr = mp4->track + mp4->track_count++;
            memset(tr, 0, sizeof(MP4D_track_t));
        } else if (box_name == BOX_meta)
        {
            tr = NULL;  // Avoid update of 'hdlr' box, which may contains in the 'meta' box
        }

        // If this box is envelope, save it's size in box stack
        for (i = 0; i < NELEM(g_envelope_box); i++)
        {
            if (box_name == g_envelope_box[i].name)
            {
                if (++depth >= MAX_CHUNKS_DEPTH)
                {
                    ERROR("too deep atoms nesting!");
                }
                stack[depth].bytes = payload_bytes;
                stack[depth].format = g_envelope_box[i].type;
                break;
            }
        }

        // if box is not envelope, just skip it
        if (i == NELEM(g_envelope_box))
        {
            if (payload_bytes > file_size)
            {
                eof_flag = 1;
            } else
            {
                SKIP(payload_bytes);
            }
        }

        // remove empty boxes from stack
        // don't touch box with index 0 (which indicates whole file)
        while (depth > 0 && !stack[depth].bytes)
        {
            depth--;
        }

    } while(!eof_flag);

    if (!mp4->track_count)
    {
        RETURN_ERROR("no tracks found");
    }
    return 1;
}

/**
*   Find chunk, containing given sample.
*   Returns chunk number, and first sample in this chunk.
*/
static int sample_to_chunk(MP4D_track_t *tr, unsigned nsample, unsigned *nfirst_sample_in_chunk)
{
    unsigned chunk_group, nc, sum;
    *nfirst_sample_in_chunk = 0;
    if (tr->chunk_count == 0)
    {
        // Hardening fix: a track can declare samples (stsz) with no chunk
        // offset table (missing/empty stco/co64) in a malformed file. The
        // original "<= 1" fast path treated this the same as the legitimate
        // single-chunk case and returned chunk 0, which made callers index
        // into a chunk_offset array that was never allocated.
        return -1;
    }
    if (tr->chunk_count == 1)
    {
        return 0;
    }
    if (!tr->sample_to_chunk || tr->sample_to_chunk_count == 0)
    {
        // Hardening fix: a track can have a stco/co64 (chunk_count > 1)
        // with no stsc box at all (tr->sample_to_chunk NULL), or a stsc
        // box declaring zero entries. The loop below dereferences
        // tr->sample_to_chunk[chunk_group] unconditionally on its very
        // first iteration regardless of tr->sample_to_chunk_count --
        // fuzzer found the NULL case as a null-pointer deref.
        return -1;
    }

    // Sequential demuxing queries nsample in strictly increasing order,
    // once per sample -- rescanning from nc=0 every time (the original
    // behavior, and its own "TODO: this can be calculated once per file"
    // above) makes a full demux O(chunk_count * sample_count). Fuzzing
    // found this as a multi-second hang from a crafted file with a
    // large-but-not-otherwise-invalid chunk count. Resume from the last
    // call's stopping point whenever this query doesn't need to look
    // earlier than it did.
    if (nsample >= tr->stc_cache_sum && tr->stc_cache_nc < tr->chunk_count)
    {
        chunk_group = tr->stc_cache_group;
        nc = tr->stc_cache_nc;
        sum = tr->stc_cache_sum;
        *nfirst_sample_in_chunk = sum;
    }
    else
    {
        chunk_group = 0;
        nc = 0;
        sum = 0;
    }

    for (; nc < tr->chunk_count; nc++)
    {
        if (chunk_group + 1 < tr->sample_to_chunk_count     // stuck at last entry till EOF
            && nc + 1 ==    // Chunks counted starting with '1'
            tr->sample_to_chunk[chunk_group + 1].first_chunk)    // next group?
        {
            chunk_group++;
        }

        sum += tr->sample_to_chunk[chunk_group].samples_per_chunk;
        if (nsample < sum)
        {
            tr->stc_cache_group = chunk_group;
            tr->stc_cache_nc = nc + 1;
            tr->stc_cache_sum = sum;
            return nc;
        }

        *nfirst_sample_in_chunk = sum;
    }

    tr->stc_cache_group = chunk_group;
    tr->stc_cache_nc = nc;
    tr->stc_cache_sum = sum;
    return -1;
}

// Exported API function
MP4D_file_offset_t MP4D_frame_offset(const MP4D_demux_t *mp4, unsigned ntrack, unsigned nsample, unsigned *frame_bytes, unsigned *timestamp, unsigned *duration)
{
    MP4D_track_t *tr = mp4->track + ntrack;
    unsigned ns;
    int nchunk = sample_to_chunk(tr, nsample, &ns);
    MP4D_file_offset_t offset;

    if (nchunk < 0 || !tr->chunk_offset || !tr->entry_size)
    {
        // Hardening fix: chunk_count/sample_count and their matching
        // chunk_offset/entry_size arrays are set together within a single
        // box handler, but a malformed file can hit that handler's
        // out-of-memory ERROR() path (e.g. a duplicate stco/stsz declaring
        // an oversized count the second time around) after already
        // free()-ing the previous, valid allocation -- and, at depth 0,
        // ERROR() doesn't actually abort the parse (see the box handlers'
        // own comments on this), so a later chunk_count/sample_count can
        // end up numerically valid while its array stayed NULL. Fuzzer
        // found this as a null-pointer deref.
        *frame_bytes = 0;
        return 0;
    }

    // Resuming from a per-track cache the same way sample_to_chunk() does
    // above: summing entry_size from the chunk's first sample up to
    // `nsample` on every single call makes a demux with a few large chunks
    // (e.g. one giant chunk holding every sample) O(chunk_size^2) even
    // though sample_to_chunk() itself never has to rescan in that case.
    // Fuzzing found this as a multi-second hang distinct from (and not
    // fixed by) the sample_to_chunk() memoization.
    if (tr->fo_cache_valid && nsample == tr->fo_cache_nsample + 1 && ns <= tr->fo_cache_nsample)
    {
        offset = tr->fo_cache_offset;
        ns = nsample;
    }
    else
    {
        offset = tr->chunk_offset[nchunk];
    }

    for (; ns < nsample; ns++)
    {
        offset += tr->entry_size[ns];
    }

    *frame_bytes = tr->entry_size[ns];

    tr->fo_cache_valid = 1;
    tr->fo_cache_nsample = nsample;
    tr->fo_cache_offset = offset + tr->entry_size[ns];

    if (timestamp)
    {
#if MP4D_TIMESTAMPS_SUPPORTED
        // Hardening fix: a track can have samples (from stsz) but no stts
        // box at all, leaving tr->timestamp/tr->duration NULL from the
        // MP4D_track_t's zero-init; or a malformed file's stsz sample_count
        // can simply exceed the total sample count its own stts box
        // declared, leaving `ns` past the end of both arrays even though
        // they're non-NULL. Fuzzer found both as reads on a malformed file.
        *timestamp = (tr->timestamp && ns < tr->timestamp_count) ? tr->timestamp[ns] : 0;
#else
        *timestamp = 0;
#endif
    }
    if (duration)
    {
#if MP4D_TIMESTAMPS_SUPPORTED
        *duration = (tr->duration && ns < tr->timestamp_count) ? tr->duration[ns] : 0;
#else
        *duration = 0;
#endif
    }

    return offset;
}

#define FREE(x) if (x) {free(x); x = NULL;}

// Exported API function
void MP4D_close(MP4D_demux_t *mp4)
{
    while (mp4->track_count)
    {
        MP4D_track_t *tr = mp4->track + --mp4->track_count;
        FREE(tr->entry_size);
#if MP4D_TIMESTAMPS_SUPPORTED
        FREE(tr->timestamp);
        FREE(tr->duration);
#endif
        FREE(tr->sample_to_chunk);
        FREE(tr->chunk_offset);
        FREE(tr->dsi);
    }
    FREE(mp4->track);
#if MP4D_INFO_SUPPORTED
    FREE(mp4->tag.title);
    FREE(mp4->tag.artist);
    FREE(mp4->tag.album);
    FREE(mp4->tag.year);
    FREE(mp4->tag.comment);
    FREE(mp4->tag.genre);
#endif
}

static int skip_spspps(const unsigned char *p, int nbytes, int nskip)
{
    int i, k = 0;
    for (i = 0; i < nskip; i++)
    {
        unsigned segmbytes;
        if (k > nbytes - 2)
            return -1;
        segmbytes = p[k]*256 + p[k+1];
        k += 2 + segmbytes;
    }
    return k;
}

static const void *MP4D_read_spspps(const MP4D_demux_t *mp4, unsigned int ntrack, int pps_flag, int nsps, int *sps_bytes)
{
    int sps_count, skip_bytes;
    int bytepos = 0;
    unsigned char *p = mp4->track[ntrack].dsi;
    if (ntrack >= mp4->track_count)
        return NULL;
    if (mp4->track[ntrack].object_type_indication != MP4_OBJECT_TYPE_AVC)
        return NULL;    // SPS/PPS are specific for AVC format only

    if (pps_flag)
    {
        // Skip all SPS
        sps_count = p[bytepos++];
        skip_bytes = skip_spspps(p+bytepos, mp4->track[ntrack].dsi_bytes - bytepos, sps_count);
        if (skip_bytes < 0)
            return NULL;
        bytepos += skip_bytes;
    }

    // Skip sps/pps before the given target
    sps_count = p[bytepos++];
    if (nsps >= sps_count)
        return NULL;
    skip_bytes = skip_spspps(p+bytepos, mp4->track[ntrack].dsi_bytes - bytepos, nsps);
    if (skip_bytes < 0)
        return NULL;
    bytepos += skip_bytes;
    *sps_bytes = p[bytepos]*256 + p[bytepos+1];
    return p + bytepos + 2;
}


const void *MP4D_read_sps(const MP4D_demux_t *mp4, unsigned int ntrack, int nsps, int *sps_bytes)
{
    return MP4D_read_spspps(mp4, ntrack, 0, nsps, sps_bytes);
}

const void *MP4D_read_pps(const MP4D_demux_t *mp4, unsigned int ntrack, int npps, int *pps_bytes)
{
    return MP4D_read_spspps(mp4, ntrack, 1, npps, pps_bytes);
}

#if MP4D_PRINT_INFO_SUPPORTED
/************************************************************************/
/*  Purely informational part, may be removed for embedded applications */
/************************************************************************/

//
// Decodes ISO/IEC 14496 MP4 stream type to ASCII string
//
static const char *GetMP4StreamTypeName(int streamType)
{
    switch (streamType)
    {
    case 0x00: return "Forbidden";
    case 0x01: return "ObjectDescriptorStream";
    case 0x02: return "ClockReferenceStream";
    case 0x03: return "SceneDescriptionStream";
    case 0x04: return "VisualStream";
    case 0x05: return "AudioStream";
    case 0x06: return "MPEG7Stream";
    case 0x07: return "IPMPStream";
    case 0x08: return "ObjectContentInfoStream";
    case 0x09: return "MPEGJStream";
    default:
        if (streamType >= 0x20 && streamType <= 0x3F)
        {
            return "User private";
        } else
        {
            return "Reserved for ISO use";
        }
    }
}

//
// Decodes ISO/IEC 14496 MP4 object type to ASCII string
//
static const char *GetMP4ObjectTypeName(int objectTypeIndication)
{
    switch (objectTypeIndication)
    {
    case 0x00: return "Forbidden";
    case 0x01: return "Systems ISO/IEC 14496-1";
    case 0x02: return "Systems ISO/IEC 14496-1";
    case 0x20: return "Visual ISO/IEC 14496-2";
    case 0x40: return "Audio ISO/IEC 14496-3";
    case 0x60: return "Visual ISO/IEC 13818-2 Simple Profile";
    case 0x61: return "Visual ISO/IEC 13818-2 Main Profile";
    case 0x62: return "Visual ISO/IEC 13818-2 SNR Profile";
    case 0x63: return "Visual ISO/IEC 13818-2 Spatial Profile";
    case 0x64: return "Visual ISO/IEC 13818-2 High Profile";
    case 0x65: return "Visual ISO/IEC 13818-2 422 Profile";
    case 0x66: return "Audio ISO/IEC 13818-7 Main Profile";
    case 0x67: return "Audio ISO/IEC 13818-7 LC Profile";
    case 0x68: return "Audio ISO/IEC 13818-7 SSR Profile";
    case 0x69: return "Audio ISO/IEC 13818-3";
    case 0x6A: return "Visual ISO/IEC 11172-2";
    case 0x6B: return "Audio ISO/IEC 11172-3";
    case 0x6C: return "Visual ISO/IEC 10918-1";
    case 0xFF: return "no object type specified";
    default:
        if (objectTypeIndication >= 0xC0 && objectTypeIndication <= 0xFE)
            return "User private";
        else
            return "Reserved for ISO use";
    }
}

/**
*   Print MP4 information to stdout.
*   Subject for customization to particular application

Output Example #1: movie file

MP4 FILE: 7 tracks found. Movie time 104.12 sec

No|type|lng| duration           | bitrate| Stream type            | Object type
 0|odsm|fre|   0.00 s      1 frm|       0| Forbidden              | Forbidden
 1|sdsm|fre|   0.00 s      1 frm|       0| Forbidden              | Forbidden
 2|vide|```| 104.12 s   2603 frm| 1960559| VisualStream           | Visual ISO/IEC 14496-2   -  720x304
 3|soun|ger| 104.06 s   2439 frm|  191242| AudioStream            | Audio ISO/IEC 14496-3    -  6 ch 24000 hz
 4|soun|eng| 104.06 s   2439 frm|  194171| AudioStream            | Audio ISO/IEC 14496-3    -  6 ch 24000 hz
 5|subp|ger|  71.08 s     25 frm|       0| Forbidden              | Forbidden
 6|subp|eng|  71.08 s     25 frm|       0| Forbidden              | Forbidden

Output Example #2: audio file with tags

MP4 FILE: 1 tracks found. Movie time 92.42 sec
title = 86-Second Blowout
artist = Yo La Tengo
album = May I Sing With Me
year = 1992

No|type|lng| duration           | bitrate| Stream type            | Object type
 0|mdir|und|  92.42 s   3980 frm|  128000| AudioStream            | Audio ISO/IEC 14496-3MP4 FILE: 1 tracks found. Movie time 92.42 sec

*/
void MP4D_printf_info(const MP4D_demux_t *mp4)
{
    unsigned i;
    printf("\nMP4 FILE: %d tracks found. Movie time %.2f sec\n", mp4->track_count, (4294967296.0*mp4->duration_hi + mp4->duration_lo) / mp4->timescale);
#define STR_TAG(name) if (mp4->tag.name)  printf("%10s = %s\n", #name, mp4->tag.name)
    STR_TAG(title);
    STR_TAG(artist);
    STR_TAG(album);
    STR_TAG(year);
    STR_TAG(comment);
    STR_TAG(genre);
    printf("\nNo|type|lng| duration           | bitrate| %-23s| Object type", "Stream type");
    for (i = 0; i < mp4->track_count; i++)
    {
        MP4D_track_t *tr = mp4->track + i;

        printf("\n%2d|%c%c%c%c|%c%c%c|%7.2f s %6d frm| %7d|", i,
            (tr->handler_type >> 24), (tr->handler_type >> 16), (tr->handler_type >> 8), (tr->handler_type >> 0),
            tr->language[0], tr->language[1], tr->language[2],
            (65536.0*65536.0*tr->duration_hi + tr->duration_lo) / tr->timescale,
            tr->sample_count,
            tr->avg_bitrate_bps);

        printf(" %-23s|", GetMP4StreamTypeName(tr->stream_type));
        printf(" %-23s", GetMP4ObjectTypeName(tr->object_type_indication));

        if (tr->handler_type == MP4D_HANDLER_TYPE_SOUN)
        {
            printf("  -  %d ch %d hz", tr->SampleDescription.audio.channelcount, tr->SampleDescription.audio.samplerate_hz);
        } else if (tr->handler_type == MP4D_HANDLER_TYPE_VIDE)
        {
            printf("  -  %dx%d", tr->SampleDescription.video.width, tr->SampleDescription.video.height);
        }
    }
    printf("\n");
}

#endif // MP4D_PRINT_INFO_SUPPORTED
#endif

头文件路径:minimp4头文件

头文件路径:minimp4头文件

  • 初始化 Rockit
//RK_MPI_SYS_Init()在`rk_mpi_sys.h`
if(RK_MPI_SYS_Init() != RK_SUCCESS){  //RK_SUCCESS 是 0
}
  • 创建通道
VENC_CHN_ATTR_S chn_attr;
memset(&chn_attr, 0, sizeof(chn_attr));
/*
*	省略配置chn_attr相关参数
*/
RK_S32 ret = RK_MPI_VENC_CreateChn(0, &chn_attr);//第0通道
  • 接收流
/* 开启收流,和编码有一定出入 */
RK_S32 ret = RK_MPI_VDEC_StartRecvStream(0);
  • 送码流给 VDEC(MP4 格式是 4字节长度前缀,VDEC 直接支持)
// 缓冲包装成 MB_BLK
MB_BLK blk = NULL;
MB_EXT_CONFIG_S mb_cfg;
memset(&mb_cfg, 0, sizeof(mb_cfg));
/*
*	配置mb_cfg种种 ...
*/
RK_S32 ret = RK_MPI_SYS_CreateMB(&blk, &mb_cfg);

/* 拷贝码流数据到 MB 缓冲 */
void *vir_addr = RK_MPI_MB_Handle2VirAddr(blk);

/* 填充码流包信息 */
VDEC_STREAM_S stream;
memset(&stream, 0, sizeof(stream));
stream.pMbBlk = blk;
stream.u32Len = len;
stream.u64PTS = pts_ms;
stream.bEndOfFrame = RK_TRUE;      /* 一帧结束标志 */
stream.bEndOfStream = RK_FALSE;
stream.bBypassMbBlk = RK_FALSE;    /* 内部会拷贝数据 */

ret = RK_MPI_VDEC_SendStream(0, &stream, 1000);

/* 释放 MB 缓冲(内部已拷贝,原始缓冲可释放) */
RK_MPI_MB_ReleaseMB(blk);

RK 音视频开发:从硬件到软件的完整链路

本文以 Rockchip(RK)平台为背景,讲清音视频数据从真实世界进入芯片、被处理和编码、最终通过网络播放的完整过程。

贯穿示例:CSI 摄像头与麦克风接入 RK 开发板,向 VLC 等客户端提供带声音的 RTSP 实时流。

不同 RK SoC(如 RK356x、RK3588、RV1106)与 SDK 的具体设备名、结构体字段、API 名称可能略有差异;但媒体链路和模块职责一致。


1. 核心认知:一条实时数据流水线

真实世界
  │
  ├─ 摄像头光信号 ─→ Sensor ─→ MIPI-CSI ─→ VICAP ─→ ISP ─→ NV12/YUV
  │                                                        │
  │                                                        ├─ RGA:缩放/旋转/叠字
  │                                                        └─ VENC:H.264/H.265 编码
  │                                                               │
  │                                                               └─ RTSP / MP4 / WebRTC / 屏幕显示
  │
  └─ 麦克风声信号 ─→ Audio Codec ─→ I2S ─→ ALSA ─→ AI ─→ AENC
                                                               │
                                                               └─ AAC / G.711 / Opus → 网络或文件

音视频开发不是简单地“读取摄像头、发送网络包”,而是搭建一条尽量由硬件完成、尽量少经过 CPU 拷贝的高吞吐流水线。


2. RK 芯片内的主要硬件模块

硬件块作用常见软件接口
CSI / DPHY接收 MIPI 摄像头高速数据Device Tree、V4L2
VICAP将摄像头帧采集进内存缓冲区/dev/video*、VI
ISP去噪、曝光、白平衡、HDR、颜色处理rkaiq、ISP 参数
RGA缩放、裁剪、旋转、颜色转换、OSDRGA API
VENCH.264/H.265/MJPEG 硬件编码MPP、RKMedia VENC
VDECH.264/H.265 等硬件解码MPP、RKMedia VDEC
I2S音频数字总线Device Tree、ALSA
Audio CodecADC/DAC,模拟与数字音频转换ALSA mixer
VO / DRM把图像送到 HDMI、MIPI 屏等显示设备DRM/KMS、VO

几个容易混淆的事实:

  • 摄像头通常先输出 RAW Bayer 原始像素,并非 H.264 视频。
  • ISP 将 RAW 转换为可供处理、显示或编码的 YUV/NV12 图像。
  • VENC 将逐帧的 NV12/YUV 图像压缩为 H.264/H.265 码流。
  • RTSP 仅负责传输已经编码的码流,不负责编码。

3. 一帧视频图像经历了什么

以 1920×1080、30fps 的摄像头为例:

Sensor
  ↓ 输出 RAW10 Bayer 数据
MIPI CSI-2
  ↓
VICAP
  ↓ 将帧送入内存缓冲区
ISP
  ↓ 自动曝光、白平衡、去马赛克、降噪、颜色校正
NV12,1920×1080,30fps
  ↓
VENC
  ↓ H.264/H.265 压缩
H.264 NALU:SPS/PPS/IDR/P 帧
  ↓
RTSP Server
  ↓ RTP 打包并经 TCP/UDP 发送
VLC / NVR / Web 前端

一帧 1080P NV12 图像约占:

1920 × 1080 × 1.5 ≈ 3 MB

30fps 时,未压缩图像数据量约为:

3 MB × 30 = 90 MB/s

若 H.264 编码码率为 4 Mbps,网络侧实际数据量约为:

4 Mbps ÷ 8 = 0.5 MB/s

这解释了硬件编码器的价值:把巨大的原始图像流压缩成适合存储和网络传输的码流,同时避免 CPU 被软件编码占满。


4. 软件分层

应用程序
  ├─ RTSP / MP4 / WebRTC 业务逻辑
  ├─ RKMedia 或 MPP
  └─ ALSA / V4L2 / DRM
          ↓
RK 内核媒体驱动
  ├─ rkcif / vicap
  ├─ rkisp
  ├─ rkvenc / rkvdec
  ├─ rga
  ├─ i2s / codec
  └─ drm
          ↓
Device Tree
  ├─ 摄像头型号、I2C 地址
  ├─ MIPI lane、时钟、reset/pwdn GPIO
  ├─ ISP/VICAP 连接关系
  └─ I2S、Codec、功放等
          ↓
RK 芯片与板级硬件

各层职责:

  • Device Tree:描述开发板上有哪些硬件、硬件之间如何连接。
  • 内核驱动:使硬件成为 Linux 可使用的设备节点。
  • V4L2 / ALSA:Linux 通用视频和音频接口。
  • MPP:Rockchip 较底层的媒体处理接口。
  • RKMedia:对 VI、VENC、AI、AENC、RGA 等模块的较高层封装。
  • rkaiq:调节 ISP 的自动曝光、白平衡、HDR、降噪等画质能力。

5. Demo 架构:Camera + Mic → RTSP

目标链路:

CSI 摄像头 ─→ VI ─→ VENC(H.264) ─→ RTSP video track
I2S 麦克风 ─→ AI ─→ AENC(G.711/AAC) ─→ RTSP audio track

RKMedia 中的关键思想是模块绑定:

VI channel 0  ── bind ──>  VENC channel 0
AI device     ── bind ──>  AENC channel 0

绑定后:

  1. VI 产生 NV12 帧;
  2. 帧通过 DMA Buffer 直接传给 VENC;
  3. VENC 输出 H.264 码流;
  4. 应用只需要从 VENC 获取编码后码流,发送给 RTSP 客户端。

这种链路通常称为 zero-copy(零拷贝) 或 少拷贝。实际含义是:图像数据尽量不从硬件缓冲区复制到 CPU 私有内存后再复制回来。


6. Demo 的初始化与退出顺序

启动顺序:

1. 初始化系统
2. 启动 ISP(摄像头需要 ISP 时)
3. 配置并启动 VI
4. 配置并创建 VENC
5. Bind:VI → VENC
6. 配置 AI 与 AENC
7. Bind:AI → AENC
8. 创建 RTSP Server
9. 循环获取 VENC/AENC 码流并发送

退出时按相反方向执行:先停止 RTSP 和取流线程,解除 Bind,销毁编码器,关闭输入,再反初始化系统。


7. 视频核心代码骨架

以下示例以 RKMedia 风格 API 展示核心流程。请按实际 SDK 调整头文件、设备节点和字段名。

#include "rkmedia_api.h"
#include "rtsp_demo.h"

int main(void) {
    RK_MPI_SYS_Init();

    /* 1. 摄像头输入:VI */
    VI_CHN_ATTR_S vi_attr;
    memset(&vi_attr, 0, sizeof(vi_attr));

    vi_attr.pcVideoNode = "rkispp_scale0";
    vi_attr.u32BufCnt = 3;
    vi_attr.u32Width = 1920;
    vi_attr.u32Height = 1080;
    vi_attr.enPixFmt = IMAGE_TYPE_NV12;
    vi_attr.enWorkMode = VI_WORK_MODE_NORMAL;

    RK_MPI_VI_SetChnAttr(0, 0, &vi_attr);
    RK_MPI_VI_EnableChn(0, 0);

    /* 2. 硬件编码器:VENC */
    VENC_CHN_ATTR_S venc_attr;
    memset(&venc_attr, 0, sizeof(venc_attr));

    venc_attr.stVencAttr.enType = VIDEO_ID_AVC;
    venc_attr.stVencAttr.imageType = IMAGE_TYPE_NV12;
    venc_attr.stVencAttr.u32PicWidth = 1920;
    venc_attr.stVencAttr.u32PicHeight = 1080;
    venc_attr.stVencAttr.u32VirWidth = 1920;
    venc_attr.stVencAttr.u32VirHeight = 1080;

    venc_attr.stRcAttr.enRcMode = VENC_RC_MODE_H264CBR;
    venc_attr.stRcAttr.stH264Cbr.u32BitRate = 4 * 1024;
    venc_attr.stRcAttr.stH264Cbr.fr32DstFrameRateNum = 30;
    venc_attr.stRcAttr.stH264Cbr.fr32DstFrameRateDen = 1;
    venc_attr.stRcAttr.stH264Cbr.u32Gop = 60;

    RK_MPI_VENC_CreateChn(0, &venc_attr);

    /* 3. 建立零拷贝绑定:VI → VENC */
    MPP_CHN_S vi_chn = {
        .enModId = RK_ID_VI,
        .s32DevId = 0,
        .s32ChnId = 0
    };

    MPP_CHN_S venc_chn = {
        .enModId = RK_ID_VENC,
        .s32DevId = 0,
        .s32ChnId = 0
    };

    RK_MPI_SYS_Bind(&vi_chn, &venc_chn);

    /* 4. 创建 RTSP 服务 */
    rtsp_demo_handle rtsp = create_rtsp_demo(554);
    rtsp_session_handle session = rtsp_new_session(rtsp, "/live");
    rtsp_set_video(session, RTSP_CODEC_ID_VIDEO_H264, NULL, 0);

    /* 5. 取得 H.264 码流并发送 */
    while (1) {
        MEDIA_BUFFER mb = RK_MPI_SYS_GetMediaBuffer(RK_ID_VENC, 0, -1);

        if (mb) {
            void *data = RK_MPI_MB_GetPtr(mb);
            size_t size = RK_MPI_MB_GetSize(mb);
            int64_t pts = RK_MPI_MB_GetTimestamp(mb);

            rtsp_tx_video(session, data, size, pts);
            rtsp_do_event(rtsp);

            RK_MPI_MB_ReleaseBuffer(mb);
        }
    }
}

启动服务后,通常可通过 VLC 打开:

rtsp://<开发板IP>/live

8. Bind 背后的意义

下面这句是高性能链路的关键:

RK_MPI_SYS_Bind(&vi_chn, &venc_chn);

它表达的是:

VI 产生的 NV12 Buffer
     ↓
尽量不经过应用层 memcpy
     ↓
直接交给 VENC 硬件编码

如果业务需要在中间自行处理帧,例如送 NPU 推理、做算法或调试,可以不用 Bind,而由应用手动转发:

MEDIA_BUFFER frame = RK_MPI_SYS_GetMediaBuffer(RK_ID_VI, 0, -1);
RK_MPI_SYS_SendMediaBuffer(RK_ID_VENC, 0, frame);
RK_MPI_MB_ReleaseBuffer(frame);

手动转发灵活,但需要自行处理帧率节奏、缓存积压、超时与 Buffer 生命周期。


9. 音频链路

音频模块和视频模块是一一对应的:

AI = Audio Input,采集 PCM
AENC = Audio Encoder,将 PCM 编码为 G.711/AAC 等

监控场景常见参数:

采样率:16000 Hz
位宽:16 bit
声道:1
编码:G.711A

原始 PCM 数据量:

16000 samples/s × 16 bit × 1 channel = 256 kbps

G.711A 后约为:

128 kbps

音视频同步依赖 PTS:

  • 视频 PTS 通常来自编码器;
  • 音频 PTS 应按采样率和采样数递增;
  • 不要将“收到数据的系统时间”随意混作媒体时间戳。

如果声音越来越不同步,检查音视频 PTS、丢帧、缓存积压,以及 RTSP 客户端是否正确识别音频编码。


10. ISP:决定画质的关键模块

ISP 不是编码器,但它决定图像质量。典型能力包括:

  • AE:自动曝光;
  • AWB:自动白平衡;
  • AF:自动对焦,需要镜头马达支持;
  • NR:暗光降噪;
  • HDR:同时保留亮部和暗部细节;
  • 锐化、去雾、畸变校正等。

推荐调试顺序:

确认 Sensor 被识别
→ 确认 MIPI/CSI 稳定收帧
→ 确认 ISP 输出图像正确
→ 再调编码码率、GOP 与低延迟

如果原始画面已经花屏、偏色、闪烁或曝光异常,优先检查 Sensor、MIPI、时钟、设备树与 ISP 参数,而不是先怀疑 H.264 编码器。


11. 常用排障路径

没有视频设备节点

dmesg | grep -Ei "isp|cif|csi|sensor|mipi"
media-ctl -p
v4l2-ctl --list-devices

重点检查:

  • Sensor 驱动是否 probe 成功;
  • I2C 地址是否正确;
  • reset/pwdn GPIO 是否正确;
  • MIPI lane 数、lane 顺序及时钟是否匹配;
  • Device Tree endpoint 是否正确连接。

有画面但花屏或偏色

常见原因:

  • RAW10/RAW12 等输入格式配置不一致;
  • MIPI lane 或时钟质量问题;
  • Sensor 寄存器初始化错误;
  • ISP IQ 文件或参数不匹配;
  • NV12 与 NV21 等像素格式混淆。

RTSP 可打开但黑屏

逐段验证:

v4l2-ctl 验证摄像头
→ 保存一帧 NV12/JPEG 验证 ISP
→ 将 H.264 码流写为 .h264 文件
→ 用 VLC 本地播放 .h264
→ 最后检查 RTSP
  • 裸 .h264 无法播放:问题多在 VI、ISP 或 VENC;
  • 裸 .h264 可播放但 RTSP 失败:重点检查 SPS/PPS、时间戳、RTP/RTSP 封装和网络。

延迟过高

检查以下项目:

  • GOP 是否过大;
  • 是否使用 B 帧;
  • 编码或网络缓冲是否积压;
  • RTSP 是否走 TCP;
  • 客户端网络缓存是否过大;
  • 应用层是否发生多次 memcpy;
  • 是否把高分辨率图像拉到 CPU 做软件缩放。

低延迟监控的常见设置:

H.264 CBR
30 fps
GOP = 30 或 60
关闭 B 帧
使用硬件 RGA
VI → VENC Bind
客户端缩小网络缓存

12. 用“媒体图”理解整个系统

不要把 RK 音视频理解为一段孤立的摄像头编码代码。它是一张可以按业务拼接的媒体图:

Camera → VI → ISP → RGA → VENC → RTSP
                         ├→ JPEG → 抓拍
                         ├→ NPU  → AI 检测
                         └→ VO   → 本地屏幕

Mic → AI → AENC → RTSP / MP4

加入 AI 检测时,一个常见架构为:

VI → RGA(缩小到 640×640) → NPU
VI → VENC → RTSP
NPU 检测结果 → RGA/OSD → VENC

主码流持续以高分辨率编码,NPU 只处理缩小后的图像,检测结果以框或文字叠加回视频。这是智能摄像机常见的实现方式。


13. 推荐学习顺序

  1. Linux 基础:设备树、驱动日志、dmesg、media-ctl、v4l2-ctl、ALSA。
  2. 摄像头链路:Sensor → MIPI → CSI → ISP → NV12。
  3. 硬件编解码:NV12 → H.264/H.265;理解码率、帧率、GOP、I/P/B 帧。
  4. RKMedia / MPP:VI、VENC、AI、AENC、RGA、Bind、MEDIA_BUFFER。
  5. 协议与封装:H.264 裸流、MP4、RTSP、RTP、WebRTC。
  6. 系统优化:DMA Buffer、零拷贝、缓冲深度、延迟与内存带宽。
  7. 进阶:ISP 调优、OSD、录像、双码流、NPU 推理、WebRTC。

总结

硬件负责高吞吐处理;内核驱动负责把硬件暴露为 Linux 设备;RKMedia/MPP 负责连接媒体模块;应用负责业务和协议输出。

真正的工程能力,是能够沿着 Sensor → ISP → Buffer → 编码器 → 网络 → 播放端 逐段验证、定位和优化问题。

Usb控制器

USB 使用 D+/D- 进行差分传输,有效提高抗干扰能力,采用 NRZI 编码 和 位填充(bit stuffing) 技术。

  • Full-Speed(USB 1.1):12 Mbps
  • High-Speed(USB 2.0):480 Mbps
  • SuperSpeed(USB 3.0/3.1):5 Gbps / 10 Gbps,新增 TX/RX 对(USB 3.x 不再复用 D+/D-)2.2 上拉电阻与速度识别

USB 设备在插入总线时,会通过 D+ 或 D- 上拉电阻向主机宣告其速率:

  • D+ 上拉 → Full Speed(12 Mbps)
  • D- 上拉 → Low Speed(1.5 Mbps)SuperSpeed 通过额外引脚检测

Usb红外摄像头

海康红外摄像头

HM-TM30

初始化+sdk路径

static USB_DEVICE_INFO *dev_list = NULL;   /* USB 枚举结果列表 */
static int dev_count = 0;                  /* 检测到的 USB 设备数 */
/*
 * 将工作目录切到可执行文件所在目录。
 * HCUsbSDK 通过 dlopen 加载同目录下的 .so 文件,必须运行在 .so 所在目录。
 */
static void setup_chdir(void)
{
    char exe[PATH_MAX], *p;
    ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
    if (n <= 0) return;
    exe[n] = 0;
    p = strrchr(exe, '/');
    if (!p) return;
    *p = 0;
    if (access(exe, F_OK) == 0) (void)chdir(exe);
}
int camera_init(void)
{
    LONG login_id; /
    USB_USER_LOGIN_INFO login_info = {0}; //登录配置结构体
    USB_DEVICE_REG_RES reg_result = {0}; //注册成功结构体

    setup_chdir(); //设置路径
    if (!USB_Init()) return -1; //usb初始化,摄像头会切换到usb模式

    dev_count = USB_GetDeviceCount(); //获取连接的usb摄像头数量
    if (dev_count <= 0) { USB_Cleanup(); return -1; }
    printf("Devices: %d\n", dev_count);

    dev_list = malloc(dev_count * sizeof(USB_DEVICE_INFO)); //为每个设备分配内存
    USB_EnumDevices(dev_count, dev_list);

    login_info.dwSize = sizeof(login_info); //配置登录信息
    login_info.dwTimeout = 5000;
    login_info.dwVID = dev_list[0].dwVID;
    login_info.dwPID = dev_list[0].dwPID;
    login_info.byLoginMode = 1;
    memcpy(login_info.szSerialNumber, dev_list[0].szSerialNumber, sizeof(login_info.szSerialNumber));
    memcpy(login_info.szUserName, "admin", 5);
    memcpy(login_info.szPassword, "12345", 5);

    reg_result.dwSize = sizeof(reg_result);
    login_id = USB_Login(&login_info, &reg_result); //登录测试
    if (login_id < 0) { printf("Login fail\n"); camera_shutdown(-1, -1); return -1; }
    printf("Login OK login_id=%ld\n", (long)uid);
    return (int)login_id;
}

配置

int camera_configure(LONG uid)
{
    USB_COMMON_COND channel_cond = {0};
    USB_CONFIG_INPUT_INFO input_info = {0};
    USB_CONFIG_OUTPUT_INFO output_info = {0};

    channel_cond.dwSize = sizeof(channel_cond);
    channel_cond.byChannelID = USB_CHANNEL_IR;

    /* ---- 配置测温流(coding type 8 = 测温+YUV) ---- */
    USB_THERMAL_STREAM_PARAM thermal_param = {0};
    thermal_param.dwSize = sizeof(thermal_param);
    thermal_param.byVideoCodingType = 8; //模式
    thermal_param.dwWidth  = 4;    /* 根据 YUV_MODE 选择传输帧宽 */
    thermal_param.dwHeight = 5188;    /* 根据 YUV_MODE 选择传输帧高 */
    thermal_param.dwFrameRate = 25;  //帧数
    input_info.lpCondBuffer = &channel_cond;
    input_info.dwCondBufferSize = sizeof(channel_cond);
    input_info.lpInBuffer = &thermal_param;
    input_info.dwInBufferSize = sizeof(thermal_param);
    if (!USB_SetDeviceConfig(uid, USB_SET_THERMAL_STREAM_PARAM, &input_info, &output_info))
        { printf("Set thermal param fail\n"); return 0; }

    /* ---- 配置视频格式为 YUY2 ---- */
    USB_VIDEO_PARAM video_param = {0};
    video_param.dwVideoFormat = USB_STREAM_YUY2;
    video_param.dwWidth  = 4;
    video_param.dwHeight = 5188;
    video_param.dwFramerate = 25;
    memset(&input_info, 0, sizeof(input_info));
    memset(&output_info, 0, sizeof(output_info));
    input_info.lpCondBuffer = &channel_cond;
    input_info.dwCondBufferSize = sizeof(channel_cond);
    input_info.lpInBuffer = &video_param;
    input_info.dwInBufferSize = sizeof(video_param);
    if (!USB_SetDeviceConfig(login_id, USB_SET_VIDEO_PARAM, &input_info, &output_info))
        { printf("Set video param fail\n"); return 0; }
    return 1;
}

回调开启

/* 完整帧数据(协议头 + 测温区 + YUY2,按 YUV_MODE 定长) */
static uint8_t frame_data[HEADER_BYTES + TEMP_BYTES + YUV_BYTES];
static pthread_mutex_t frame_mutex = PTHREAD_MUTEX_INITIALIZER;  /* 保护 frame_data/frame_ready/ */
static volatile int frame_ready = 0;   /* 新帧到达标志:on_frame 置 1,camera_get_frame 消费后置 0 */
void CALLBACK on_frame(LONG handle, USB_FRAME_INFO *frame, void *user)
{
    (void)handle;
    (void)user;
    if (!frame || !frame->pBuf) return;
    if (frame->dwBufSize < YUV_OFFSET + YUV_BYTES) return;

    pthread_mutex_lock(&frame_mutex);
    memcpy(frame_data, frame->pBuf, YUV_OFFSET + YUV_BYTES);
    frame_ready = 1;
    pthread_mutex_unlock(&frame_mutex);
}
int camera_start_stream(LONG uid)
{
    USB_STREAM_CALLBACK_PARAM callback_param = {0};
    callback_param.dwSize = sizeof(callback_param);
    callback_param.dwStreamType = USB_STREAM_YUY2;
    callback_param.funcStreamCallBack = on_frame;
    LONG stream_handle = USB_StartStreamCallback(uid, &callback_param);
    if (stream_handle < 0) { printf("Start stream fail\n"); return -1; }
    printf("Stream started\n");
    return (int)stream_handle;
}

其他地方获取回调

int camera_get_frame(uint8_t *buf)
{
    int ready;
    pthread_mutex_lock(&frame_mutex);
    ready = frame_ready;
    if (ready) {
        memcpy(buf, frame_data + YUV_OFFSET, YUV_BYTES);
        frame_ready = 0;
    }
    pthread_mutex_unlock(&frame_mutex);
    return ready;
}

结束

void camera_shutdown(LONG uid, int stream_handle)
{
    if (stream_handle >= 0) USB_StopChannel(uid, (DWORD)stream_handle);
    if (uid >= 0) USB_Logout(uid);
    free(dev_list);
    dev_list = NULL;
    USB_Cleanup();
}
  • 停止流
  • 修改配置
  • 启动流
#红外
thermal_param.dwWidth  = g_usb_w;
thermal_param.dwHeight = g_usb_h;
#yuv
video_param.dwWidth  = g_usb_w;
video_param.dwHeight = g_usb_h;

开启流的时候需要配置红外的dwWidth,dwHeight,关闭之后修改配置,然后再打开就好

/*
 * camera_config.c — 热成像摄像头参数配置接口实现
 *
 * 参考官方示例 ThermalDevice.cpp 封装的全部红外配置项。
 * 配置操作统一走 USB 通道 1(官方约定),码流通道 2 只用于视频流。
 * 图像增强使用 V20→V1 自动回退;其余均使用 V1 命令。
 */

#include "camera_config.h"
#include <stdio.h>
#include <string.h>

/* ======================================================================== *
 *  内部辅助函数                                                              *
 * ======================================================================== */

/*
 * 通用 GET:通过 USB_GetDeviceConfig 读取设备参数。
 * 通道固定为 1(控制通道),与流数据通道(2)分离。
 */
static int get_config(LONG uid, DWORD cmd, void *out, DWORD out_size)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = out;
    ii.dwInBufferSize   = out_size;
    oo.lpOutBuffer      = out;
    oo.dwOutBufferSize  = out_size;

    if (!USB_GetDeviceConfig(uid, cmd, &ii, &oo)) {
        printf("[camera_config] GET fail cmd=0x%lx (%lu)\n",
               (unsigned long)cmd, (unsigned long)cmd);
        return 0;
    }
    return 1;
}

/*
 * 通用 SET:通过 USB_SetDeviceConfig 写入设备参数。
 */
static int set_config(LONG uid, DWORD cmd, void *in, DWORD in_size)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = in;
    ii.dwInBufferSize   = in_size;

    if (!USB_SetDeviceConfig(uid, cmd, &ii, &oo)) {
        printf("[camera_config] SET fail cmd=0x%lx (%lu)\n",
               (unsigned long)cmd, (unsigned long)cmd);
        return 0;
    }
    return 1;
}

/*
 * 通用 CONTROL:通过 USB_Control 发送无数据/简单参数的控制命令。
 */
static int control_cmd(LONG uid, DWORD cmd)
{
    USB_COMMON_COND         cond = {0};
    USB_CONTROL_INPUT_INFO  ci   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    ci.lpCondBuffer     = &cond;
    ci.dwCondBufferSize = sizeof(cond);

    if (!USB_Control(uid, cmd, &ci)) {
        printf("[camera_config] CTRL fail cmd=0x%lx (%lu)\n",
               (unsigned long)cmd, (unsigned long)cmd);
        return 0;
    }
    return 1;
}

/* ======================================================================== *
 *  1.  系统管理                                                             *
 * ======================================================================== */

int camera_get_device_info(LONG uid, USB_SYSTEM_DEVICE_INFO *info)
{
    memset(info, 0, sizeof(*info));
    info->dwSize = sizeof(*info);
    return get_config(uid, USB_GET_SYSTEM_DEVICE_INFO, info, sizeof(*info));
}

int camera_set_reboot(LONG uid)
{
    return control_cmd(uid, USB_SET_SYSTEM_REBOOT);
}

int camera_set_factory_reset(LONG uid)
{
    return control_cmd(uid, USB_SET_SYSTEM_RESET);
}

int camera_set_background_correct(LONG uid)
{
    return control_cmd(uid, USB_SET_IMAGE_BACKGROUND_CORRECT);
}

int camera_set_manual_correct(LONG uid)
{
    return control_cmd(uid, USB_SET_IMAGE_MANUAL_CORRECT);
}

int camera_get_hardware_server(LONG uid, USB_SYSTEM_HARDWARE_SERVER *hs)
{
    memset(hs, 0, sizeof(*hs));
    hs->dwSize = sizeof(*hs);
    return get_config(uid, USB_GET_SYSTEM_HARDWARE_SERVER, hs, sizeof(*hs));
}

int camera_set_hardware_server(LONG uid, const USB_SYSTEM_HARDWARE_SERVER *hs)
{
    return set_config(uid, USB_SET_SYSTEM_HARDWARE_SERVER,
                      (void *)hs, sizeof(*hs));
}

int camera_get_local_time(LONG uid, USB_SYSTEM_LOCALTIME *lt)
{
    memset(lt, 0, sizeof(*lt));
    lt->dwSize = sizeof(*lt);
    return get_config(uid, USB_GET_SYSTEM_LOCALTIME, lt, sizeof(*lt));
}

int camera_set_local_time(LONG uid, const USB_SYSTEM_LOCALTIME *lt)
{
    return set_config(uid, USB_SET_SYSTEM_LOCALTIME, (void *)lt, sizeof(*lt));
}

/* ======================================================================== *
 *  2.  图像增强 Enhancement(V20 → V1 自动回退)                             *
 * ======================================================================== */

int camera_get_enhancement(LONG uid, USB_IMAGE_ENHANCEMENT *enh)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    enh->dwSize = sizeof(*enh);

    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = enh;
    ii.dwInBufferSize   = sizeof(*enh);
    oo.lpOutBuffer      = enh;
    oo.dwOutBufferSize  = sizeof(*enh);

    /* 先试 V20 */
    if (USB_GetDeviceConfig(uid, USB_GET_IMAGE_ENHANCEMENT_V20, &ii, &oo))
        return 1;

    /* 回退 V1 */
    memset(&ii, 0, sizeof(ii));
    memset(&oo, 0, sizeof(oo));
    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = enh;
    ii.dwInBufferSize   = sizeof(*enh);
    oo.lpOutBuffer      = enh;
    oo.dwOutBufferSize  = sizeof(*enh);

    if (!USB_GetDeviceConfig(uid, USB_GET_IMAGE_ENHANCEMENT, &ii, &oo)) {
        printf("[camera_config] get_enhancement: V20 和 V1 都失败\n");
        return 0;
    }
    return 1;
}

int camera_set_enhancement(LONG uid, const USB_IMAGE_ENHANCEMENT *enh)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = (void *)enh;
    ii.dwInBufferSize   = sizeof(*enh);

    /* 先试 V20 */
    if (USB_SetDeviceConfig(uid, USB_SET_IMAGE_ENHANCEMENT_V20, &ii, &oo))
        return 1;

    /* 回退 V1 */
    memset(&ii, 0, sizeof(ii));
    memset(&oo, 0, sizeof(oo));
    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = (void *)enh;
    ii.dwInBufferSize   = sizeof(*enh);

    if (!USB_SetDeviceConfig(uid, USB_SET_IMAGE_ENHANCEMENT, &ii, &oo)) {
        printf("[camera_config] set_enhancement: V20 和 V1 都失败\n");
        return 0;
    }
    return 1;
}

/* ---- 2a. 伪彩色 ---- */

int camera_get_palette(LONG uid, int *mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *mode = enh.byPaletteMode;
    return 1;
}

int camera_set_palette(LONG uid, int mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byPaletteMode = (BYTE)mode;
    return camera_set_enhancement(uid, &enh);
}

/* ---- 2b. AGC 模式 ---- */

int camera_get_agc_mode(LONG uid, int *mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *mode = enh.byIspAgcMode;
    return 1;
}

int camera_set_agc_mode(LONG uid, int mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byIspAgcMode = (BYTE)mode;
    return camera_set_enhancement(uid, &enh);
}

/* ---- 2c. DDE ---- */

int camera_get_dde_enabled(LONG uid, int *enabled)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *enabled = enh.byLSEDetailEnabled;
    return 1;
}

int camera_set_dde_enabled(LONG uid, int enabled)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byLSEDetailEnabled = enabled ? 1 : 0;
    return camera_set_enhancement(uid, &enh);
}

int camera_get_dde_level(LONG uid, DWORD *level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *level = enh.dwLSEDetailLevel;
    return 1;
}

int camera_set_dde_level(LONG uid, DWORD level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.dwLSEDetailLevel = level;
    return camera_set_enhancement(uid, &enh);
}

/* ---- 2d. 降噪 ---- */

int camera_get_noise_reduce_mode(LONG uid, int *mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *mode = enh.byNoiseReduceMode;
    return 1;
}

int camera_set_noise_reduce_mode(LONG uid, int mode)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byNoiseReduceMode = (BYTE)mode;
    return camera_set_enhancement(uid, &enh);
}

int camera_get_general_denoise(LONG uid, DWORD *level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *level = enh.dwGeneralLevel;
    return 1;
}

int camera_set_general_denoise(LONG uid, DWORD level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byNoiseReduceMode = 1;
    enh.dwGeneralLevel = level;
    return camera_set_enhancement(uid, &enh);
}

int camera_get_spatial_denoise(LONG uid, DWORD *level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *level = enh.dwFrameNoiseReduceLevel;
    return 1;
}

int camera_set_spatial_denoise(LONG uid, DWORD level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byNoiseReduceMode = 2;
    enh.dwFrameNoiseReduceLevel = level;
    return camera_set_enhancement(uid, &enh);
}

int camera_get_temporal_denoise(LONG uid, DWORD *level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *level = enh.dwInterFrameNoiseReduceLevel;
    return 1;
}

int camera_set_temporal_denoise(LONG uid, DWORD level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byNoiseReduceMode = 2;
    enh.dwInterFrameNoiseReduceLevel = level;
    return camera_set_enhancement(uid, &enh);
}

/* ---- 2e. 勾边 ---- */

int camera_get_hook_edge(LONG uid, int *enabled, int *level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    *enabled = enh.byHookEdgeMode;
    *level   = enh.byHookEdgeLevel;
    return 1;
}

int camera_set_hook_edge(LONG uid, int enabled, int level)
{
    USB_IMAGE_ENHANCEMENT enh = {0};
    if (!camera_get_enhancement(uid, &enh)) return 0;
    enh.byHookEdgeMode  = enabled ? 1 : 0;
    enh.byHookEdgeLevel = (BYTE)level;
    return camera_set_enhancement(uid, &enh);
}

/* ======================================================================== *
 *  3.  视频调整                                                             *
 * ======================================================================== */

int camera_get_video_adjust(LONG uid, USB_IMAGE_VIDEO_ADJUST *va)
{
    memset(va, 0, sizeof(*va));
    va->dwSize = sizeof(*va);
    return get_config(uid, USB_GET_IMAGE_VIDEO_ADJUST_CAMERA, va, sizeof(*va));
}

int camera_set_video_adjust(LONG uid, const USB_IMAGE_VIDEO_ADJUST *va)
{
    return set_config(uid, USB_SET_IMAGE_VIDEO_ADJUST_CAMERA,
                      (void *)va, sizeof(*va));
}

int camera_get_mirror_mode(LONG uid, int *mode)
{
    USB_IMAGE_VIDEO_ADJUST va;
    if (!camera_get_video_adjust(uid, &va)) return 0;
    *mode = va.byImageFlipStyle;
    return 1;
}

int camera_set_mirror_mode(LONG uid, int mode)
{
    USB_IMAGE_VIDEO_ADJUST va;
    if (!camera_get_video_adjust(uid, &va)) return 0;
    va.byImageFlipStyle = (BYTE)mode;
    return camera_set_video_adjust(uid, &va);
}

int camera_get_digital_zoom(LONG uid, int *zoom)
{
    USB_IMAGE_VIDEO_ADJUST va;
    if (!camera_get_video_adjust(uid, &va)) return 0;
    *zoom = va.byDigitalZoom;
    return 1;
}

int camera_set_digital_zoom(LONG uid, int zoom)
{
    USB_IMAGE_VIDEO_ADJUST va;
    if (!camera_get_video_adjust(uid, &va)) return 0;
    va.byDigitalZoom = (BYTE)zoom;
    return camera_set_video_adjust(uid, &va);
}

/* ======================================================================== *
 *  4.  亮度 / 对比度                                                        *
 * ======================================================================== */

int camera_get_brightness(LONG uid, DWORD *value)
{
    USB_IMAGE_BRIGHTNESS b = {0};
    b.dwSize = sizeof(b);
    if (!get_config(uid, USB_GET_IMAGE_BRIGHTNESS, &b, sizeof(b)))
        return 0;
    *value = b.dwBrightness;
    return 1;
}

int camera_set_brightness(LONG uid, DWORD value)
{
    USB_IMAGE_BRIGHTNESS b = {0};
    b.dwSize = sizeof(b);
    b.dwBrightness = value;
    return set_config(uid, USB_SET_IMAGE_BRIGHTNESS, &b, sizeof(b));
}

int camera_get_contrast(LONG uid, DWORD *value)
{
    USB_IMAGE_CONTRAST c = {0};
    c.dwSize = sizeof(c);
    if (!get_config(uid, USB_GET_IMAGE_CONTRAST, &c, sizeof(c)))
        return 0;
    *value = c.dwContrast;
    return 1;
}

int camera_set_contrast(LONG uid, DWORD value)
{
    USB_IMAGE_CONTRAST c = {0};
    c.dwSize = sizeof(c);
    c.dwContrast = value;
    return set_config(uid, USB_SET_IMAGE_CONTRAST, &c, sizeof(c));
}

/* ======================================================================== *
 *  5.  测温基本参数                                                          *
 * ======================================================================== */

int camera_get_thermometry(LONG uid, USB_THERMOMETRY_BASIC_PARAM *tp)
{
    memset(tp, 0, sizeof(*tp));
    tp->dwSize = sizeof(*tp);
    return get_config(uid, USB_GET_THERMOMETRY_BASIC_PARAM, tp, sizeof(*tp));
}

int camera_set_thermometry(LONG uid, const USB_THERMOMETRY_BASIC_PARAM *tp)
{
    return set_config(uid, USB_SET_THERMOMETRY_BASIC_PARAM,
                      (void *)tp, sizeof(*tp));
}

/* ---- 5a. 发射率 ---- */

int camera_get_emissivity(LONG uid, DWORD *value_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *value_x100 = tp.dwEmissivity;
    return 1;
}

int camera_set_emissivity(LONG uid, DWORD value_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwEmissivity = value_x100;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5b. 测温距离 ---- */

int camera_get_distance(LONG uid, DWORD *distance_cm)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *distance_cm = tp.dwDistance;
    return 1;
}

int camera_set_distance(LONG uid, DWORD distance_cm)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwDistance = distance_cm;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_distance_unit(LONG uid, int *unit)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *unit = tp.byDistanceUnit;
    return 1;
}

int camera_set_distance_unit(LONG uid, int unit)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byDistanceUnit = (BYTE)unit;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5c. 温度单位 ---- */

int camera_get_temp_unit(LONG uid, int *unit)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *unit = tp.byTemperatureUnit;
    return 1;
}

int camera_set_temp_unit(LONG uid, int unit)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byTemperatureUnit = (BYTE)unit;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5d. 测温范围 ---- */

int camera_get_temp_range(LONG uid, int *range)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *range = tp.byTemperatureRange;
    return 1;
}

int camera_set_temp_range(LONG uid, int range)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byTemperatureRange = (BYTE)range;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_temp_range_auto_enabled(LONG uid, int *enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byTemperatureRangeAutoChangedEnabled;
    return 1;
}

int camera_set_temp_range_auto_enabled(LONG uid, int enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byTemperatureRangeAutoChangedEnabled = enabled ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5e. 标定系数 ---- */

int camera_get_calibration_coeff(LONG uid, DWORD *value_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *value_x100 = tp.dwCalibrationCoefficient;
    return 1;
}

int camera_set_calibration_coeff(LONG uid, DWORD value_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwCalibrationCoefficient = value_x100;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_calibration_coeff_enabled(LONG uid, int *enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byCalibrationCoefficientEnabled;
    return 1;
}

int camera_set_calibration_coeff_enabled(LONG uid, int enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byCalibrationCoefficientEnabled = enabled ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5f. 反射温度 ---- */

int camera_get_reflective_temp(LONG uid, DWORD *temp_x10)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *temp_x10 = tp.dwReflectiveTemperature;
    return 1;
}

int camera_set_reflective_temp(LONG uid, DWORD temp_x10)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwReflectiveTemperature = temp_x10;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_reflective_enabled(LONG uid, int *enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byReflectiveEnable;
    return 1;
}

int camera_set_reflective_enabled(LONG uid, int enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byReflectiveEnable = enabled ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5g. 环境温度 ---- */

int camera_get_env_temp(LONG uid, int *enabled, DWORD *temp)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byEnviromentTemperatureEnable;
    *temp    = tp.dwEnviromentTemperature;
    return 1;
}

int camera_set_env_temp(LONG uid, int enabled, DWORD temp)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byEnviromentTemperatureEnable = (BYTE)enabled;
    tp.dwEnviromentTemperature       = temp;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5h. 报警温度 ---- */

int camera_get_alert_temp(LONG uid, DWORD *temp_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *temp_x100 = tp.dwAlert;
    return 1;
}

int camera_set_alert_temp(LONG uid, DWORD temp_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwAlert = temp_x100;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_alarm_temp(LONG uid, DWORD *temp_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *temp_x100 = tp.dwAlarm;
    return 1;
}

int camera_set_alarm_temp(LONG uid, DWORD temp_x100)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwAlarm = temp_x100;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5i. 显示元素 ---- */

int camera_get_display_flags(LONG uid, int *max_en, int *min_en,
                             int *avg_en, int *cen_en)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *max_en = tp.byDisplayMaxTemperatureEnabled;
    *min_en = tp.byDisplayMinTemperatureEnabled;
    *avg_en = tp.byDisplayAverageTemperatureEnabled;
    *cen_en = tp.byDisplayCenTempEnabled;
    return 1;
}

int camera_set_display_flags(LONG uid, int max_en, int min_en,
                             int avg_en, int cen_en)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byDisplayMaxTemperatureEnabled     = max_en ? 1 : 0;
    tp.byDisplayMinTemperatureEnabled     = min_en ? 1 : 0;
    tp.byDisplayAverageTemperatureEnabled = avg_en ? 1 : 0;
    tp.byDisplayCenTempEnabled            = cen_en ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5j. 全局开关 / 叠加 ---- */

int camera_get_thermometry_enabled(LONG uid, int *enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byEnabled;
    return 1;
}

int camera_set_thermometry_enabled(LONG uid, int enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byEnabled = enabled ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_thermometry_overlay(LONG uid, int *enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *enabled = tp.byThermometryStreamOverlay;
    return 1;
}

int camera_set_thermometry_overlay(LONG uid, int enabled)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.byThermometryStreamOverlay = enabled ? 1 : 0;
    return camera_set_thermometry(uid, &tp);
}

/* ---- 5k. 外部光学 ---- */

int camera_get_external_optics_correction(LONG uid, DWORD *temp)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *temp = tp.dwExternalOpticsWindowCorrection;
    return 1;
}

int camera_set_external_optics_correction(LONG uid, DWORD temp)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwExternalOpticsWindowCorrection = temp;
    return camera_set_thermometry(uid, &tp);
}

int camera_get_atmospheric_humidity(LONG uid, DWORD *humidity)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    *humidity = tp.dwAtmosphericHumidity;
    return 1;
}

int camera_set_atmospheric_humidity(LONG uid, DWORD humidity)
{
    USB_THERMOMETRY_BASIC_PARAM tp;
    if (!camera_get_thermometry(uid, &tp)) return 0;
    tp.dwAtmosphericHumidity = humidity;
    return camera_set_thermometry(uid, &tp);
}

/* ======================================================================== *
 *  6.  测温模式                                                             *
 * ======================================================================== */

int camera_get_thermometry_mode(LONG uid, USB_THERMOMETRY_MODE *mode)
{
    memset(mode, 0, sizeof(*mode));
    mode->dwSize = sizeof(*mode);
    return get_config(uid, USB_GET_THERMOMETRY_MODE, mode, sizeof(*mode));
}

int camera_set_thermometry_mode(LONG uid, const USB_THERMOMETRY_MODE *mode)
{
    return set_config(uid, USB_SET_THERMOMETRY_MODE,
                      (void *)mode, sizeof(*mode));
}

/* ======================================================================== *
 *  7.  测温规则区域                                                          *
 * ======================================================================== */

int camera_get_thermometry_regions(LONG uid, USB_THERMOMETRY_REGIONS *regs)
{
    memset(regs, 0, sizeof(*regs));
    regs->dwSize = sizeof(*regs);
    return get_config(uid, USB_GET_THERMOMETRY_REGIONS, regs, sizeof(*regs));
}

int camera_set_thermometry_regions(LONG uid,
                                   const USB_THERMOMETRY_REGIONS *regs)
{
    return set_config(uid, USB_SET_THERMOMETRY_REGIONS,
                      (void *)regs, sizeof(*regs));
}

/* ======================================================================== *
 *  8.  测温修正                                                             *
 * ======================================================================== */

int camera_get_temperature_correct(LONG uid, USB_TEMPERATURE_CORRECT *tc)
{
    memset(tc, 0, sizeof(*tc));
    tc->dwSize = sizeof(*tc);
    return get_config(uid, USB_GET_TEMPERATURE_CORRECT, tc, sizeof(*tc));
}

int camera_set_temperature_correct(LONG uid,
                                   const USB_TEMPERATURE_CORRECT *tc)
{
    return set_config(uid, USB_SET_TEMPERATURE_CORRECT,
                      (void *)tc, sizeof(*tc));
}

/* ======================================================================== *
 *  9.  黑体参数                                                             *
 * ======================================================================== */

int camera_get_black_body(LONG uid, USB_BLACK_BODY *bb)
{
    memset(bb, 0, sizeof(*bb));
    bb->dwSize = sizeof(*bb);
    return get_config(uid, USB_GET_BLACK_BODY, bb, sizeof(*bb));
}

int camera_set_black_body(LONG uid, const USB_BLACK_BODY *bb)
{
    return set_config(uid, USB_SET_BLACK_BODY, (void *)bb, sizeof(*bb));
}

/* ======================================================================== *
 *  10. 体温补偿                                                             *
 * ======================================================================== */

int camera_get_bodytemp_compensation(LONG uid,
                                     USB_BODYTEMP_COMPENSATION *bc)
{
    memset(bc, 0, sizeof(*bc));
    bc->dwSize = sizeof(*bc);
    return get_config(uid, USB_GET_BODYTEMP_COMPENSATION,
                      bc, sizeof(*bc));
}

int camera_set_bodytemp_compensation(LONG uid,
                                     const USB_BODYTEMP_COMPENSATION *bc)
{
    return set_config(uid, USB_SET_BODYTEMP_COMPENSATION,
                      (void *)bc, sizeof(*bc));
}

/* ======================================================================== *
 *  11. 全屏测温参数                                                         *
 * ======================================================================== */

int camera_get_p2p_param(LONG uid, USB_P2P_PARAM *p2p)
{
    memset(p2p, 0, sizeof(*p2p));
    p2p->dwSize = sizeof(*p2p);
    return get_config(uid, USB_GET_P2P_PARAM, p2p, sizeof(*p2p));
}

int camera_set_p2p_param(LONG uid, const USB_P2P_PARAM *p2p)
{
    return set_config(uid, USB_SET_P2P_PARAM, (void *)p2p, sizeof(*p2p));
}

/* ======================================================================== *
 *  12. 热成像码流参数                                                       *
 * ======================================================================== */

int camera_get_thermal_stream_param(LONG uid, USB_THERMAL_STREAM_PARAM *sp)
{
    memset(sp, 0, sizeof(*sp));
    sp->dwSize = sizeof(*sp);
    return get_config(uid, USB_GET_THERMAL_STREAM_PARAM, sp, sizeof(*sp));
}

/* ======================================================================== *
 *  13. 算法版本                                                             *
 * ======================================================================== */

int camera_get_thermal_alg_version(LONG uid, USB_THERMAL_ALG_VERSION *ver)
{
    memset(ver, 0, sizeof(*ver));
    ver->dwSize = sizeof(*ver);
    return get_config(uid, USB_GET_THERMAL_ALG_VERSION, ver, sizeof(*ver));
}

/* ======================================================================== *
 *  14. 诊断信息导出                                                         *
 * ======================================================================== */

int camera_get_system_diagnosed_data(LONG uid,
                                     USB_SYSTEM_DIAGNOSED_DATA *dd)
{
    dd->dwSize = sizeof(*dd);
    return get_config(uid, USB_GET_SYSTEM_DIAGNOSED_DATA, dd, sizeof(*dd));
}

/* ======================================================================== *
 *  15. ROI 最高温搜索                                                       *
 * ======================================================================== */

int camera_get_roi_max_temperature_search(
        LONG uid,
        USB_ROI_MAX_TEMPERATURE_SEARCH *in,
        USB_ROI_MAX_TEMPERATURE_SEARCH_RESULT *out)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;
    in->dwSize = sizeof(*in);

    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = in;
    ii.dwInBufferSize   = sizeof(*in);
    oo.lpOutBuffer      = out;
    oo.dwOutBufferSize  = sizeof(*out);

    if (!USB_GetDeviceConfig(uid, USB_GET_ROI_MAX_TEMPERATURE_SEARCH,
                             &ii, &oo)) {
        printf("[camera_config] get_roi_max_temperature_search 失败\n");
        return 0;
    }
    return 1;
}

/* ======================================================================== *
 *  16. 双光校准                                                             *
 * ======================================================================== */

int camera_post_double_lights_correct(
        LONG uid,
        const USB_DOUBLE_LIGHTS_CORRECT *in,
        USB_DOUBLE_LIGHTS_CORRECT_RESULT *out)
{
    USB_COMMON_COND         cond = {0};
    USB_CONFIG_INPUT_INFO   ii   = {0};
    USB_CONFIG_OUTPUT_INFO  oo   = {0};

    cond.dwSize = sizeof(cond);
    cond.byChannelID = 1;

    ii.lpCondBuffer     = &cond;
    ii.dwCondBufferSize = sizeof(cond);
    ii.lpInBuffer       = (void *)in;
    ii.dwInBufferSize   = sizeof(*in);
    oo.lpOutBuffer      = out;
    oo.dwOutBufferSize  = sizeof(*out);

    if (!USB_GetDeviceConfig(uid, USB_POST_DOUBLE_LIGHTS_CORRECT,
                             &ii, &oo)) {
        printf("[camera_config] post_double_lights_correct 失败\n");
        return 0;
    }
    return 1;
}

int camera_get_double_lights_correct_points_ctrl(
        LONG uid, USB_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL *ctrl)
{
    memset(ctrl, 0, sizeof(*ctrl));
    ctrl->dwSize = sizeof(*ctrl);
    return get_config(uid, USB_GET_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL,
                      ctrl, sizeof(*ctrl));
}

int camera_set_double_lights_correct_points_ctrl(
        LONG uid, const USB_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL *ctrl)
{
    return set_config(uid, USB_SET_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL,
                      (void *)ctrl, sizeof(*ctrl));
}

/* ======================================================================== *
 *  17. 测温标定文件                                                         *
 * ======================================================================== */

int camera_get_thermometry_calibration_file(
        LONG uid, USB_THERMOMETRY_CALIBRATION_FILE *cf)
{
    cf->dwSize = sizeof(*cf);
    return get_config(uid, USB_GET_THERMOMETRY_CALIBRATION_FILE,
                      cf, sizeof(*cf));
}

int camera_set_thermometry_calibration_file(
        LONG uid, const USB_THERMOMETRY_CALIBRATION_FILE *cf)
{
    return set_config(uid, USB_SET_THERMOMETRY_CALIBRATION_FILE,
                      (void *)cf, sizeof(*cf));
}

/* ======================================================================== *
 *  18. 命令状态                                                             *
 * ======================================================================== */

int camera_get_command_state(LONG uid, USB_COMMAND_STATE *state)
{
    memset(state, 0, sizeof(*state));
    state->dwSize = sizeof(*state);
    if (!USB_GetCommandState(uid, state)) {
        printf("[camera_config] get_command_state 失败\n");
        return 0;
    }
    return 1;
}
/*
 * camera_config.h — 热成像摄像头参数配置接口
 *
 * 基于 HCUsbSDK 封装,提供热成像设备的全部配置项。
 * 所有配置操作通过 USB 通道 1(官方 Hikvision demo 约定)发送。
 * 返回 1 成功 / 0 失败(失败时自动打印错误码)。
 *
 * 字段访问器采用 读-改-写 模式:先 GET 整个结构体,修改单个字段,再 SET 回设备。
 * 命名约定:camera_{get/set}_{参数名},无特殊声明时走通道 1、无额外数据。
 */

#ifndef CAMERA_CONFIG_H
#define CAMERA_CONFIG_H

#include "HCUsbSDK.h"

#ifdef __cplusplus
extern "C" {
#endif

/* ======================================================================== *
 *  1.  系统管理                                                             *
 * ======================================================================== */

int camera_get_device_info(LONG uid, USB_SYSTEM_DEVICE_INFO *info);
/*  读取设备信息(固件版本、硬件版本、设备型号、序列号等)。
 *  @param uid    登录句柄
 *  @param info   输出:设备信息结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_reboot(LONG uid);
/*  远程重启设备。
 *  @param uid    登录句柄
 *  @return       1 成功 / 0 失败                              */

int camera_set_factory_reset(LONG uid);
/*  恢复设备出厂设置。
 *  @param uid    登录句柄
 *  @return       1 成功 / 0 失败                              */

int camera_set_background_correct(LONG uid);
/*  一键背景校正(快门校正),消除热成像背景不均匀。
 *  @param uid    登录句柄
 *  @return       1 成功 / 0 失败                              */

int camera_set_manual_correct(LONG uid);
/*  一键手动校正(快门校正)。
 *  @param uid    登录句柄
 *  @return       1 成功 / 0 失败                              */

int camera_get_hardware_server(LONG uid, USB_SYSTEM_HARDWARE_SERVER *hs);
/*  读取硬件服务参数(USB 模式、设备初始化/运行状态)。
 *  @param uid    登录句柄
 *  @param hs     输出:硬件服务参数结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_hardware_server(LONG uid, const USB_SYSTEM_HARDWARE_SERVER *hs);
/*  设置硬件服务参数。
 *  @param uid    登录句柄
 *  @param hs     输入:硬件服务参数结构体
 *  @return       1 成功 / 0 失败                              */

int camera_get_local_time(LONG uid, USB_SYSTEM_LOCALTIME *lt);
/*  读取设备本地时间(年/月/日/时/分/秒/毫秒)。
 *  @param uid    登录句柄
 *  @param lt     输出:系统时间结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_local_time(LONG uid, const USB_SYSTEM_LOCALTIME *lt);
/*  校时——设置设备本地时间。
 *  @param uid    登录句柄
 *  @param lt     输入:期望的系统时间结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  2.  图像增强 Enhancement(V20 → V1 自动回退)                             *
 *      USB_IMAGE_ENHANCEMENT 结构体,先试 _V20 命令,失败回退 V1。           *
 * ======================================================================== */

int camera_get_enhancement(LONG uid, USB_IMAGE_ENHANCEMENT *enh);
/*  读取完整的图像增强参数(降噪/伪彩/DDE/勾边/宽动态等所有字段)。
 *  @param uid    登录句柄
 *  @param enh    输出:图像增强结构体(注意 enh->dwSize 必须预置 sizeof(*enh))
 *  @return       1 成功 / 0 失败                              */

int camera_set_enhancement(LONG uid, const USB_IMAGE_ENHANCEMENT *enh);
/*  写入完整的图像增强参数(读-改-写风格:先 get 再 put)。
 *  @param uid    登录句柄
 *  @param enh    输入:填充好的图像增强结构体
 *  @return       1 成功 / 0 失败                              */

/* ---- 2a. 伪彩色 ---- */

int camera_get_palette(LONG uid, int *mode);
/*  获取当前伪彩色模式。
 *  @param mode   输出:1=白热 2=黑热 10=融合1 11=彩虹 12=铁红1
 *                13=琥珀1 14=琥珀2 15=高对比 16=色彩1 17=色彩2
 *                18=冰火 19=雨 20=高亮 21=聚焦 22=紫红
 *  @return       1 成功 / 0 失败                              */

int camera_set_palette(LONG uid, int mode);
/*  设置伪彩色模式。
 *  @param mode   同上取值
 *  @return       1 成功 / 0 失败                              */

/* ---- 2b. AGC 模式 ---- */

int camera_get_agc_mode(LONG uid, int *mode);
/*  读取 AGC(自动增益控制)模式。
 *  @param mode   输出:1=线性 2=直方图 3=巡检 4=手动
 *  @return       1 成功 / 0 失败                              */

int camera_set_agc_mode(LONG uid, int mode);
/*  设置 AGC 模式。
 *  @param mode   同上取值
 *  @return       1 成功 / 0 失败                              */

/* ---- 2c. DDE(数字细节增强) ---- */

int camera_get_dde_enabled(LONG uid, int *enabled);
/*  读取 DDE 开关状态。
 *  @param enabled 输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_dde_enabled(LONG uid, int enabled);
/*  设置 DDE 开关。
 *  @param enabled 0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_get_dde_level(LONG uid, DWORD *level);
/*  读取 DDE 细节增强级别。
 *  @param level  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_dde_level(LONG uid, DWORD level);
/*  设置 DDE 细节增强级别。
 *  @param level  0~100
 *  @return       1 成功 / 0 失败                              */

/* ---- 2d. 降噪 ---- */

int camera_get_noise_reduce_mode(LONG uid, int *mode);
/*  读取降噪模式。
 *  @param mode   输出:0=关闭 1=普通 2=专家
 *  @return       1 成功 / 0 失败                              */

int camera_set_noise_reduce_mode(LONG uid, int mode);
/*  设置降噪模式。
 *  @param mode   同上取值。注意:设为 2(专家)时需配合 temporal/spatial 级别。
 *  @return       1 成功 / 0 失败                              */

int camera_get_general_denoise(LONG uid, DWORD *level);
/*  读取普通降噪级别(普通模式下生效)。
 *  @param level  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_general_denoise(LONG uid, DWORD level);
/*  设置普通降噪级别。自动将降噪模式设为 1(普通)。
 *  @param level  0~100
 *  @return       1 成功 / 0 失败                              */

int camera_get_spatial_denoise(LONG uid, DWORD *level);
/*  读取空域降噪级别(专家模式下生效)。
 *  @param level  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_spatial_denoise(LONG uid, DWORD level);
/*  设置空域降噪级别。自动将降噪模式设为 2(专家)。
 *  @param level  0~100
 *  @return       1 成功 / 0 失败                              */

int camera_get_temporal_denoise(LONG uid, DWORD *level);
/*  读取时域降噪级别(专家模式下生效)。
 *  @param level  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_temporal_denoise(LONG uid, DWORD level);
/*  设置时域降噪级别。自动将降噪模式设为 2(专家)。
 *  @param level  0~100
 *  @return       1 成功 / 0 失败                              */

/* ---- 2e. 勾边 ---- */

int camera_get_hook_edge(LONG uid, int *enabled, int *level);
/*  读取勾边(边缘增强)参数。
 *  @param enabled 输出:0=关闭 1=开启
 *  @param level   输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_hook_edge(LONG uid, int enabled, int level);
/*  设置勾边参数。
 *  @param enabled 0=关闭 1=开启
 *  @param level   0~100
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  3.  视频调整 VideoAdjust(镜像/数字变倍/工频)                            *
 * ======================================================================== */

int camera_get_video_adjust(LONG uid, USB_IMAGE_VIDEO_ADJUST *va);
/*  读取完整的视频调整参数(镜像/变倍/工频/走廊模式等)。
 *  @param uid    登录句柄
 *  @param va     输出:视频调整结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_video_adjust(LONG uid, const USB_IMAGE_VIDEO_ADJUST *va);
/*  写入完整的视频调整参数。
 *  @param uid    登录句柄
 *  @param va     输入:填充好的视频调整结构体
 *  @return       1 成功 / 0 失败                              */

int camera_get_mirror_mode(LONG uid, int *mode);
/*  读取镜像模式。
 *  @param mode   输出:0=关闭 1=中心 2=左右 3=上下
 *  @return       1 成功 / 0 失败                              */

int camera_set_mirror_mode(LONG uid, int mode);
/*  设置镜像模式。
 *  @param mode   同上取值
 *  @return       1 成功 / 0 失败                              */

int camera_get_digital_zoom(LONG uid, int *zoom);
/*  读取数字变倍倍率。
 *  @param zoom   输出:0=1× 1=2× 2=4× 3=8×
 *  @return       1 成功 / 0 失败                              */

int camera_set_digital_zoom(LONG uid, int zoom);
/*  设置数字变倍。
 *  @param zoom   同上取值
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  4.  亮度 / 对比度(单字段结构体)                                         *
 * ======================================================================== */

int camera_get_brightness(LONG uid, DWORD *value);
/*  读取图像亮度。
 *  @param value  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_brightness(LONG uid, DWORD value);
/*  设置图像亮度。
 *  @param value  0~100
 *  @return       1 成功 / 0 失败                              */

int camera_get_contrast(LONG uid, DWORD *value);
/*  读取图像对比度。
 *  @param value  输出:0~100
 *  @return       1 成功 / 0 失败                              */

int camera_set_contrast(LONG uid, DWORD value);
/*  设置图像对比度。
 *  @param value  0~100
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  5.  测温基本参数 Thermometry                                              *
 *      USB_THERMOMETRY_BASIC_PARAM 结构体(发射率/距离/范围/单位/报警/环境等)   *
 * ======================================================================== */

int camera_get_thermometry(LONG uid, USB_THERMOMETRY_BASIC_PARAM *tp);
/*  读取完整的测温基本参数结构体(所有字段)。
 *  @param uid    登录句柄
 *  @param tp     输出:测温参数结构体(tp->dwSize 预置 sizeof(*tp))
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry(LONG uid, const USB_THERMOMETRY_BASIC_PARAM *tp);
/*  写入完整的测温基本参数结构体。
 *  @param uid    登录句柄
 *  @param tp     输入:填充好的测温参数结构体
 *  @return       1 成功 / 0 失败                              */

/* ---- 5a. 发射率 ---- */

int camera_get_emissivity(LONG uid, DWORD *value_x100);
/*  读取发射率。
 *  @param value_x100  输出:实际值×100(例如 0.97 → 97)
 *  @return       1 成功 / 0 失败                              */

int camera_set_emissivity(LONG uid, DWORD value_x100);
/*  设置发射率。
 *  @param value_x100  实际值×100(范围 1~100,对应 0.01~1.00)
 *  @return       1 成功 / 0 失败                              */

/* ---- 5b. 测温距离 ---- */

int camera_get_distance(LONG uid, DWORD *distance_cm);
/*  读取测温距离。
 *  @param distance_cm  输出:厘米
 *  @return       1 成功 / 0 失败                              */

int camera_set_distance(LONG uid, DWORD distance_cm);
/*  设置测温距离。
 *  @param distance_cm  厘米,范围 30~200(即 0.3~2.0 米)
 *  @return       1 成功 / 0 失败                              */

int camera_get_distance_unit(LONG uid, int *unit);
/*  读取距离单位。
 *  @param unit   输出:1=米 2=厘米 3=英尺
 *  @return       1 成功 / 0 失败                              */

int camera_set_distance_unit(LONG uid, int unit);
/*  设置距离单位。
 *  @param unit   同上取值
 *  @return       1 成功 / 0 失败                              */

/* ---- 5c. 温度单位 ---- */

int camera_get_temp_unit(LONG uid, int *unit);
/*  读取温度单位。
 *  @param unit   输出:1=℃  2=℉  3=K
 *  @return       1 成功 / 0 失败                              */

int camera_set_temp_unit(LONG uid, int unit);
/*  设置温度单位。
 *  @param unit   同上取值
 *  @return       1 成功 / 0 失败                              */

/* ---- 5d. 测温范围 ---- */

int camera_get_temp_range(LONG uid, int *range);
/*  读取测温范围档位。
 *  @param range  输出:1~5(各档位对应的上下限温度由硬件决定,只读)
 *  @return       1 成功 / 0 失败                              */

int camera_set_temp_range(LONG uid, int range);
/*  设置测温范围档位。
 *  @param range  1~5
 *  @return       1 成功 / 0 失败                              */

int camera_get_temp_range_auto_enabled(LONG uid, int *enabled);
/*  读取测温档位自动切换开关。
 *  @param enabled  输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_temp_range_auto_enabled(LONG uid, int enabled);
/*  设置测温档位自动切换开关。
 *  @param enabled  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

/* ---- 5e. 标定系数 ---- */

int camera_get_calibration_coeff(LONG uid, DWORD *value_x100);
/*  读取标定系数。
 *  @param value_x100  输出:实际值×100(例如 1.00 → 100),范围 0~3000
 *  @return       1 成功 / 0 失败                              */

int camera_set_calibration_coeff(LONG uid, DWORD value_x100);
/*  设置标定系数。
 *  @param value_x100  实际值×100(0.00~30.00 对应 0~3000)
 *  @return       1 成功 / 0 失败                              */

int camera_get_calibration_coeff_enabled(LONG uid, int *enabled);
/*  读取标定系数启用状态。
 *  @param enabled  输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_calibration_coeff_enabled(LONG uid, int enabled);
/*  设置标定系数启用状态。
 *  @param enabled  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

/* ---- 5f. 反射温度 ---- */

int camera_get_reflective_temp(LONG uid, DWORD *temp_x10);
/*  读取反射温度补偿值。
 *  @param temp_x10  输出:(实际温度 + 100) × 10(例如 25.0℃ → 1250)
 *  @return       1 成功 / 0 失败                              */

int camera_set_reflective_temp(LONG uid, DWORD temp_x10);
/*  设置反射温度补偿值。
 *  @param temp_x10  (实际温度 + 100) × 10
 *  @return       1 成功 / 0 失败                              */

int camera_get_reflective_enabled(LONG uid, int *enabled);
/*  读取反射温度使能。
 *  @param enabled  输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_reflective_enabled(LONG uid, int enabled);
/*  设置反射温度使能。
 *  @param enabled  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

/* ---- 5g. 环境温度 ---- */

int camera_get_env_temp(LONG uid, int *enabled, DWORD *temp);
/*  读取环境温度参数。
 *  @param enabled 输出:0=关闭 1=开启
 *  @param temp    输出:(实际值 + 100) × 10,-99.0~99.0℃
 *  @return       1 成功 / 0 失败                              */

int camera_set_env_temp(LONG uid, int enabled, DWORD temp);
/*  设置环境温度参数。
 *  @param enabled 0=关闭 1=开启
 *  @param temp    (实际值 + 100) × 10
 *  @return       1 成功 / 0 失败                              */

/* ---- 5h. 报警温度 ---- */

#define CAMERA_ALERT_TEMP_MIN  0u
#define CAMERA_ALERT_TEMP_MAX  20000u  /* 200.00℃ × 100 */

int camera_get_alert_temp(LONG uid, DWORD *temp_x100);
/*  读取预警温度(根据位号显示框标记)。
 *  @param temp_x100  输出:实际值×100
 *  @return       1 成功 / 0 失败                              */

int camera_set_alert_temp(LONG uid, DWORD temp_x100);
/*  设置预警温度。
 *  @param temp_x100  实际值×100
 *  @return       1 成功 / 0 失败                              */

int camera_get_alarm_temp(LONG uid, DWORD *temp_x100);
/*  读取报警温度(超温报警)。
 *  @param temp_x100  输出:实际值×100
 *  @return       1 成功 / 0 失败                              */

int camera_set_alarm_temp(LONG uid, DWORD temp_x100);
/*  设置报警温度。
 *  @param temp_x100  实际值×100
 *  @return       1 成功 / 0 失败                              */

/* ---- 5i. 显示元素 ---- */

int camera_get_display_flags(LONG uid, int *max_en, int *min_en,
                             int *avg_en, int *cen_en);
/*  读取测温信息叠加显示选项。
 *  @param max_en  输出:0=关闭 1=开启  最高温显示
 *  @param min_en  输出:0=关闭 1=开启  最低温显示
 *  @param avg_en  输出:0=关闭 1=开启  平均温显示
 *  @param cen_en  输出:0=关闭 1=开启  中心温显示
 *  @return       1 成功 / 0 失败                              */

int camera_set_display_flags(LONG uid, int max_en, int min_en,
                             int avg_en, int cen_en);
/*  设置测温信息叠加显示选项。
 *  @param max_en/min_en/avg_en/cen_en  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

/* ---- 5j. 全局开关与码流叠加 ---- */

int camera_get_thermometry_enabled(LONG uid, int *enabled);
/*  读取测温功能全局使能。
 *  @param enabled  输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry_enabled(LONG uid, int enabled);
/*  设置测温功能全局使能。
 *  @param enabled  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_get_thermometry_overlay(LONG uid, int *enabled);
/*  读取测温信息码流叠加使能(由设备在视频流中叠加温度文字/框)。
 *  @param enabled  输出:0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry_overlay(LONG uid, int enabled);
/*  设置测温信息码流叠加使能。
 *  @param enabled  0=关闭 1=开启
 *  @return       1 成功 / 0 失败                              */

/* ---- 5k. 外部光学参数 ---- */

int camera_get_external_optics_correction(LONG uid, DWORD *temp);
/*  读取外部光学窗口校正温度。
 *  @param temp    输出:实际值×100
 *  @return       1 成功 / 0 失败                              */

int camera_set_external_optics_correction(LONG uid, DWORD temp);
/*  设置外部光学窗口校正温度。
 *  @param temp    实际值×100
 *  @return       1 成功 / 0 失败                              */

int camera_get_atmospheric_humidity(LONG uid, DWORD *humidity);
/*  读取大气湿度补偿值。
 *  @param humidity 输出:百分比×100(例如 5000 = 50.00%)
 *  @return       1 成功 / 0 失败                              */

int camera_set_atmospheric_humidity(LONG uid, DWORD humidity);
/*  设置大气湿度补偿值。
 *  @param humidity 百分比×100
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  6.  测温模式 ThermometryMode                                              *
 *      USB_THERMOMETRY_MODE:普通 / 专家 + ROI 使能                          *
 * ======================================================================== */

int camera_get_thermometry_mode(LONG uid, USB_THERMOMETRY_MODE *mode);
/*  读取测温模式(普通/专家)及 ROI 使能状态。
 *  @param uid    登录句柄
 *  @param mode   输出:测温模式结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry_mode(LONG uid, const USB_THERMOMETRY_MODE *mode);
/*  设置测温模式。
 *  @param uid    登录句柄
 *  @param mode   输入:填充好的测温模式结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  7.  测温规则区域 ThermometryRegions                                       *
 *      USB_THERMOMETRY_REGIONS:多个 THERMAL_REGION,归一化坐标 0-1000       *
 * ======================================================================== */

int camera_get_thermometry_regions(LONG uid, USB_THERMOMETRY_REGIONS *regs);
/*  读取测温规则区域配置。
 *  @param uid    登录句柄
 *  @param regs   输出:测温规则区域结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry_regions(LONG uid,
                                   const USB_THERMOMETRY_REGIONS *regs);
/*  写入测温规则区域配置。
 *  @param uid    登录句柄
 *  @param regs   输入:填充好的测温规则区域结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  8.  测温修正 TemperatureCorrect                                           *
 *      USB_TEMPERATURE_CORRECT:黑体温度修正(发射率/距离/黑体温度/修正温度)   *
 * ======================================================================== */

int camera_get_temperature_correct(LONG uid, USB_TEMPERATURE_CORRECT *tc);
/*  读取测温修正参数(黑体修正使能/发射率/距离/黑体温度/中心坐标/修正温度)。
 *  @param uid    登录句柄
 *  @param tc     输出:测温修正结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_temperature_correct(LONG uid,
                                   const USB_TEMPERATURE_CORRECT *tc);
/*  写入测温修正参数。
 *  @param uid    登录句柄
 *  @param tc     输入:填充好的测温修正结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  9.  黑体参数 BlackBody                                                    *
 *      USB_BLACK_BODY:黑体使能/发射率/距离/黑体温度/中心坐标                 *
 * ======================================================================== */

int camera_get_black_body(LONG uid, USB_BLACK_BODY *bb);
/*  读取黑体参数(黑体使能/发射率/距离/温度/中心坐标)。
 *  @param uid    登录句柄
 *  @param bb     输出:黑体参数结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_black_body(LONG uid, const USB_BLACK_BODY *bb);
/*  写入黑体参数。
 *  @param uid    登录句柄
 *  @param bb     输入:填充好的黑体参数结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  10. 体温补偿 BodytempCompensation                                         *
 *      USB_BODYTEMP_COMPENSATION:人体测温使能/补偿类型/补偿值/环境温度/曲线   *
 * ======================================================================== */

int camera_get_bodytemp_compensation(LONG uid,
                                     USB_BODYTEMP_COMPENSATION *bc);
/*  读取体温补偿参数(使能/补偿方式/补偿值/环境温度/曲线灵敏度等)。
 *  @param uid    登录句柄
 *  @param bc     输出:体温补偿结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_bodytemp_compensation(LONG uid,
                                     const USB_BODYTEMP_COMPENSATION *bc);
/*  写入体温补偿参数。
 *  @param uid    登录句柄
 *  @param bc     输入:填充好的体温补偿结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  11. 全屏测温参数 P2PParam                                                 *
 *      USB_P2P_PARAM:全屏测温 JPEG 图返回使能                                *
 * ======================================================================== */

int camera_get_p2p_param(LONG uid, USB_P2P_PARAM *p2p);
/*  读取全屏测温参数(JPEG 图返回使能)。
 *  @param uid    登录句柄
 *  @param p2p    输出:全屏测温参数结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_p2p_param(LONG uid, const USB_P2P_PARAM *p2p);
/*  写入全屏测温参数。
 *  @param uid    登录句柄
 *  @param p2p    输入:填充好的全屏测温参数结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  12. 热成像码流参数 ThermalStreamParam                                     *
 *      USB_THERMAL_STREAM_PARAM:编码类型(裸数据/全屏测温/YUV 等)           *
 *      注意:camera_configure() 已在 camera.c 中调用 SET,此处只提供 GET。    *
 * ======================================================================== */

int camera_get_thermal_stream_param(LONG uid,
                                    USB_THERMAL_STREAM_PARAM *sp);
/*  读取当前热成像码流参数(编码类型/分辨率/帧率)。
 *  @param uid    登录句柄
 *  @param sp     输出:码流参数结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  13. 算法版本 ThermalAlgVersion                                            *
 * ======================================================================== */

int camera_get_thermal_alg_version(LONG uid,
                                   USB_THERMAL_ALG_VERSION *ver);
/*  读取热成像算法版本(算法库名称、逻辑版本号)。
 *  @param uid    登录句柄
 *  @param ver    输出:算法版本结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  14. 诊断信息导出(系统诊断)                                               *
 *      USB_SYSTEM_DIAGNOSED_DATA:导出设备诊断信息到文件                      *
 * ======================================================================== */

int camera_get_system_diagnosed_data(LONG uid,
                                     USB_SYSTEM_DIAGNOSED_DATA *dd);
/*  导出设备诊断信息。调用前 dd->pDiagnosedData 需指向足够大的缓冲区,
 *  dd->dwDataLenth 需预置缓冲区大小。
 *  @param uid    登录句柄
 *  @param dd     输出:诊断数据结构体(诊断数据写入 pDiagnosedData)
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  15. ROI 最高温搜索(复杂操作,涉及 JPEG 输出)                              *
 *      USB_ROI_MAX_TEMPERATURE_SEARCH:设置 ROI 区域查询最高温                 *
 * ======================================================================== */

int camera_get_roi_max_temperature_search(
        LONG uid,
        USB_ROI_MAX_TEMPERATURE_SEARCH *in,
        USB_ROI_MAX_TEMPERATURE_SEARCH_RESULT *out);
/*  ROI 最高温信息查询。输入 ROI 区域和时间,输出各 ROI 最高温及可选的 JPEG 图。
 *  调用前 out->pJpegPic 需指向足够大的缓冲区,out->dwJpegPicLen 预置缓冲区大小。
 *  @param uid    登录句柄
 *  @param in     输入:查询条件(ROI 区域/时间/JPEG 使能等)
 *  @param out    输出:查询结果(最高温/坐标/JPEG 数据)
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  16. 双光校准(复杂操作,涉及可见光图片文件)                                *
 * ======================================================================== */

int camera_post_double_lights_correct(
        LONG uid,
        const USB_DOUBLE_LIGHTS_CORRECT *in,
        USB_DOUBLE_LIGHTS_CORRECT_RESULT *out);
/*  双光校准。输入可见光图片和校准参数,输出校准结果 JPEG。
 *  调用前 in->pVisiblePic 指向已读入的可见光图片数据。
 *         out->pJpegPic 指向输出 JPEG 缓冲区,dwJpegPicLen 预置缓冲区大小。
 *  @param uid    登录句柄
 *  @param in     输入:校准参数
 *  @param out    输出:校准结果
 *  @return       1 成功 / 0 失败                              */

/* --- 双光校准坐标控制 --- */

int camera_get_double_lights_correct_points_ctrl(
        LONG uid, USB_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL *ctrl);
/*  读取双光校准坐标控制使能。
 *  @param ctrl   输出:坐标控制结构体
 *  @return       1 成功 / 0 失败                              */

int camera_set_double_lights_correct_points_ctrl(
        LONG uid, const USB_DOUBLE_LIGHTS_CORRECT_POINTS_CTRL *ctrl);
/*  设置双光校准坐标控制使能。
 *  @param ctrl   输入:填充好的坐标控制结构体
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  17. 测温标定文件导入导出                                                  *
 * ======================================================================== */

int camera_get_thermometry_calibration_file(
        LONG uid, USB_THERMOMETRY_CALIBRATION_FILE *cf);
/*  导出测温标定文件。
 *  调用前 cf->pCalibrationFile 指向足够大的缓冲区,cf->dwFileLenth 预置缓冲区大小。
 *  @param uid    登录句柄
 *  @param cf     输出:标定文件数据
 *  @return       1 成功 / 0 失败                              */

int camera_set_thermometry_calibration_file(
        LONG uid, const USB_THERMOMETRY_CALIBRATION_FILE *cf);
/*  导入测温标定文件。
 *  调用前 cf->pCalibrationFile 指向已读入的标定文件数据,cf->dwFileLenth 为数据长度,
 *  cf->byFileName 需填文件名。
 *  @param uid    登录句柄
 *  @param cf     输入:标定文件数据
 *  @return       1 成功 / 0 失败                              */

/* ======================================================================== *
 *  18. 命令状态查询                                                        *
 * ======================================================================== */

int camera_get_command_state(LONG uid, USB_COMMAND_STATE *state);
/*  查询设备命令状态(用于判断设备是否空闲可接收新命令)。
 *  @param state  输出:命令状态结构体
 *  @return       1 成功 / 0 失败                              */

#ifdef __cplusplus
}
#endif

#endif /* CAMERA_CONFIG_H */

参考目录:

参考

  • 原始码值 → 摄氏度
  • 温度统计结果,对一帧 96×96 原始数据分析(一个结构体包含原始数据,温度数据,温度最高最低点坐标)
  • 最高/最低/平均/中心温及其坐标
  • 缩放原始测温矩阵
/*
 * thermal.h — 热成像测温数据处理声明
 *
 * 原始值 → 摄氏度换算(由固件决定):
 *   TEMP_RAW_TO_C(raw) = raw/64.0 - 50.0
 *   raw = 16-bit 无符号原始值,范围约 3200~12800(0℃~150℃)
 *
 * 坐标系统:
 *   原始测温矩阵 96×96(由传感器分辨率固定),
 *   compute_thermal_stats() 在该分辨率上搜索最高/最低温 x,y。
 */

#ifndef THERMAL_H
#define THERMAL_H

#include <stdint.h>

/* 原始码值 → 摄氏度 */
#define TEMP_RAW_TO_C(raw)  ((raw) / 64.0f - 50.0f)

/* 温度统计结果(对一帧 96×96 原始数据分析得出) */
typedef struct {
    uint16_t raw_min, raw_max, raw_center;  /* 原始码值 */
    float    min_c, max_c, avg_c, center_c; /* 对应摄氏度 */
    int      min_x, min_y, max_x, max_y;    /* 最高/最低温在 96×96 网格中的坐标 */
} thermal_stats_t;

/* 分析一帧原始测温数据。
 * @param temp  96×96 uint16_t 原始数据
 * @param w,h  宽度和高度(固定 96×96)
 * @return      thermal_stats_t 包含最高/最低/平均/中心温及其坐标
 */
thermal_stats_t compute_thermal_stats(const uint16_t *temp, int w, int h);

/* 缩放原始测温矩阵(最近邻插值)。
 * @param src  输入矩阵 sw×sh
 * @param dst  输出矩阵 dw×dh(调用者分配)
 */
void scale_thermal(const uint16_t *src, int sw, int sh,
                   uint16_t *dst, int dw, int dh);

#endif
/*
 * thermal.c — 测温数据处理实现
 *
 * 提供两个核心函数:
 *   compute_thermal_stats — 遍历 96×96 原始数据,找最高温/最低温/中心温/平均温
 *   scale_thermal         — 最近邻插值缩放原始矩阵
 *
 * 原始值 → 摄氏度换算(固件约定):
 *   °C = raw_value / 64 - 50
 *   例如 raw=6400 → 50.0°C
 */

#include "thermal.h"
#include <limits.h>

/*
 * 最近邻插值缩放原始测温矩阵。
 *
 * @param src  输入矩阵 sw×sh
 * @param dst  输出矩阵 dw×dh(由调用者分配)
 * @param sw,sh  输入尺寸
 * @param dw,dh  输出尺寸
 */
void scale_thermal(const uint16_t *src, int sw, int sh,
                   uint16_t *dst, int dw, int dh)
{
    for (int dy = 0; dy < dh; dy++) {
        int sy = dy * sh / dh;
        for (int dx = 0; dx < dw; dx++) {
            int sx = dx * sw / dw;
            dst[dy * dw + dx] = src[sy * sw + sx];
        }
    }
}

/*
 * 计算一帧原始测温数据的统计信息。
 *
 * 遍历整个矩阵,同时找出最高温/最低温的原始值及其坐标,
 * 计算平均温,读取中心点温度。
 * 所有温度同时以原始值和摄氏度返回。
 *
 * @param temp  96×96 uint16_t 原始数据
 * @param w,h   矩阵尺寸(固定 96×96)
 * @return      填充好的 thermal_stats_t
 */
thermal_stats_t compute_thermal_stats(const uint16_t *temp, int w, int h)
{
    thermal_stats_t stats;
    int      pixels = w * h;
    uint16_t tmin = UINT16_MAX, tmax = 0;
    double   sum = 0.0;
    int      cx = w / 2, cy = h / 2;
    int      min_xi = 0, min_yi = 0, max_xi = 0, max_yi = 0;

    for (int yi = 0; yi < h; yi++) {
        for (int xi = 0; xi < w; xi++) {
            uint16_t v = temp[yi * w + xi];
            if (v < tmin) { tmin = v; min_xi = xi; min_yi = yi; }
            if (v > tmax) { tmax = v; max_xi = xi; max_yi = yi; }
            sum += v;
        }
    }

    stats.raw_min    = tmin;
    stats.raw_max    = tmax;
    stats.raw_center = temp[cy * w + cx];
    stats.min_c      = TEMP_RAW_TO_C(tmin);
    stats.max_c      = TEMP_RAW_TO_C(tmax);
    stats.avg_c      = TEMP_RAW_TO_C(sum / pixels);
    stats.center_c   = TEMP_RAW_TO_C(stats.raw_center);
    stats.min_x      = min_xi;
    stats.min_y      = min_yi;
    stats.max_x      = max_xi;
    stats.max_y      = max_yi;

    return stats;
}

V4L2

Media control

media-controller框架专门用于控制soc内部 视频相关 硬件连接状态。

以rk的SOC为例:

mipi摄像头(DPHY输出)
	 ↓
SOC: DPHY
	 ↓
SOC: csi2_dphy
	 ↓
SOC: mipi1_csi2(CSI-2 Host)
	 ↓								 DMA(CMA)
SOC: rkcif_mipi_lvds1(VICAP / CIF) → 输出到DDR
	 ↓
SOC: RK-ISP子系统
	 ↓ (处理后)
  输出到DDR(DMA)

比如:直接控制不走ISP

不同平台有不一样的链路,所以需要一个专门的框架去做抽象管理。

ls /dev/media*
media-ctl -p -d /dev/media0

V4L2 控件本质上是一个 key-value 配置接口,驱动暴露一组 id → value 的映射,用户层通过 ioctl 读写。

对应V4L2设备,以摄像头为例:

​ 有控件的说法。很多V4L2设备高度抽象了一些控件。直接拿来用就好。

​ 比如:亮度、对比度控件。

1. 数据类型(能存什么值)

类型说明例子
INTEGER整数,范围 min~max,步进 step亮度 0~255 step 1
BOOLEAN0/1自动白平衡开关
MENU索引 → 字符串电源频率 0=50Hz 1=60Hz
INTEGER_MENU索引 → 整数某驱动自定义模式 ID
BUTTON写入即触发一个动作执行一次白平衡
INTEGER6464位整数大范围计数器
STRING字符串设备序列号
BITMASK位掩码各功能使能位

2. 属性标志(能怎么用)

当前文件 print_ctrl_flags() 已经列的:

标志含义
DISABLED控件被禁用,不可用
READ_ONLY只读,只能查询不能设置
WRITE_ONLY只写(通常是触发类)
VOLATILE值会随硬件自动变化(如自动曝光时 actual exposure)
HAS_PAYLOAD携带复杂数据结构(H.264/HDR 元数据等)
EXECUTE_ON_WRITE写入即执行(BUTTON 类型必须有此标志)
MODIFY_LAYOUT改变此控件会影响图像布局(比如改变裁剪窗口)

枚举设备支持的控件

像素格式有哪些?

摄像头常见输出可分为未压缩像素格式和视频编码格式。YUV、RGB 是存储色彩的像素数据,而 H.264 则用来压缩编码这些数据。

  • 未压缩像素格式 (Uncompressed Pixel Formats):这类格式可直接描述图像的亮度、色度或 RGB 分量。本文中的 YUYV、NV12 等属于这一类;严格来说,传感器输出的 Bayer 数据才通常称为 RAW。未压缩格式又主要分为两种:
格式大类代表格式内存占用 (1080p每帧)主要优缺点数据排列方式
RGB类RGB24, BGR24, RGBA32~6.2MB数据量大,逻辑直观,适合直接在屏幕上显示和处理。每个像素固定存储红、绿、蓝三原色的数据。
YUV类YUYV, NV12, I420, YV12约3~4.1MB将亮度和颜色分离,利于压缩和兼容黑白显示;数据量通常小于 RGB。每个像素都有亮度(Y)值,而色度(U/V)值则由相邻像素共享。

YUV的采样方式(如4:4:4、4:2:2、4:2:0)决定了色度信息的保留程度。常见YUV格式的区别如下:

格式名称采样方式存储类型特点
YUYV4:2:2打包 (Packed)每两个像素共享一对UV,顺序交错排列。
UYVY4:2:2打包 (Packed)与YUYV类似,但顺序变为U、Y、V、Y。
NV124:2:0半平面 (Semi-Planar)一个Y平面,一个UV交错存储的平面,在Windows、Android和硬件编解码中很常见。
I4204:2:0平面 (Planar)三个平面顺序存储:全部Y,然后全部U,最后全部V。在FFmpeg等软件中广泛使用。
YV124:2:0平面 (Planar)与I420类似,但存储顺序是Y,然后V,最后U。
NV164:2:2半平面 (Semi-Planar)一个 Y 平面,后接 UV 交错平面;相比 NV12 保留更多垂直方向的色度信息。
  • 视频编码格式 (Video Codecs):
    • 特点:通过复杂的算法(如帧内、帧间预测)对原始像素数据进行高倍率压缩。
    • 代表:H.264、H.265、MJPEG等。它们是处理视频文件或通过网络传输时的格式。其中,H.264 是一种高效的视频编码标准,常与原始像素格式(如YUYV)搭配使用,先将数据压缩成H.264再传输。

🔬 YUYV 的深层解读与数据帧结构

1. YUYV 为什么重要?

YUYV 在摄像头和视频领域扮演着“通用语言”的角色。它数据量适中(YUV 4:2:2采样),能提供不错的图像质量,因此被很多USB摄像头(遵循UVC标准)默认支持。

2. YUYV 的数据帧长什么样?

YUYV是一种打包(Packed)格式,意味着它的Y、U、V分量是交错存储在一起的。

采样方式与数据组成:

  • 采样方式:YUV 4:2:2,意味着在水平方向上,每2个像素点共享一对UV色度信息,但每个像素都保留自己独立的Y亮度信息。
  • 数据组成:一个像素对 (4字节) = Y0 (1字节) + U (1字节) + Y1 (1字节) + V (1字节)。所以,如果图像分辨率是 W x H,那么一帧完整画面的数据量约为 W \* H \* 2 字节。

3. 数据排列顺序

假设你有一张4x4像素的图片,YUYV数据在内存中的排列顺序如下图所示,一个格子代表1个字节:

字节偏移Byte 01234567
数据内容Y00U00Y01V00Y02U01Y03V01
所属像素像素(0,0)(0,0)&(0,1)像素(0,1)(0,0)&(0,1)像素(0,2)(0,2)&(0,3)像素(0,3)(0,2)&(0,3)
  • Y后面跟行号和列号,例如Y00表示第0行第0列的Y值。
  • 可以看到,U和V分量总是被相邻的两个Y像素所共享。

🆚 与其他像素格式的数据帧对比

YUYV与其他常见格式的主要区别在于采样方式和排列方式。

对比项YUYV (YUV 4:2:2, Packed)NV12 (YUV 4:2:0, Semi-Planar)RGB24 (RGB, Packed)H.264 (压缩格式)
采样方式YUV 4:2:2YUV 4:2:0N/A (每个像素包含完整R,G,B)基于视频编码标准
存储类型打包 (Packed)半平面 (Semi-Planar)打包 (Packed)码流格式 (Bitstream)
数据排列交错排列:Y U Y V先存全部Y,再存交错UV顺序排列:R G B 或 B G R包含NAL单元等复杂结构
每像素占用16 bits = 2 bytes12 bits = 1.5 bytes24 bits = 3 bytes高度可变,通常远小于原始格式
内存布局示例[Y0 U0 Y1 V0] [Y2 U1 Y3 V1][Y0 Y1 Y2 Y3 ...] [U0 V0 U1 V1 ...][R0 G0 B0] [R1 G1 B1] [R2 G2 B2] ...无法直接查看像素,需要解码器解析
优点图像质量较好,兼容性好压缩率高,是流媒体和硬件编解码的主流格式色彩表现直接、完整压缩率极高,适合网络传输和存储
缺点数据量较大垂直方向的色彩细节有损失数据量巨大编解码复杂,有算力开销

💡 YUYV 与 H.264 的本质区别

最后,澄清一个最核心的误解:YUYV 和 H.264 是完全不同层次的概念,它们是“原材料”与“压缩包”的关系。

  • YUYV:未压缩像素格式(原材料)。它可直接用来描述图像的亮度和色度。
  • H.264:视频编码标准(压缩算法/压缩包)。为了高效存储或通过网络传输,用H.264算法对“原材料”(如YUYV数据)进行复杂压缩,得到的数据流就是H.264格式。

整个过程通常是:CMOS传感器捕获光信号 → 经 ISP 处理并转换成 YUYV 等未压缩格式数据 → 送入编码器(如 H.264 编码器)进行压缩 → 输出 H.264 格式的码流。选择格式,本质是在“画质好、体积大”的未压缩数据和“体积小、但编解码有开销”的压缩数据间做权衡。

摄像头实际返回的数据格式与选择

V4L2 设备返回的是驱动当前协商出的格式:有些摄像头直接输出未压缩的 YUYV、YVYU、NV12 或 NV16;有些则由摄像头内部 ISP / 编码器先压缩,再输出 MJPEG、H.264 或 H.265 码流。应通过 VIDIOC_ENUM_FMT 和 VIDIOC_S_FMT 确认、设置实际格式,不能只凭摄像头型号推断。

返回格式性质典型特点与适用场景
YUYV / YVYU未压缩 YUV 4:2:2,打包存储延迟低、兼容性好,但带宽和存储开销较大;适合实时采集、图像处理。
NV12未压缩 YUV 4:2:0,半平面存储每像素约 1.5 字节,适合许多硬件编解码器;色度细节低于 4:2:2。
NV16未压缩 YUV 4:2:2,半平面存储每像素约 2 字节,色度信息多于 NV12,适合更重视色彩细节的处理链路。
MJPEG帧内压缩码流每帧都是独立 JPEG,体积适中、解码简单,随机取帧方便。
H.264 / H.265帧内和帧间压缩码流压缩率高,适合网络传输和长期存储;解码有算力需求,帧间编码也会带来缓存和延迟。
  • 追求低延迟、并且主机有足够 USB/内存带宽时,优先考虑 YUYV、NV12 等未压缩格式。
  • 希望在画质、带宽和实现复杂度间折中时,可选择 MJPEG。
  • 带宽受限,或用于网络传输、录像存储时,优先考虑 H.264/H.265;应用需使用相应解码器获取像素数据。

V4L2 设备的基本属性和功能支持情况

  • driver:驱动程序名称
  • card:设备卡名(通常是摄像头型号)
  • bus_info:总线信息(如 USB 位置)
  • version:驱动版本号
  • capabilities:设备整体能力标志(如是否支持视频捕获、流式 I/O 等)
  • device_caps:具体设备的能力标志(与 capabilities 类似,但更精确)
  • reserved:保留字段(通常为 0)
#include <sys/ioctl.h>
#include <linux/videodev2.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *dev = "/dev/video0";
int main(void)
{
    int fd;
    struct v4l2_capability cap;
    fd = open(dev, O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }
    if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) {
        perror("ioctl");
        return -1;
    }
    printf("driver: %s\n", cap.driver);
    printf("card: %s\n", cap.card);
    printf("bus_info: %s\n", cap.bus_info);
    printf("version: %d\n", cap.version);
    printf("capabilities: %x\n", cap.capabilities);
    printf("device_caps: %x\n", cap.device_caps);
    printf("reserved: %x %x %x\n", cap.reserved[0], cap.reserved[1],cap.reserved[2]);
    close(fd);
    return 0;
}

返回

driver: uvcvideo
card: HikCamera: UVC Camera
bus_info: usb-0000:02:03.0-1
version: 331719
capabilities: 84a00001
device_caps: 4200001
reserved: 0 0 0

描述了摄像头的图像采集能力——能输出哪些像素格式、每种格式下能达到哪些分辨率、当前配置是什么,以及如何试探和更改这些参数。

支持的像素格式(枚举)

  • 通过 VIDIOC_ENUM_FMT 列出所有可用的像素格式(如 YUYV、MJPEG、H264 等),并打印 FourCC 码和描述信息。
int main(void)
{
    int fd = open(dev, O_RDWR);
    if (fd < 0)
    {
        printf("open %s failed\n", dev);
        return -1;
    }
	struct v4l2_fmtdesc fmt;
    printf("\n====== 枚举支持的像素格式 ======\n");
	memset(&fmt, 0, sizeof(fmt)); // 用 memset 将所有字段清零,避免残留数据影响 ioctl。
	fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 设置 fmt.type 为 V4L2_BUF_TYPE_VIDEO_CAPTURE (摄像头设备)

	for (fmt.index = 0; ; fmt.index++) { 
		if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) < 0)
			break;
		printf("  [%d] 0x%08x  %s\n",
		       fmt.index,
		       fmt.pixelformat,
		       fmt.description);
	}
	printf("\n");
    close(fd);
    return 0;
}
  • 结果

    root@wyl:~/c-std/for-linux/099_other/v4l2# ./002_v4l2_fmt_type 
    ====== 枚举支持的像素格式 ======
      [0] 0x56595559  YUYV 4:2:2
    
#include <sys/ioctl.h>   /* ioctl() */
#include <fcntl.h>       /* open() */
#include <linux/videodev2.h> /* V4L2 相关结构体与 ioctl 宏 */
#include <unistd.h>      /* close() */
#include <stdio.h>       /* printf() */
#include <stdlib.h>      /* EXIT_FAILURE 等 */
#include <string.h>      /* memset() */

/* 默认摄像头设备节点,可通过修改此变量更换设备 */
static char *dev = "/dev/video0";

static void enum_framesizes(int fd, unsigned int pixelformat)
{
    struct v4l2_frmsizeenum frmsize;
    for (int i = 0; ; i++) {
        memset(&frmsize, 0, sizeof(frmsize));
        frmsize.pixel_format = pixelformat;
        frmsize.index = i;

        /* VIDIOC_ENUM_FRAMESIZES 根据 pixel_format + index 查询分辨率 */
        if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0)
            break;

        switch (frmsize.type) {
        case V4L2_FRMSIZE_TYPE_DISCRETE:
            /* 离散分辨率:每次返回一个具体的宽高值 */
            printf("          [%d] %dx%d\n", i,
                   frmsize.discrete.width,
                   frmsize.discrete.height);
            break;

        case V4L2_FRMSIZE_TYPE_STEPWISE:
            /*
             * 步进范围:min~max 范围内按 step 递增。
             * 例如 min_width=320, max_width=1920, step_width=160 表示
             * 支持 320, 480, 640, ..., 1920 这些宽度值。
             * 一次调用即得全部信息,return 退出函数,不再继续循环。
             */
            printf("          stepwise: %dx%d ~ %dx%d  step %dx%d\n",
                   frmsize.stepwise.min_width,  frmsize.stepwise.min_height,
                   frmsize.stepwise.max_width,  frmsize.stepwise.max_height,
                   frmsize.stepwise.step_width, frmsize.stepwise.step_height);
            return;

        case V4L2_FRMSIZE_TYPE_CONTINUOUS:
            /*
             * 连续范围:min~max 之间任意分辨率都支持。
             * 实际上驱动通常会限制一定的粒度,这里只是"名义上连续"。
             * 一次调用即得全部信息,return 退出函数。
             */
            printf("          continuous: %dx%d ~ %dx%d\n",
                   frmsize.stepwise.min_width,  frmsize.stepwise.min_height,
                   frmsize.stepwise.max_width,  frmsize.stepwise.max_height);
            return;
        }
    }
}

static void enum_format_with_sizes(int fd)
{
    struct v4l2_fmtdesc fmt;

    printf("====== 枚举像素格式及支持的分辨率 ======\n");

    /* memset 清零:避免结构体中的残留数据影响 ioctl 行为 */
    memset(&fmt, 0, sizeof(fmt));

    /* 设置缓冲区类型为视频捕获,其他类型还有 V4L2_BUF_TYPE_VIDEO_OUTPUT 等 */
    fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;

    for (fmt.index = 0; ; fmt.index++) {
        if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) < 0)
            break;

        /* fmt.pixelformat 是 FourCC 码(如 0x56595559 即 "YUYV") */
        printf("  [%d] 0x%08x  %s\n",
               fmt.index,
               fmt.pixelformat,
               fmt.description);

        /* 对每种格式查询其支持的分辨率 */
        enum_framesizes(fd, fmt.pixelformat);
    }
    printf("\n");
}

int main(void)
{
    int fd;

    /*
     * 打开 V4L2 设备节点。
     * O_RDWR:读写模式,既可用于查询(G_FMT/ENUM_FMT)也可用于设置(S_FMT)。
     * 部分设备可能只需要 O_RDONLY,但 O_RDWR 兼容性最好。
     */
    fd = open(dev, O_RDWR);
    if (fd < 0) {
        perror("open");  /* perror 会打印 "open: <系统错误信息>" */
        return -1;
    }

    /* 枚举所有像素格式及其支持的分辨率 */
    enum_format_with_sizes(fd);

    close(fd);
    return 0;
}

结果

root@wyl:~/c-std/for-linux/099_other/v4l2# ./002_v4l2_fmt_pix
====== 枚举像素格式及支持的分辨率 ======
  [0] 0x56595559  YUYV 4:2:2
       分辨率:
          [0] 96x200
          [1] 200x96
          [2] 8x8448
          [3] 8448x8
          [4] 4x2321
          [5] 96x100
          [6] 4x2637
          [7] 4x14733
          [8] 4x5188
          [9] 5188x4
          [10] 8x8642
          [11] 8642x8
          [12] 96x96
          [13] 240x240
#include <sys/ioctl.h>
#include <fcntl.h>
#include <linux/videodev2.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static char *dev = "/dev/video0";

static void enum_frameintervals(int fd, unsigned int fourcc, int w, int h)
{
    struct v4l2_frmivalenum frmi;

    for (int index = 0; ; index++) {
        memset(&frmi, 0, sizeof(frmi));
        frmi.index        = index;
        frmi.pixel_format = fourcc;
        frmi.width        = w;
        frmi.height       = h;

        if (ioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &frmi) < 0)
            break;

        switch (frmi.type) {
        case V4L2_FRMIVAL_TYPE_DISCRETE:
            /* 离散帧率:转换分数为浮点 fps */
            printf("              [%d] %.2f fps\n", index,
                   (float)frmi.discrete.denominator / frmi.discrete.numerator);
            break;
        case V4L2_FRMIVAL_TYPE_STEPWISE:
            /* 步进帧率:min ~ max fps,步进 step fps */
            printf("              [%d] %.2f ~ %.2f fps  step %.3f\n", index,
                   (float)frmi.stepwise.min.denominator / frmi.stepwise.min.numerator,
                   (float)frmi.stepwise.max.denominator / frmi.stepwise.max.numerator,
                   (float)frmi.stepwise.step.denominator / frmi.stepwise.step.numerator);
            return;
        case V4L2_FRMIVAL_TYPE_CONTINUOUS:
            /* 连续帧率:min ~ max fps */
            printf("              [%d] %.2f ~ %.2f fps  (continuous)\n", index,
                   (float)frmi.stepwise.min.denominator / frmi.stepwise.min.numerator,
                   (float)frmi.stepwise.max.denominator / frmi.stepwise.max.numerator);
            return;
        }
    }
}

/*
 * enum_framesizes - 枚举像素格式下的分辨率及对应帧率
 * @fd:        设备描述符
 * @fourcc:    像素格式 FourCC
 */
static void enum_framesizes(int fd, unsigned int fourcc)
{
    struct v4l2_frmsizeenum frmsize;

    /* 先获知帧尺寸类型(DISCRETE/STEPWISE/CONTINUOUS) */
    memset(&frmsize, 0, sizeof(frmsize));
    frmsize.pixel_format = fourcc;
    frmsize.index = 0;

    if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0)
        return;

    switch (frmsize.type) {
    case V4L2_FRMSIZE_TYPE_DISCRETE:
        /* 离散分辨率:递增 index 枚举每一项 */
        for (int index = 0; ; index++) {
            memset(&frmsize, 0, sizeof(frmsize));
            frmsize.pixel_format = fourcc;
            frmsize.index = index;
            if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0)
                break;
            printf("      [%d] %dx%d\n", index,
                   frmsize.discrete.width, frmsize.discrete.height);
            /* 进一步枚举该分辨率的帧率 */
            enum_frameintervals(fd, fourcc,
                                frmsize.discrete.width,
                                frmsize.discrete.height);
        }
        break;

    case V4L2_FRMSIZE_TYPE_STEPWISE:
        printf("      stepwise: %dx%d ~ %dx%d  step %dx%d\n",
               frmsize.stepwise.min_width,  frmsize.stepwise.min_height,
               frmsize.stepwise.max_width,  frmsize.stepwise.max_height,
               frmsize.stepwise.step_width, frmsize.stepwise.step_height);
        break;

    case V4L2_FRMSIZE_TYPE_CONTINUOUS:
        printf("      continuous: %dx%d ~ %dx%d\n",
               frmsize.stepwise.min_width,  frmsize.stepwise.min_height,
               frmsize.stepwise.max_width,  frmsize.stepwise.max_height);
        break;
    }
}

/*
 * enum_all - 三级枚举:格式 -> 分辨率 -> 帧率
 */
static void enum_all(int fd)
{
    struct v4l2_fmtdesc fmt;

    printf("====== 枚举像素格式/分辨率/帧率 ======\n");
    memset(&fmt, 0, sizeof(fmt));
    fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;

    for (fmt.index = 0; ; fmt.index++) {
        if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) < 0)
            break;
        printf("  [%d] 0x%08x  %s\n",
               fmt.index, fmt.pixelformat, fmt.description);
        enum_framesizes(fd, fmt.pixelformat);
    }
    printf("\n");
}

int main(void)
{
    int fd = open(dev, O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    enum_all(fd);

    close(fd);
    return 0;
}

结果

root@wyl:~/c-std/for-linux/099_other/v4l2# ./002_v4l2_fmt_fps
====== 枚举像素格式/分辨率/帧率 ======
  [0] 0x56595559  YUYV 4:2:2
      [0] 96x200
              [0] 25.00 fps
      [1] 200x96
              [0] 25.00 fps
      [2] 8x8448
              [0] 25.00 fps
      [3] 8448x8
              [0] 25.00 fps
      [4] 4x2321
              [0] 25.00 fps
      [5] 96x100
              [0] 25.00 fps
      [6] 4x2637
              [0] 25.00 fps
      [7] 4x14733
              [0] 25.00 fps
      [8] 4x5188
              [0] 25.00 fps
      [9] 5188x4
              [0] 25.00 fps
      [10] 8x8642
              [0] 25.00 fps
      [11] 8642x8
              [0] 25.00 fps
      [12] 96x96
              [0] 25.00 fps
      [13] 240x240
              [0] 25.00 fps

流程

  1. 配置
  2. 获取一帧
/* 初始化 V4L2:打开设备、设置 YUYV 格式、申请 mmap 缓冲区、开始流 */
int camera_init(const char *dev, int w, int h)
{
    struct v4l2_format fmt = {0};
    struct v4l2_requestbuffers req = {0};
    struct v4l2_buffer vbuf = {0};
    enum v4l2_buf_type type;

    cam_fd = open(dev, O_RDWR);
    if (cam_fd < 0) { perror("open"); return -1; }

    /* 协商 YUYV 格式 */
    fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    fmt.fmt.pix.width       = w;
    fmt.fmt.pix.height      = h;
    fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
    fmt.fmt.pix.field       = V4L2_FIELD_NONE;
    if (ioctl(cam_fd, VIDIOC_S_FMT, &fmt) < 0) {
        perror("VIDIOC_S_FMT"); close(cam_fd); return -1;
    }

    img_w = fmt.fmt.pix.width;
    img_h = fmt.fmt.pix.height;
    printf("camera: %dx%d YUYV\n", img_w, img_h);

    /* 申请 V4L2 循环缓冲区(内存由驱动管理) */
    req.count  = BUF_NUM;
    req.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    req.memory = V4L2_MEMORY_MMAP;
    if (ioctl(cam_fd, VIDIOC_REQBUFS, &req) < 0) {
        perror("VIDIOC_REQBUFS"); close(cam_fd); return -1;
    }

    for (int i = 0; i < BUF_NUM; i++) {
        memset(&vbuf, 0, sizeof(vbuf));
        vbuf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        vbuf.memory = V4L2_MEMORY_MMAP;
        vbuf.index  = i;
        if (ioctl(cam_fd, VIDIOC_QUERYBUF, &vbuf) < 0) {
            perror("VIDIOC_QUERYBUF"); close(cam_fd); return -1;
        }
        buffers[i].length = vbuf.length;
        buffers[i].start  = mmap(NULL, vbuf.length, PROT_READ | PROT_WRITE,
                                  MAP_SHARED, cam_fd, vbuf.m.offset);
        if (buffers[i].start == MAP_FAILED) {
            perror("mmap"); close(cam_fd); return -1;
        }
    }

    /* 所有缓冲入队,准备采集 */
    for (int i = 0; i < BUF_NUM; i++) {
        memset(&vbuf, 0, sizeof(vbuf));
        vbuf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        vbuf.memory = V4L2_MEMORY_MMAP;
        vbuf.index  = i;
        ioctl(cam_fd, VIDIOC_QBUF, &vbuf);
    }

    type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    if (ioctl(cam_fd, VIDIOC_STREAMON, &type) < 0) {
        perror("VIDIOC_STREAMON"); close(cam_fd); return -1;
    }

    return 0;
}
/*
 * 捕获一帧:DQBUF 取一帧 → 直接转换到 rgb_buf → QBUF 归还
 * 不经过中间缓冲,mmap 数据直转 ARGB8888
 */
int camera_capture_frame(uint8_t *rgb_buf, int *size)
{
    struct v4l2_buffer vbuf = {0};
    fd_set fds;
    struct timeval tv;

    FD_ZERO(&fds);
    FD_SET(cam_fd, &fds);
    tv.tv_sec  = 0;
    tv.tv_usec = 200000;

    int ret = select(cam_fd + 1, &fds, NULL, NULL, &tv);
    if (ret <= 0) return -1;

    vbuf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    vbuf.memory = V4L2_MEMORY_MMAP;
    if (ioctl(cam_fd, VIDIOC_DQBUF, &vbuf) < 0) return -1;

    /* 从 mmap 缓冲直接转换到输出缓冲,节省一次 memcpy */
    yuyv_to_argb8888(buffers[vbuf.index].start, rgb_buf, img_w, img_h);
    ioctl(cam_fd, VIDIOC_QBUF, &vbuf);

    if (size) *size = img_w * img_h * 4;
    return 0;
}
/*
 * YUYV → ARGB8888 转换(BT.601 近似)
 * 输入 4 字节 = 2 像素 (Y0 U Y1 V)
 * 输出每像素 4 字节 (B G R A)
 */
static void yuyv_to_argb8888(const uint8_t *src, uint8_t *dst, int w, int h)
{
    for (int i = 0; i < w * h / 2; i++) {
        int y0 = src[0], u = src[1] - 128, y1 = src[2], v = src[3] - 128;
        src += 4;

        int r = (298 * y0 + 409 * v + 128) >> 8;
        int g = (298 * y0 - 100 * u - 208 * v + 128) >> 8;
        int b = (298 * y0 + 516 * u + 128) >> 8;
        dst[0] = (uint8_t)(b < 0 ? 0 : (b > 255 ? 255 : b));
        dst[1] = (uint8_t)(g < 0 ? 0 : (g > 255 ? 255 : g));
        dst[2] = (uint8_t)(r < 0 ? 0 : (r > 255 ? 255 : r));
        dst[3] = 0xFF;
        dst += 4;

        r = (298 * y1 + 409 * v + 128) >> 8;
        g = (298 * y1 - 100 * u - 208 * v + 128) >> 8;
        b = (298 * y1 + 516 * u + 128) >> 8;
        dst[0] = (uint8_t)(b < 0 ? 0 : (b > 255 ? 255 : b));
        dst[1] = (uint8_t)(g < 0 ? 0 : (g > 255 ? 255 : g));
        dst[2] = (uint8_t)(r < 0 ? 0 : (r > 255 ? 255 : r));
        dst[3] = 0xFF;
        dst += 4;
    }
}
  • 打开设备
int fd;

fd = open("/dev/video0",O_RDWR | O_NONBLOCK)
if(fd == -1){
	perror("open 摄像头失败");
	return -1;
}
  • 设备能力查询
#include <string> //memset
#include <sys/ioctl.h> // ioctl
#include <linux/videodev2.h> //v4l2宏

struct v4l2_capability cap; //能力信息
memset(&cap, 0, sizeof(cap)); //分配内存
if(ioctl(fd, VIDIOC_QUERYCAP, &cap) == -1)
{
	perror("VIDIOC_QUERYCAP 失败");
}

安装

在 Debian / Ubuntu 系统中安装 v4l2-ctl(属于 v4l-utils 软件包):

apt install v4l-utils

查看设备列表

v4l2-ctl --list-devices

输出会列出每个设备及其关联的 /dev/videoN、/dev/mediaN 节点:

HikCamera: UVC Camera (usb-0000:02:03.0-1):
        /dev/video0
        /dev/video1
        /dev/media0

查看设备详细信息

v4l2-ctl -d /dev/video0 --all

示例输出:

Driver Info:
        Driver name      : uvcvideo
        Card type        : HikCamera: UVC Camera
        Bus info         : usb-0000:02:03.0-1
        Driver version   : 5.15.199
...

查看支持的格式、分辨率和帧率

v4l2-ctl -d /dev/video0 --list-formats-ext

示例输出:

ioctl: VIDIOC_ENUM_FMT
        Type: Video Capture

        [0]: 'YUYV' (YUYV 4:2:2)
                Size: Discrete 256x392
                        Interval: Discrete 0.040s (25.000 fps)
                ...
        [1]: 'MJPG' (Motion-JPEG, compressed)
                Size: Discrete 120x160
                        Interval: Discrete 0.040s (25.000 fps)
                ...
        [2]: 'H264' (H.264, compressed)
                Size: Discrete 240x320
                        Interval: Discrete 0.033s (30.000 fps)

设置采集格式

设置 800×600 的 UYVY 格式:

v4l2-ctl -d /dev/video0 --set-fmt-video=width=800,height=600,pixelformat=UYVY

设置前应先通过 --list-formats-ext 确认该设备支持对应的像素格式和分辨率;驱动可能会将不支持的请求调整为最接近的有效值。

获取一帧数据

采集一帧 YUYV 原始数据:

v4l2-ctl -d /dev/video0 --set-fmt-video=width=256,height=392,pixelformat=YUYV --stream-mmap --stream-count=1 --stream-to=frame.raw

采集一帧 MJPEG 数据:

v4l2-ctl -d /dev/video0 --set-fmt-video=width=256,height=392,pixelformat=MJPG --stream-mmap --stream-count=1 --stream-to=frame.jpg

--stream-mmap 表示使用 mmap 方式采集;--stream-count=1 限制采集一帧;--stream-to 指定输出文件。YUYV 输出是裸像素数据,需要按分辨率和像素格式解释;MJPEG 输出通常可直接作为 JPEG 图片打开。

概述

V4L2(Video for Linux 2)是 Linux 内核中视频设备驱动的标准接口规范,广泛用于摄像头、电视调谐器、视频采集卡等设备。

使用 V4L2 通常需要:

  • 编译内核时开启相应的视频设备和驱动支持。
  • 在设备树中描述硬件连接及相关参数。
  • 在根文件系统中提供用户空间工具和应用程序。

常见设备节点

摄像头设备经常会同时暴露多个节点,例如:

HikCamera: UVC Camera (usb-0000:02:03.0-1):
        /dev/video0
        /dev/video1
        /dev/media0

/dev/videoN

这是 V4L2 视频设备节点,应用程序通过它协商格式、申请缓冲区并采集视频帧。

  • video0 通常是主视频流,例如输出 MJPEG 或 YUYV 的实时预览画面。
  • video1 可能是元数据通道、静态抓拍通道,或同一物理摄像头提供的另一条视频流(例如不同分辨率)。
  • 节点编号和实际功能由驱动决定,不能仅凭编号判断;应查询节点支持的格式和能力。

/dev/mediaN

这是 Media Controller 设备节点,用于管理复杂摄像头管线,而非直接传输视频数据。

当设备由传感器、ISP、缩放器等多个模块组成时,用户空间可通过 media-ctl 查看、配置模块及其连接关系(pipeline)。配置完成后,通常仍通过对应的 /dev/videoN 节点采集数据。

某些 UVC 摄像头也会暴露 Media Controller 节点,具体取决于驱动和设备实现。

摄像头的帧数据是怎么"进内存"的:硬件 DMA(不经过 CPU)

gc2145 传感器(MIPI 接口)
   │  像素数据流(PCLK 时钟驱动的信号)
   ▼
rkcif 控制器(SoC 里的 MIPI 接收器)
   │  ← 内核驱动提前告诉它:"把数据写到内存地址 0xXXXXXX,写 960KB"
   ▼
内存(内核分配的物理内存)gc2145 传感器(MIPI 接口)
  • 从传感器到内存,全程没有 CPU 参与。

  • 传感器把像素信号发给 rkcif 硬件,rkcif 硬件按驱动配置的地址,自己把数据写到内存里——这叫 DMA(Direct Memory Access,直接内存访问)。

队列

  • 帧队列,有4个帧在队列中,REQBUFS(4)。

  • 内核驱动分配了4块内存。DMA填充一块,我们处理一块,等待入队一块,空闲一块。

  • 填充帧需要时间,程序处理帧需要时间,如果只有1帧,那么填充完,然后程序处理,帧率直接减半。

  • DQBUF把队列里最新一帧的"所有权"转给你——内核在 vb.index 里告诉你"第 2 块缓冲填好了,归你了。数据原地没动(还在那块物理内存里),只是"现在轮到你看它了"。你通过 mmap 映射的虚拟地址(bufs[2].start)可以直接读它。

  • select,select(fd) 是"等货架上有货"的通知——内核有帧入队完成时会让 fd 可读,select 返回。没有 select 直接 DQBUF 也行,但会阻塞等;select 给你"有货再取"的效率。

VFS虚拟文件系统

SPI NAND
├── env            原始分区,不挂载
├── idblock        原始启动分区,不挂载
├── uboot          U-Boot镜像,不挂载
├── boot           Kernel/DTB/resource,不挂载
├── rootfs         UBIFS,挂载为 /
│   ├── bin
│   ├── etc
│   ├── usr
│   │   ├── bin
│   │   └── lib
│   ├── oem         挂载点
│   └── userdata    挂载点
├── oem            UBIFS,挂载到 /oem
├── userdata       UBIFS,挂载到 /userdata
└── logo           原始BMP分区,不挂载

Linux只有一棵目录树。rootfs首先挂载为根目录 /,然后 oem、userdata 再挂载到这棵目录树中的 /oem、/userdata。

/usr,/usr/bin,/usr/lib,/etc,/bin 全部属于 rootfs。

未挂载时,/oem、/userdata 仍然只是 rootfs 里的普通目录,写进去的数据会实际消耗并修改 rootfs;以后分区挂载成功,这些文件又会被“遮住”。

信号

触发流程

  • 进入系统调用,用户态变成内核态
  • 是对应触发信号

内核态在返回用户态时,看到有信号标记,然后修改栈和指令寄存器让cpu去执行对应函数。

sleep(1)这是一个系统调用,进入内核后休眠。Ctrl+C 按下时:

1. 内核标记信号 pending
2. sleep 因为信号被中断,返回 EINTR
3. 内核在返回用户态之前,看到有 pending signal
4. 内核在用户栈上压入一个伪造的返回地址(指回原代码)
5. 修改栈/指令寄存器,让 CPU 执行 on_signal
6. on_signal 返回时,内核帮你 sigreturn,恢复原来的上下文
7. 代码继续跑 while(running) 检查条件

1

#include <stdio.h>
#include <signal.h>
#include <sys/time.h>
static volatile int running = 1; // static 文件作用域,volatile 编译器不会对它做任何优化
static void on_sig(int s)
{
    (void)s;
    printf("触发系统信号,信号类型为SIGINT或SIGTERM");
    running = 0;
}

int main(void)
{
    signal(SIGINT, on_sig); //绑定信号
    signal(SIGTERM, on_sig);//绑定信号
    while(running){
        printf("运行中-----");
        usleep(5000);
    }
}

SIGINT(终端 Ctrl+C)和 SIGTERM(kill 命令)捕获后将 running 置 0,主循环退出。

  • SIGUSR1 和 SIGUSR2 这是两个预留的用户自定义信号,编号通常为 10 和 12(不同体系可能略有差异),你可以像处理 SIGINT 一样为它们注册处理函数。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

static void my_handler(int sig) {
    printf("捕获到信号 %d\n", sig);
}

int main() {
    // 注册 SIGUSR1
    signal(SIGUSR1, my_handler);
    // 注册 SIGUSR2
    signal(SIGUSR2, my_handler);

    while (1) {
        printf("等待信号...\n");
        sleep(1);
    }
    return 0;
}

触发方式

kill(pid, SIGUSR1);   // 向 pid 进程发送 SIGUSR1
raise(SIGUSR1);			//向当前进程发送

#或者
kill -USR1 <进程ID> #向SIGUSR1发送信号
kill -10 <进程ID>   # 部分系统 SIGUSR1 编号为 10
  • 实时信号(Realtime Signals) 范围从 SIGRTMIN 到 SIGRTMAX(通常是 34~64),数量较多,支持队列化和按优先级递送,适合更复杂的自定义场景。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void rt_handler(int sig, siginfo_t *info, void *ctx) {
    (void)ctx;
    printf("收到实时信号 %d,携带数值: %d\n", sig, info->si_value.sival_int);
}

int main() {
    struct sigaction act;
    act.sa_sigaction = rt_handler;
    act.sa_flags = SA_SIGINFO;
    sigemptyset(&act.sa_mask);
    sigaction(SIGRTMIN, &act, NULL);

    union sigval val;
    // 连续发送 5 个信号,附带不同数值
    for (int i = 1; i <= 5; i++) {
        val.sival_int = i * 10;
        sigqueue(getpid(), SIGRTMIN, val);
    }

    sleep(3); // 等待异步处理完成
    return 0;
}

输出会依次打印 10, 20, 30, 40, 50(保证顺序,不会丢失)。

内核源码添加驱动

新增panel-lh24030c50.c文件

配置makefile

  • 路径:/kernel/drivers/gpu/drm/panel/Makefile

  • 新增一行

    obj-$(CONFIG_DRM_PANEL_LH24030C50) += panel-lh24030c50.o
    
    • 当内核配置系统检测到 CONFIG_DRM_PANEL_LH24030C50 被赋值为 y(内建)或 m(模块)时,该行会生效。
    • 将 .c 文件编译为对应的 .o 目标文件,并最终链接进内核镜像(y)或生成独立的 .ko 内核模块文件(m)。

配置Kconfig

  • 路径:/kernel/drivers/gpu/drm/panel/Kconfig

  • 新增

    config DRM_PANEL_LH24030C50
    	tristate "LH24030C50 RGB panel"
    	depends on OF && SPI
    	depends on BACKLIGHT_CLASS_DEVICE
    	help
    	  Say Y here if you want to enable support for the LH24030C50
    	  RGB panel module driven by an ST7789V-compatible controller.
    
    • tristate:表示该选项支持三种状态——Y(内建)、M(模块)、N(不编译)。对应Makefile中的 obj-*。
    • depends on OF && SPI:依赖约束。只有启用了设备树(OF)和SPI总线支持时,该选项才会出现在菜单中。这防止了非硬件平台误选,也保证了编译时能引用到SPI子系统的头文件和符号。
    • depends on BACKLIGHT_CLASS_DEVICE:强制依赖背光类设备。因为面板需要背光调节功能,若内核未开启背光支持,该驱动编译会因找不到 struct backlight_device 等定义而失败。
    • help:给开发者或用户看的说明文本,描述该驱动适用的硬件型号。

内核配置

开启内核配置

kernel_defconfig

CONFIG_DRM_PANEL_LH24030C50=y #与上面makefile中的 CONFIG_DRM_PANEL_LH24030C50 一致
CONFIG_BACKLIGHT_CLASS_DEVICE=y
CONFIG_BACKLIGHT_GPIO=y

关闭内核配置

#CONFIG_FB_TFT=y
#CONFIG_DRM_PANEL_SITRONIX_ST7789V=y
#CONFIG_FB_TFT_ST7735R=y
#CONFIG_FB_TFT_ST7789V=y

也许要修改

一些fragment

rv1106-evb.config

CONFIG_BACKLIGHT_GPIO=y

频率过高

rv1106g-luckfox-pico-pro-max.dts

/**********CRU**********/
&cru {
	assigned-clocks = <&cru 3>;
	assigned-clock-rates = <216000000>;
};

aa_rv1106-lcd.dtsi

&vop {
	assigned-clocks = <&cru 201>;
	assigned-clock-parents = <&cru 3>;
	status = "okay";
};
timing0: panel-timing {
	clock-frequency = <7000000>;
	hactive = <240>;
	vactive = <320>;
	hfront-porch = <1>;
	hback-porch = <20>;
	hsync-len = <10>;
	vfront-porch = <8>;
	vback-porch = <2>;
	vsync-len = <6>;
	hsync-active = <0>;
	vsync-active = <0>;
	de-active = <1>;
	pixelclk-active = <0>;
};

rv1106-luckfox-pico-pro-max-ipc.dtsi

注释
/*****************************PINCTRL********************************/
// SPI
// &spi0 {
// 	pinctrl-0 = <&spi0m0_clk &spi0m0_miso &spi0m0_mosi &spi0m0_cs0>;
// 	#address-cells = <1>;
// 	#size-cells = <0>;
// 	spidev@0 {
// 	compatible = "rockchip,spidev";
// 		spi-max-frequency = <50000000>;
// 		reg = <0>;
// 	};

// 	fbtft@0 {
// 		compatible = "sitronix,st7789v";
// 		reg = <0>;
// 		spi-max-frequency = <20000000>;
// 		fps = <30>;
// 		buswidth = <8>;
// 		debug = <0x7>;
// 		led-gpios = <&gpio2 RK_PB0 GPIO_ACTIVE_HIGH>;//BL
// 		dc-gpios = <&gpio2 RK_PB1 GPIO_ACTIVE_HIGH>;//DC
// 		reset-gpios = <&gpio1 RK_PC3 GPIO_ACTIVE_LOW>;//RES
// 	};
// };
// SPDX-License-Identifier: GPL-2.0-only
/*
 * LH24030C50 RGB panel driver based on the ST7789V controller.
 */

#include <linux/delay.h>
#include <linux/media-bus-format.h>
#include <linux/of.h>
#include <video/display_timing.h>
#include <video/videomode.h>
#include <video/of_videomode.h>
#include <linux/gpio/consumer.h>
#include <linux/module.h>
#include <linux/regulator/consumer.h>
#include <linux/spi/spi.h>

#include <video/mipi_display.h>

#include <drm/drm_device.h>
#include <drm/drm_modes.h>
#include <drm/drm_panel.h>

#define ST7789V_COLMOD_RGB_FMT_18BITS		(6 << 4)
#define ST7789V_COLMOD_CTRL_FMT_18BITS		(6 << 0)

#define ST7789V_RAMCTRL_CMD		0xb0
#define ST7789V_RAMCTRL_RM_RGB			BIT(4)
#define ST7789V_RAMCTRL_DM_RGB			BIT(0)
#define ST7789V_RAMCTRL_MAGIC			(3 << 6)
#define ST7789V_RAMCTRL_EPF(n)			(((n) & 3) << 4)

#define ST7789V_RGBCTRL_CMD		0xb1
#define ST7789V_RGBCTRL_WO			BIT(7)
#define ST7789V_RGBCTRL_RCM(n)			(((n) & 3) << 5)
#define ST7789V_RGBCTRL_VSYNC_HIGH		BIT(3)
#define ST7789V_RGBCTRL_HSYNC_HIGH		BIT(2)
#define ST7789V_RGBCTRL_PCLK_HIGH		BIT(1)
#define ST7789V_RGBCTRL_DE_LOW			BIT(0)
#define ST7789V_RGBCTRL_VBP(n)			((n) & 0x7f)
#define ST7789V_RGBCTRL_HBP(n)			((n) & 0x1f)

#define ST7789V_PORCTRL_CMD		0xb2
#define ST7789V_PORCTRL_IDLE_BP(n)		(((n) & 0xf) << 4)
#define ST7789V_PORCTRL_IDLE_FP(n)		((n) & 0xf)
#define ST7789V_PORCTRL_PARTIAL_BP(n)		(((n) & 0xf) << 4)
#define ST7789V_PORCTRL_PARTIAL_FP(n)		((n) & 0xf)

#define ST7789V_GCTRL_CMD		0xb7
#define ST7789V_GCTRL_VGHS(n)			(((n) & 7) << 4)
#define ST7789V_GCTRL_VGLS(n)			((n) & 7)

#define ST7789V_VCOMS_CMD		0xbb

#define ST7789V_LCMCTRL_CMD		0xc0
#define ST7789V_LCMCTRL_XBGR			BIT(5)
#define ST7789V_LCMCTRL_XMX			BIT(3)
#define ST7789V_LCMCTRL_XMH			BIT(2)

#define ST7789V_VDVVRHEN_CMD		0xc2
#define ST7789V_VDVVRHEN_CMDEN			BIT(0)

#define ST7789V_VRHS_CMD		0xc3

#define ST7789V_VDVS_CMD		0xc4

#define ST7789V_FRCTRL2_CMD		0xc6

#define ST7789V_PWCTRL1_CMD		0xd0
#define ST7789V_PWCTRL1_MAGIC			0xa4
#define ST7789V_PWCTRL1_AVDD(n)			(((n) & 3) << 6)
#define ST7789V_PWCTRL1_AVCL(n)			(((n) & 3) << 4)
#define ST7789V_PWCTRL1_VDS(n)			((n) & 3)

#define ST7789V_PVGAMCTRL_CMD		0xe0
#define ST7789V_PVGAMCTRL_JP0(n)		(((n) & 3) << 4)
#define ST7789V_PVGAMCTRL_JP1(n)		(((n) & 3) << 4)
#define ST7789V_PVGAMCTRL_VP0(n)		((n) & 0xf)
#define ST7789V_PVGAMCTRL_VP1(n)		((n) & 0x3f)
#define ST7789V_PVGAMCTRL_VP2(n)		((n) & 0x3f)
#define ST7789V_PVGAMCTRL_VP4(n)		((n) & 0x1f)
#define ST7789V_PVGAMCTRL_VP6(n)		((n) & 0x1f)
#define ST7789V_PVGAMCTRL_VP13(n)		((n) & 0xf)
#define ST7789V_PVGAMCTRL_VP20(n)		((n) & 0x7f)
#define ST7789V_PVGAMCTRL_VP27(n)		((n) & 7)
#define ST7789V_PVGAMCTRL_VP36(n)		(((n) & 7) << 4)
#define ST7789V_PVGAMCTRL_VP43(n)		((n) & 0x7f)
#define ST7789V_PVGAMCTRL_VP50(n)		((n) & 0xf)
#define ST7789V_PVGAMCTRL_VP57(n)		((n) & 0x1f)
#define ST7789V_PVGAMCTRL_VP59(n)		((n) & 0x1f)
#define ST7789V_PVGAMCTRL_VP61(n)		((n) & 0x3f)
#define ST7789V_PVGAMCTRL_VP62(n)		((n) & 0x3f)
#define ST7789V_PVGAMCTRL_VP63(n)		(((n) & 0xf) << 4)

#define ST7789V_NVGAMCTRL_CMD		0xe1
#define ST7789V_NVGAMCTRL_JN0(n)		(((n) & 3) << 4)
#define ST7789V_NVGAMCTRL_JN1(n)		(((n) & 3) << 4)
#define ST7789V_NVGAMCTRL_VN0(n)		((n) & 0xf)
#define ST7789V_NVGAMCTRL_VN1(n)		((n) & 0x3f)
#define ST7789V_NVGAMCTRL_VN2(n)		((n) & 0x3f)
#define ST7789V_NVGAMCTRL_VN4(n)		((n) & 0x1f)
#define ST7789V_NVGAMCTRL_VN6(n)		((n) & 0x1f)
#define ST7789V_NVGAMCTRL_VN13(n)		((n) & 0xf)
#define ST7789V_NVGAMCTRL_VN20(n)		((n) & 0x7f)
#define ST7789V_NVGAMCTRL_VN27(n)		((n) & 7)
#define ST7789V_NVGAMCTRL_VN36(n)		(((n) & 7) << 4)
#define ST7789V_NVGAMCTRL_VN43(n)		((n) & 0x7f)
#define ST7789V_NVGAMCTRL_VN50(n)		((n) & 0xf)
#define ST7789V_NVGAMCTRL_VN57(n)		((n) & 0x1f)
#define ST7789V_NVGAMCTRL_VN59(n)		((n) & 0x1f)
#define ST7789V_NVGAMCTRL_VN61(n)		((n) & 0x3f)
#define ST7789V_NVGAMCTRL_VN62(n)		((n) & 0x3f)
#define ST7789V_NVGAMCTRL_VN63(n)		(((n) & 0xf) << 4)

#define ST7789V_TEST(val, func)			\
	do {					\
		if ((val = (func)))		\
			return val;		\
	} while (0)

struct st7789v {
	struct drm_panel panel;
	struct spi_device *spi;
	struct gpio_desc *reset;
	struct gpio_desc *sclk;
	struct gpio_desc *mosi;
	struct gpio_desc *cs;
	struct regulator *power;
};

enum st7789v_prefix {
	ST7789V_COMMAND = 0,
	ST7789V_DATA = 1,
};

static inline struct st7789v *panel_to_st7789v(struct drm_panel *panel)
{
	return container_of(panel, struct st7789v, panel);
}

static void st7789v_get_rgbctrl_from_dt(struct device *dev, u8 *ctrl,
					u8 *vbp, u8 *hbp)
{
	struct videomode vm;

	*ctrl = ST7789V_RGBCTRL_RCM(2);
	*vbp = 4;
	*hbp = 10;

	if (of_get_videomode(dev->of_node, &vm, 0))
		return;

	if (vm.flags & DISPLAY_FLAGS_VSYNC_HIGH)
		*ctrl |= ST7789V_RGBCTRL_VSYNC_HIGH;
	if (vm.flags & DISPLAY_FLAGS_HSYNC_HIGH)
		*ctrl |= ST7789V_RGBCTRL_HSYNC_HIGH;
	if (vm.flags & DISPLAY_FLAGS_DE_LOW)
		*ctrl |= ST7789V_RGBCTRL_DE_LOW;
	if (vm.flags & DISPLAY_FLAGS_PIXDATA_NEGEDGE)
		*ctrl |= ST7789V_RGBCTRL_PCLK_HIGH;

	*vbp = vm.vback_porch;
	*hbp = vm.hback_porch;
}

static void st7789v_get_active_area_from_dt(struct device *dev, u16 *width,
					    u16 *height)
{
	struct videomode vm;

	*width = 240;
	*height = 320;

	if (of_get_videomode(dev->of_node, &vm, 0))
		return;

	*width = vm.hactive;
	*height = vm.vactive;
}

static u32 st7789v_get_bus_flags_from_dt(struct device *dev)
{
	struct videomode vm;
	u32 bus_flags = DRM_BUS_FLAG_DE_HIGH |
			DRM_BUS_FLAG_PIXDATA_DRIVE_NEGEDGE;

	if (of_get_videomode(dev->of_node, &vm, 0))
		return bus_flags;

	bus_flags = 0;

	if (vm.flags & DISPLAY_FLAGS_DE_LOW)
		bus_flags |= DRM_BUS_FLAG_DE_LOW;
	else
		bus_flags |= DRM_BUS_FLAG_DE_HIGH;

	if (vm.flags & DISPLAY_FLAGS_PIXDATA_NEGEDGE)
		bus_flags |= DRM_BUS_FLAG_PIXDATA_SAMPLE_NEGEDGE;
	else
		bus_flags |= DRM_BUS_FLAG_PIXDATA_SAMPLE_POSEDGE;

	return bus_flags;
}

static void st7789v_spi_bitbang(struct st7789v *ctx, int dc, u8 data)
{
	int i;

	gpiod_set_value_cansleep(ctx->cs, 0);
	udelay(1);
	gpiod_set_value_cansleep(ctx->sclk, 0);
	gpiod_set_value_cansleep(ctx->mosi, dc ? 1 : 0);
	udelay(2);
	gpiod_set_value_cansleep(ctx->sclk, 1);
	udelay(2);
	for (i = 0; i < 8; i++) {
		gpiod_set_value_cansleep(ctx->sclk, 0);
		gpiod_set_value_cansleep(ctx->mosi, (data >> (7 - i)) & 1);
		udelay(2);
		gpiod_set_value_cansleep(ctx->sclk, 1);
		udelay(2);
	}
	gpiod_set_value_cansleep(ctx->cs, 1);
	udelay(1);
}

static int st7789v_write_command(struct st7789v *ctx, u8 cmd)
{
	st7789v_spi_bitbang(ctx, 0, cmd);
	return 0;
}

static int st7789v_write_data(struct st7789v *ctx, u8 data)
{
	st7789v_spi_bitbang(ctx, 1, data);
	return 0;
}

static const struct drm_display_mode default_mode = {
	.clock = 7000,
	.hdisplay = 240,
	.hsync_start = 240 + 10,
	.hsync_end = 240 + 10 + 10,
	.htotal = 240 + 10 + 10 + 38,
	.vdisplay = 320,
	.vsync_start = 320 + 4,
	.vsync_end = 320 + 4 + 4,
	.vtotal = 320 + 4 + 4 + 8,
	.flags = DRM_MODE_FLAG_NHSYNC | DRM_MODE_FLAG_NVSYNC,
};

static int st7789v_get_modes(struct drm_panel *panel,
			     struct drm_connector *connector)
{
	struct drm_display_mode *mode;
	const u32 bus_format = MEDIA_BUS_FMT_RGB666_1X18;
	const struct drm_display_mode *src_mode = &default_mode;
	struct videomode vm;

	if (!of_get_videomode(panel->dev->of_node, &vm, 0)) {
		struct drm_display_mode *dt_mode;

		dev_info(panel->dev,
			 "dt mode hact=%u vact=%u hfp=%u hbp=%u hsync=%u vfp=%u vbp=%u vsync=%u flags=0x%x\n",
			 vm.hactive, vm.vactive,
			 vm.hfront_porch, vm.hback_porch, vm.hsync_len,
			 vm.vfront_porch, vm.vback_porch, vm.vsync_len,
			 vm.flags);

		dt_mode = drm_mode_create(connector->dev);
		if (dt_mode) {
			drm_display_mode_from_videomode(&vm, dt_mode);
			dt_mode->type = DRM_MODE_TYPE_DRIVER | DRM_MODE_TYPE_PREFERRED;
			src_mode = dt_mode;
		}
	} else {
		dev_info(panel->dev,
			 "using default mode hdisplay=%u vdisplay=%u hsync_start=%u hsync_end=%u htotal=%u vsync_start=%u vsync_end=%u vtotal=%u\n",
			 src_mode->hdisplay, src_mode->vdisplay,
			 src_mode->hsync_start, src_mode->hsync_end,
			 src_mode->htotal, src_mode->vsync_start,
			 src_mode->vsync_end, src_mode->vtotal);
	}

	mode = drm_mode_duplicate(connector->dev, src_mode);
	if (!mode) {
		dev_err(panel->dev, "failed to add mode %ux%ux@%u\n",
			src_mode->hdisplay, src_mode->vdisplay,
			drm_mode_vrefresh(src_mode));
		return -ENOMEM;
	}

	drm_mode_set_name(mode);

	mode->type = DRM_MODE_TYPE_DRIVER | DRM_MODE_TYPE_PREFERRED;
	drm_mode_probed_add(connector, mode);

	connector->display_info.width_mm = 43;
	connector->display_info.height_mm = 57;
	drm_display_info_set_bus_formats(&connector->display_info,
					  &bus_format, 1);
	connector->display_info.bus_flags =
		st7789v_get_bus_flags_from_dt(panel->dev);
	dev_info(panel->dev, "connector bus_flags=0x%x bus_format=0x%x\n",
		 connector->display_info.bus_flags, bus_format);

	return 1;
}

static int st7789v_prepare(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	u16 width, height;
	u8 rgbctrl, vbp, hbp;
	int ret;

	ret = regulator_enable(ctx->power);
	if (ret)
		return ret;

	gpiod_set_value(ctx->reset, 1);
	msleep(10);
	gpiod_set_value(ctx->reset, 0);
	msleep(150);

	ST7789V_TEST(ret, st7789v_write_command(ctx, MIPI_DCS_EXIT_SLEEP_MODE));

	/* We need to wait 120ms after a sleep out command */
	msleep(120);

	ST7789V_TEST(ret, st7789v_write_command(ctx,
						MIPI_DCS_SET_ADDRESS_MODE));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));

	ST7789V_TEST(ret, st7789v_write_command(ctx,
						MIPI_DCS_SET_PIXEL_FORMAT));
	ST7789V_TEST(ret, st7789v_write_data(ctx, MIPI_DCS_PIXEL_FMT_18BIT));

	st7789v_get_active_area_from_dt(panel->dev, &width, &height);
	dev_info(panel->dev, "active area width=%u height=%u\n", width, height);

	ST7789V_TEST(ret, st7789v_write_command(ctx,
						MIPI_DCS_SET_COLUMN_ADDRESS));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, (width - 1) >> 8));
	ST7789V_TEST(ret, st7789v_write_data(ctx, (width - 1) & 0xff));

	ST7789V_TEST(ret, st7789v_write_command(ctx,
						MIPI_DCS_SET_PAGE_ADDRESS));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, (height - 1) >> 8));
	ST7789V_TEST(ret, st7789v_write_data(ctx, (height - 1) & 0xff));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_PORCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xc));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xc));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x33));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x33));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_GCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x35));

	ST7789V_TEST(ret, st7789v_write_command(ctx, 0xb6));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x20));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_VCOMS_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x2b));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_LCMCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x2c));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_VDVVRHEN_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x01));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_VRHS_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x11));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_VDVS_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x20));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_FRCTRL2_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xf));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_PWCTRL1_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xa4));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xa1));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_PVGAMCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xd0));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x06));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x09));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x0b));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x2a));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x3c));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x55));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x4b));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x08));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x16));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x14));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x19));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x20));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_NVGAMCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0xd0));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x06));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x09));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x0b));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x29));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x36));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x54));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x4b));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x0d));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x16));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x14));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x21));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x20));

	ST7789V_TEST(ret, st7789v_write_command(ctx, MIPI_DCS_ENTER_INVERT_MODE));

	st7789v_get_rgbctrl_from_dt(panel->dev, &rgbctrl, &vbp, &hbp);
	dev_info(panel->dev,
		 "init regs madctl=0x%02x colmod=0x%02x ramctrl=0x%02x,0x%02x rgbctrl=0x%02x,0x%02x,0x%02x\n",
		 0x00, MIPI_DCS_PIXEL_FMT_18BIT, 0x11, 0x00,
		 rgbctrl, vbp, hbp);

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_RAMCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x11));
	ST7789V_TEST(ret, st7789v_write_data(ctx, 0x00));

	ST7789V_TEST(ret, st7789v_write_command(ctx, ST7789V_RGBCTRL_CMD));
	ST7789V_TEST(ret, st7789v_write_data(ctx, rgbctrl));
	ST7789V_TEST(ret, st7789v_write_data(ctx, ST7789V_RGBCTRL_VBP(vbp)));
	ST7789V_TEST(ret, st7789v_write_data(ctx, ST7789V_RGBCTRL_HBP(hbp)));

	return 0;
}

static int st7789v_enable(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	int ret;

	ST7789V_TEST(ret, st7789v_write_command(ctx, MIPI_DCS_SET_DISPLAY_ON));
	msleep(50);

	return 0;
}

static int st7789v_disable(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	int ret;

	ST7789V_TEST(ret, st7789v_write_command(ctx, MIPI_DCS_SET_DISPLAY_OFF));

	return 0;
}

static int st7789v_unprepare(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	int ret;

	ST7789V_TEST(ret, st7789v_write_command(ctx, MIPI_DCS_ENTER_SLEEP_MODE));

	regulator_disable(ctx->power);

	return 0;
}

static const struct drm_panel_funcs st7789v_drm_funcs = {
	.disable	= st7789v_disable,
	.enable		= st7789v_enable,
	.get_modes	= st7789v_get_modes,
	.prepare	= st7789v_prepare,
	.unprepare	= st7789v_unprepare,
};

static int st7789v_probe(struct spi_device *spi)
{
	struct st7789v *ctx;
	int ret;

	ctx = devm_kzalloc(&spi->dev, sizeof(*ctx), GFP_KERNEL);
	if (!ctx)
		return -ENOMEM;

	spi_set_drvdata(spi, ctx);
	ctx->spi = spi;

	drm_panel_init(&ctx->panel, &spi->dev, &st7789v_drm_funcs,
		       DRM_MODE_CONNECTOR_DPI);

	ctx->power = devm_regulator_get(&spi->dev, "power");
	if (IS_ERR(ctx->power))
		return PTR_ERR(ctx->power);

	ctx->reset = devm_gpiod_get(&spi->dev, "reset", GPIOD_OUT_LOW);
	if (IS_ERR(ctx->reset)) {
		dev_err(&spi->dev, "Couldn't get our reset line\n");
		return PTR_ERR(ctx->reset);
	}

	ctx->sclk = devm_gpiod_get(&spi->dev, "spi-scl", GPIOD_OUT_LOW);
	if (IS_ERR(ctx->sclk))
		return PTR_ERR(ctx->sclk);
	ctx->mosi = devm_gpiod_get(&spi->dev, "spi-sdi", GPIOD_OUT_LOW);
	if (IS_ERR(ctx->mosi))
		return PTR_ERR(ctx->mosi);
	ctx->cs = devm_gpiod_get(&spi->dev, "spi-cs", GPIOD_OUT_HIGH);
	if (IS_ERR(ctx->cs))
		return PTR_ERR(ctx->cs);

	ret = drm_panel_of_backlight(&ctx->panel);
	if (ret)
		return ret;

	drm_panel_add(&ctx->panel);

	return 0;
}

static int st7789v_remove(struct spi_device *spi)
{
	struct st7789v *ctx = spi_get_drvdata(spi);

	drm_panel_remove(&ctx->panel);

	return 0;
}

static const struct of_device_id st7789v_of_match[] = {
	{ .compatible = "lh,lh24030c50" },
	{ }
};
MODULE_DEVICE_TABLE(of, st7789v_of_match);

static struct spi_driver st7789v_driver = {
	.probe = st7789v_probe,
	.remove = st7789v_remove,
	.driver = {
		.name = "lh24030c50",
		.of_match_table = st7789v_of_match,
	},
};
module_spi_driver(st7789v_driver);

MODULE_AUTHOR("Maxime Ripard <maxime.ripard@free-electrons.com>");
MODULE_DESCRIPTION("LH24030C50 RGB LCD panel driver");
MODULE_LICENSE("GPL v2");

图像硬件加速

概念

FB(framebuffer):本质是一块内核管理的连续物理内存。显示驱动挂载时,向内核去申请(分配)一块连续物理内存,通常是**CMA(连续内存分配器)**区域。

将要显示到屏幕的数据写入到这个内存之后CPU就完全不参与搬运工作了。显示控制器(在RV1106中叫 VOP,在全志中叫 DE)内部有一个专门的DMA硬件模块,它会以固定的刷新率(如60Hz,即每秒60次)主动从这片物理内存地址中读取像素数据,并自动转换成MIPI/RGB信号发送给屏幕。

FB全程都是CPU在画画以及转换,GPU全程没有参与

把内核显存映射给用户态读写。

问题

  • 画面撕裂(Tearing)

    VOP读到一半,你还没来得及写完这一帧数据,VOP就把“半成品”画面(上半部分旧图,下半部分新图)读走了并显示出来。

    FB方案对此没有任何保护机制,你必须自己用软件去防撕裂(比如双缓冲+等待VSYNC信号)。

  • YUV转RGB

    YUV数据读到CPU,用CPU逐像素计算转换成RGB,再把RGB写进FB映射的内存里。CPU占用率极高。

    RGA优化

    • 摄像头生成YUV数据 -> 放在内存A
    • 内存A的地址告诉RGA驱动,调用 librga 的 imcvtcolor 命令:“帮我把YUV转成RGB,直接写到内存B(FB那片物理内存)
    • RGA硬件通过DMA直接把数据转换好写进FB内存,CPU全程不用碰像素数据。
    • VOP(显示控制器)再从FB内存把RGB数据读走显示。

工作流

1

概念

流程

2

数据分离存储

UI 界面、摄像头视频分别存两块完全独立的物理内存,不需要软件提前合并画面。

DRM/KMS 定位:调度配置层,不参与像素融合

DRM 只负责管理缓存、设置图层叠加参数,下发硬件指令,本身没有像素运算能力,不会融合图层。

VOP 是真正的硬件合成单元

收到 DRM 配置后,VOP 硬件同时读取视频缓存 + UI 缓存,硬件自动多层叠加混合,全程无 CPU 拷贝,性能高。

输出链路

VOP 合成完整单路像素流,通过 MIPI/RGB 排线直接推到屏幕显示。

┌─────────────────────────────────────────────────────────────────────┐
│                           应 用 层                                   │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐                         │
│  │ V4L2 采集 │   │ librga   │   │ libdrm   │                         │
│  │ 接口     │   │ 接口     │   │ 接口     │                           │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘                         │
└───────┼──────────────┼──────────────┼────────────────────────────────┘
        │              │              │
        ▼              ▼              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         内 核 驱 动 层                               │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐        │
│  │  rkisp   │   │  rga驱动 │   │rockchip- │   │ mipi_dsi │        │
│  │  驱动    │   │          │   │ drm (VOP)│   │ 驱动     │        │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘   └────┬─────┘        │
└───────┼──────────────┼──────────────┼──────────────┼────────────────┘
        │              │              │              │
        ▼              ▼              ▼              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                          硬 件 层                                    │
│                                                                     │
│   摄像头 ──→ MIPI CSI ──→ ISP ──→ DDR (YUV buffer)                 │
│                                      │                              │
│                                      ▼                              │
│                                   RGA 引擎                          │
│                              (YUV→RGB, 缩放)                        │
│                                      │                              │
│                                      ▼                              │
│                              DDR (RGB display buffer)               │
│                                      │                              │
│                                      ▼                              │
│                                   VOP 控制器                        │
│                              ┌───────────────┐                      │
│                              │  Plane 0      │ ←── 主显示图层       │
│                              │  Plane 1      │ ←── OSD/鼠标图层     │
│                              │  硬件合成     │                      │
│                              └──────┬────────┘                      │
│                                     │                               │
│                                     ▼                               │
│                              MIPI DSI / RGB 接口                   │
│                                     │                               │
│                                     ▼                               │
│                                  LCD 屏幕                           │
└─────────────────────────────────────────────────────────────────────┘

解码单元

​ 在Rockchip平台,这个硬件单元通常被称为VPU (Video Processing Unit,视频处理单元)

​ H.264编解码单元专门负责将H.264等格式的视频文件,硬解码成原始的YUV视频帧,原始的YUV视频帧再转化为RGB放到屏幕上。

	    H.264解码单元		  RGA/DE		   FB/DRM
MP4文件 ------------> YUV ------------> RGB ------------> 屏幕

编码单元

​

	  拿到RGB帧		  RGA/DE			H.264编码
屏幕 ------------> RGB ------------> YUV ------------> MP4文件
  • LVGL:支持,但需要主动配置。它通过Draw Unit对接GPU,支持OpenGL、NanoVG、VG-Lite等。
  • Qt:支持,通过QOpenGLWidget等封装主动调用OpenGL。
  • WPF:支持,默认通过DirectX管道将纹理、渐变等高层元素交由GPU渲染。
  • WinForms:基本不支持,主要依赖GDI/GDI+进行CPU软件渲染。
  • JavaFX:支持,通过Prism引擎默认启用GPU硬件加速。
  • Swing:基本不支持,完全依赖CPU进行软件渲染。
  • Unity / Godot:深度支持,核心就是通过Vulkan/D3D12/OpenGL等API将渲染命令发送给GPU。
  • Web前端:支持,现代浏览器通过GPU进程将图层合成(Composite) 等任务交给GPU。CSS 3D变换、will-change等能触发硬件加速。而WebGL/WebGPU则提供了直接的GPU调用接口。

⚙️ 技术细节:它们如何调用GPU?

1. LVGL 的 GPU 加速配置

在 lv_conf.h 中启用宏,并确保底层驱动支持。LVGL在绘制复杂图形(如带抗锯齿的矢量图)时,会将任务打包发给GPU。

2. 桌面UI框架的“自动”与“主动”

  • “自动”加速 (WPF, JavaFX):开发者无需显式调用GPU代码,框架底层(如WPF的DirectX、JavaFX的Prism引擎)会自动将绘图指令转换为GPU能处理的形式。
  • “主动”调用 (Qt):开发者需要将QOpenGLWidget作为画布,并在paintGL()中编写OpenGL代码,直接操作GPU。

3. 游戏引擎的“深度绑定”

游戏引擎的渲染管线从设计之初就围绕GPU构建。开发者通过Shader(着色器) 编写GPU执行的代码,引擎再通过RenderingDevice等抽象层将其转换为具体的GPU指令(Vulkan/D3D12)。

4. Web前端的“混合加速”

现代浏览器采用分层合成机制。CSS transform/opacity等属性的变化,只需GPU重新合成图层,无需CPU重绘。但对于DOM结构变化,仍需CPU进行布局(Layout) 和绘制(Paint)。此外,WebGL/WebGPU允许Web应用进行高性能的GPU通用计算。

总结

  • WinForms / Swing:老式“手工作坊”,几乎所有活都靠CPU。
  • WPF / JavaFX / Web (CSS):现代“自动化工厂”,框架自动将大部分任务交给GPU,开发者基本无感知。
  • Qt / LVGL:提供“自动化选项”,开发者可配置启用GPU加速。
  • Unity / Godot / WebGL:专业的“GPU编程工作室”,开发者通过着色器直接、深度地操控GPU。
int main(int argc, char **argv)
{
	/* open the drm device */
	open("/dev/dri/card0");

	/* get crtc/encoder/connector id */
	drmModeGetResources(...);

	/* get connector for display mode */
	drmModeGetConnector(...);

	/* create a dumb-buffer */
	drmIoctl(DRM_IOCTL_MODE_CREATE_DUMB);

	/* bind the dumb-buffer to an FB object */
	drmModeAddFB(...);

	/* map the dumb buffer for userspace drawing */
	drmIoctl(DRM_IOCTL_MODE_MAP_DUMB);
	mmap(...);

	/* start display */
	drmModeSetCrtc(crtc_id, fb_id, connector_id, mode);
}

硬件: VOP(Video Output Processor)— 物理硬件,负责从内存读像素 → 合成 → 发送给屏幕 软件: DRM(Direct Rendering Manager)— 内核驱动框架,负责告诉 VOP "读哪块内存、用什么格式、显示在哪"

摄像头硬件 → VICAP(DMA写入 dma-buf)
                    ↓
          dma-buf(物理内存中的一块区域,只有地址和大小)
                    ↓
camera 驱动 → 把 dma-buf 注册给 DRM → 得到 fb_id
                    ↓
        应用调 drmModeSetPlane(
            plane_id,      ← 告诉 VOP 用哪个图层
            fb_id,         ← 告诉 VOP 读哪块内存
            crtc_x/crtc_y/w/h,  ← 告诉 VOP 把画面放在屏幕哪个位置
            src_x/src_y/w/h     ← 告诉 VOP 从摄像头画面里裁剪哪一块
        )
                    ↓
          VOP 从指定内存地址读像素 → 合成 → 显示

分配

/* DRM fd 只用于缓冲分配/取物理地址,不需要 SET_MASTER(避免抢 fb0 显示) */
int drm_fd = open("dev/dri/card0");
if(drm_fd < 0){
	printf("打开drm设备失败\n");
}
//创建drm_mode_create_dumb向内核申请显存
struct drm_mode_create_dumb create;
memset(&create, 0, sizeof(create)); //清空物理内存
//设置要分配的内存大小,长 宽 像素密度 RGB565的bpp是16,NV12是12 alloc_h高度如果是NV12那么高度要*1.5
create.width = w; create.height = h; create.bpp = bpp;
ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &create); //ioctl去分配

获取

struct drm_rockchip_gem_phys gp;
memset(&gp, 0, sizeof(gp));
gp.handle = create.handle; //拿到分配后的句柄

ioctl(drm_fd, DRM_ROCKCHIP_GEM_GET_PHYS, &gp);
// 拿到 32 位物理地址。无 IOMMU 的 RV1106 上,这是 RGA 硬件能直访内存的唯一途径
gp.phy_addr;

如何分配的概念

DRM、CMA和DMA的联系,本质上是DRM框架通过GEM内存管理器,根据硬件是否具备IOMMU,来选择使用CMA或SHMEM作为底层内存分配器,以满足不同硬件对内存的连续性需求。

关键角色:硬件显示控制器 (VOP) 与 IOMMU

理解三者的关系,首先要明白瑞芯微的VOP(Video Output Processor),也就是显示控制器。

  • VOP的任务:VOP需要从内存中读取图像数据,然后转换成屏幕信号。
  • 关键变量:IOMMU:VOP读取内存时,是否支持IOMMU(输入输出内存管理单元) 是关键。
    • 有IOMMU:VOP可以通过IOMMU的页表,访问物理上不连续的内存。
    • 无IOMMU:VOP只能直接访问物理地址连续的内存。
联系一:DRM GEM 是“调度中心”

瑞芯微的DRM驱动使用GEM (Graphics Execution Manager) 来管理所有图形内存的分配和生命周期。

  • 当用户应用(如Wayland)通过ioctl请求分配显存时,DRM驱动会调用rockchip_gem_create_object()等函数进行处理。
  • 此时,DRM GEM扮演“决策者”的角色,根据当前平台的具体情况,决定将内存分配任务交给谁。
联系二:GEM 的两种分配策略:CMA vs SHMEM

瑞芯微的GEM驱动会根据有无IOMMU,选择两种不同的底层分配策略:

  1. CMA (Contiguous Memory Allocator) 策略:无IOMMU的选择
    • 适用场景:当VOP没有IOMMU支持时(例如RK3506平台),GEM会使用CMA来分配内存。
    • 工作原理:CMA从系统启动时就预留一大块物理连续的内存池。GEM通过调用CMA的API,从这个池子里申请内存。
    • 硬件需求:这种方式分配的内存,VOP无需IOMMU就能直接通过物理地址访问。
  2. SHMEM 策略:有IOMMU的选择
    • 适用场景:当VOP具备IOMMU支持时(例如RK3588等现代平台),GEM会优先使用基于SHMEM的内存分配方式。
    • 工作原理:SHMEM通过标准的内存页分配机制,获取的是物理上可能不连续的内存页。
    • 硬件需求:VOP需要通过IOMMU的地址映射功能,才能访问这些不连续的内存。

注意:内核中的DRM_GEM_CMA_HELPER和DRM_GEM_DMA_HELPER等术语,提供了实现这些策略的标准化接口。

联系三:CMA 是 DMA 框架的一部分

你需要知道,CMA本身就是Linux内核DMA(Direct Memory Access)框架的一部分。

  • 很多硬件(如摄像头ISP、视频编解码器VPU、2D图形加速器RGA)都需要物理连续的内存才能工作。
  • 因此,DRM GEM在需要连续内存时,实际上是借用了内核DMA框架下的CMA分配器来完成任务。所以,DRM与DMA的联系,通过CMA这个桥梁得以建立。

多线程

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* print_message(void* arg) {
    (void*)arg;
    while(1)
    {
        printf("Hello from thread\n");
        sleep(1);
    }
    return NULL;
}

int main() {
    pthread_t threads;
    pthread_create(&threads, NULL, print_message, NULL);
    while(1)
    {
        sleep(1);
        printf("main threads runing\n");
    }
    return 0;
}

创建一个线程:

extern int pthread_create (pthread_t *__restrict __newthread,

			const pthread_attr_t *__restrict __attr,

			void *(*__start_routine) (void *),

			void *__restrict __arg) __THROWNL __nonnull ((1, 3));
  • 创建成功返回0,否则返回错误编号。
  • 新创建的线程ID设置成__newthread指向的内存单元。
  • __attr用于定制各种不同的线程属性。
  • __start_routine现成从这个函数地址开始运行。这个函数

一、线程退出的 3 种方式

方式触发者本质资源回收
线程执行完函数返回线程自己隐式退出,返回值就是 return 的值必须 join 或 detach
pthread_exit()线程自己主动退出,可指定返回值必须 join 或 detach
pthread_cancel()其他线程请求取消(异步/延迟)必须 join 或 detach

⚠️ 无论哪种方式,线程结束后其资源(栈、TLS、内核结构)不会自动完全释放,除非被 join 或 detach。

场景推荐做法
需要线程结果pthread_join()
不需要结果, fire-and-forgetpthread_detach()(创建后立即 detach)
需要中途终止线程pthread_cancel() + pthread_join(),但尽量用条件变量/标志位优雅退出
避免僵尸线程确保每个线程都被 join 或 detach

1. pthread_exit(void *retval)

关键点:

  • 只能由线程自己调用(你笔记里写了,这是对的)
  • retval 不能指向线程栈上的局部变量(线程退出后栈销毁,指针悬空)
  • 主线程调用 pthread_exit() 不会导致进程退出,只会导致主线程退出,其他线程继续运行
void *my_thread(void *arg)
{
    printf("线程执行---\n");
    int *result = malloc(sizeof(int));
    *result = 42;
    
    pthread_exit(result);  // 主动退出,返回 42
    // 或者直接用 return result; 效果一样
}

int main(void)
{
    pthread_t pthread;
    pthread_create(&pthread, NULL, my_thread, NULL);
    while (1)
    {
        /* code */
        sleep(1);
        printf("主线程---\n");
    }
    return 0;
}

2.pthread_join(pthread_t thread, void **retval)

作用:

  • 阻塞等待指定线程结束
  • 回收线程资源(栈、内核结构)
  • 获取线程返回值(通过 retval 输出参数)

⚠️ 坑:

  • 一个线程只能被 join 一次
  • 如果线程已经被 detach,再 join 会返回 EINVAL
  • 不 join 也不 detach → 线程变成"僵尸线程",资源泄漏
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *my_thread(void *arg)
{
    sleep(3);
    printf("线程停止---\n");
}

int main(void)
{
    pthread_t pthread;
    pthread_create(&pthread,NULL,my_thread,NULL);
    void *result;
    pthread_join(pthread,result);
    printf("主线程\n");
    return 0;
}

3. pthread_detach(pthread_t thread)

作用:

  • 告诉系统:我不关心这个线程的返回值,它结束后自动回收资源
  • detach 后线程变成"分离状态",结束后立即释放资源,不可 join

适用场景:

  • 不需要知道线程结果(如后台守护线程、日志写入线程)
  • 避免忘记 join 导致资源泄漏

⚠️ 坑:

  • detach 后不能再 join
  • 如果线程返回了堆内存指针,detach 后没人收,会内存泄漏
void *my_thread(void *arg)
{
    printf("子线程运行---\n");
    return NULL;
}
int main(void)
{
    pthread_t pthread;
    pthread_create(&pthread,NULL,my_thread,NULL);
    printf("子线程开始运行---\n");
    pthread_detach(pthread);
    printf("不阻塞,继续执行主线程\n");
    sleep(1);
    printf("主线程结束--\n");
    return 0;
}

4. pthread_cancel(pthread_t thread)

关键点:

  • 只是"请求"取消,不是强制杀死
  • 目标线程必须开启取消机制才会响应(默认是开启的)
  • 取消是异步的,目标线程可能在**取消点(cancellation point)**才实际退出

⚠️ 大坑:

  • 异步取消可能导致资源泄漏(锁没释放、内存没 free)
  • 延迟取消更安全,但线程可能卡在非取消点的死循环里,永远退不出
  • 取消后必须 join 来回收资源!
void *my_thread(void *arg)
{
    while (1)
    {
        printf("子线程运行----\n");
        sleep(1);
    }
    return NULL;
}
int main(void)
{
    pthread_t pthread;
    pthread_create(&pthread,NULL,my_thread,NULL);
    printf("3s后请求取消子线程\n");
    sleep(3);
    pthread_cancel(pthread);
    printf("子线程已取消\n");
    sleep(3);
    printf("主线程终止\n");
    return 0;
}

摄像头驱动

MIPI = Mobile Industry Processor Interface,移动产业处理器接口联盟专门为手机、嵌入式、IoT、车载设计低功耗、高速差分串行接口,分很多子协议:

  • CSI:Camera Serial Interface(摄像头)
  • DSI:Display Serial Interface(屏幕)
  • I2C/I3C:低速控制
  • UniPro、CPHY、DigRF 等

MIPI CSI-2:目前主流摄像头传输标准。

MIPI CSI-2 硬件分层(分两部分:DPHY / CPHY)

  1. D-PHY:2 线差分(P/N),最高~2.5Gbps/lane,低成本,绝大多数嵌入式摄像头(GC2145/OV2640/IMX 系列)都用,RV1106 全是 DPHY。
  2. C-PHY:3 线三相信号,速率更高、布线复杂,高端车载 / 手机主摄。
Sensor → MIPI CSI-2差分线 → csi2_dphy1(模拟PHY)
→ mipi1_csi2(CSI2协议解串器)
→ rkcif(图像采集单元)
→ rkisp(图像ISP,降噪、白平衡、曝光)
→ 应用层 /dev/video0

MIPI CSI-3

M-PHY

sensor:mos → ADC → csi → DPHY-TX

SOC:DCPHY-RX → MIPI-CSI → RKCIF → ISP → DRM

硬件层

	MIPI CSI-2 Sensor传感器						  MIPI协议	控制lane发送
感光传感器--> ADC转换 --> Bayer RAW像素(RAW/RGB) --> CSI2协议封装 --> DPHY --> MIPI线 -->SOC

SOC层

		   接收lane
MIPI线 -->  DPHY --> 各种转换 --> CSI2 HOST --> VICAP / CIF --> 各种方式 --> ISP

DPHY:对应设备树 csi2_dphy0,csi2_dphy1,csi2_dphy2

CSI2 HOST:对应设备树 mipi1_csi2,mipi2_csi2

VICAP / CIF:对应设备树 rkcif_mipi_lvds1

v4l2驱动

	V4L2框架		videobuf2内存管理		video设备节点
DMA中断通知 --> 缓冲队列入队/出队 --> /dev/videoX节点 --> 用户空间系统调用

用户层

	系统调用API		应用框架		业务场景
open/ioctl/mmap --> GStreamer/FFmpeg --> 显示画面/编码存储/RTSP推流/AI推理

1

第一部分:图1(端到端数据流全景图)逐级详解

这张图展示了从“光信号”到“应用画面”的物理与逻辑路径。

1. 摄像头硬件端(Sensor Board)

  • 光信号 -> CMOS感光阵列 & ADC:外界光线打到CMOS传感器上,每个像素点根据接收光强产生模拟电压信号。**ADC(模数转换器)**立即将这些模拟信号转换为数字电平,形成原始的Bayer格式数字数据(每个像素点只包含R/G/B中的一种颜色分量)。
  • RAW Bayer 像素数据:这是ISP(图像信号处理器)处理的“原材料”,也是最原始的无损数据,体积较大。
  • MIPI CSI-2 打包器:传感器内部的逻辑电路按照MIPI联盟定义的CSI-2协议,将RAW数据封装成数据包。具体分为**长包(Long Packet)承载像素负载,和短包(Short Packet)**承载帧起始(FS)、帧结束(FE)、行起始(LS)等同步信号。
  • D-PHY 发送器:将打包好的并行数据转换为高速差分串行信号(一般包含1对时钟线和多对数据线,如2-lane或4-lane),通过FPC排线传输给主控SoC。

2. SoC硬件端(接收与路由)

  • D-PHY 接收器:SoC端对应的物理层接口,负责锁定时钟信号,并将高速串行数据重新解串为并行数据。这一步骤如果出现信号完整性(SI)问题,会导致数据错位或丢失。
  • CSI-2 Host 解包器:解析MIPI协议,剥离包头,将有效的像素负载提取出来,并根据虚拟通道(Virtual Channel)ID将数据分发到不同的缓冲区。
  • VICAP(视频捕获控制器):这是RK平台承上启下的核心硬件模块。它负责将Host解包后的数据进行格式重整(如数据对齐、位宽调整),并且内置路由逻辑。它决定了数据是直接通过DMA搬运到内存(Bypass模式),还是送入ISP进行图像增强(ISP路径)。
  • 数据路由选择(关键分叉点):
    • 直通路径(Bypass):适用于AI识别或深度感知场景。VICAP直接将RAW数据或Sensor输出的YUV数据通过**DMA(直接内存访问)**搬运到DDR内存中,应用程序直接拿原始数据做推理。此路径延时最低。
    • ISP处理路径:VICAP将数据流转发给ISP(图像信号处理器)。ISP硬件依次执行**黑电平校正(BLC)、镜头阴影校正(LSC)、去马赛克(Demosaic,将Bayer插值为全彩)、自动白平衡(AWB)、色彩转换(CCM)、Gamma校正、锐化、降噪(NR)以及宽动态(WDR)**等复杂运算。
  • DMA 写入内存:无论是否经过ISP,最终处理完的数据帧都会由DMA控制器写入预先在内存中申请好的物理地址(即videobuf2的缓冲区)。写入完成后,DMA会触发硬件中断通知CPU。

3. Linux内核 V4L2 驱动层(软件调度)

  • videobuf2 缓冲池管理:这是V4L2框架的内存管理核心。它在内核空间维护一个缓冲区队列,通过mmap(内存映射)、DMABUF(跨设备共享)或Userptr(用户指针)三种模式与用户空间交互。DMA将数据写入后,缓冲区状态会从VB2_BUF_STATE_DONE流转为VB2_BUF_STATE_QUEUED,等待用户取走。
  • video_device 设备节点:最终生成用户空间可见的 /dev/videoX 节点。应用程序通过open()打开该节点,通过ioctl(如VIDIOC_QBUF入队、VIDIOC_DQBUF出队)来获得图像帧。
  • Media Controller & v4l2_subdev(控制面):图中虚线表示控制流。v4l2_subdev代表Sensor、ISP等子设备,驱动通过I2C总线下发寄存器配置给Sensor(如修改曝光值、帧率)。Media Controller则负责动态建立Pipeline(如Sensor -> CSI -> ISP -> videoX),确保数据流打通。

4. 用户应用程序层(最终使用)

  • 系统调用(ioctl / mmap / poll):应用通过标准Linux系统调用与 /dev/videoX 交互。poll或epoll用于等待帧就绪事件,避免CPU空转。
  • 上层应用框架:实际开发中很少直接写ioctl,而是调用V4L2 Userpace API封装库,或者使用 GStreamer 的 v4l2src 插件、FFmpeg 的 avdevice 模块。在Android系统中则对接Camera HAL。
  • 显示/编码/推流:拿到内存中的图像数据后,应用将其送去显示(DRM)、硬件编码(如H.264/H.265)或通过网络协议(RTSP/WebRTC)推流。

2.png

第二部分:图2(软件分层架构图)逐层详解

这张图侧重于操作系统内部“代码模块”的层级依赖,适合开发驱动或调试内核问题时查阅。

1. 用户空间(User Space)

  • 应用/算法库:包括跑在ARM上的OpenCV推理程序、Qt显示界面等,它们不直接操作寄存器,只调用标准API。
  • Android Camera HAL / RkAiq库:特指Rockchip提供的3A(AE/AWB/AF)算法动态库。该库在用户空间运行,通过私有的/dev/isp_params节点将3A计算出的参数(如曝光时间、增益、白平衡色温)实时下发给内核驱动,形成闭环控制。

2. 内核空间 - V4L2框架层(Kernel Space)

  • 字符设备层:Linux将硬件抽象为文件,即 /dev/videoX 和 /dev/v4l-subdevX。应用的所有操作入口都在这里。
  • V4L2核心层:这是标准Linux内核提供的通用框架代码。它负责解析ioctl命令码,并路由到对应的驱动回调函数(如.vidioc_streamon、.vidioc_s_fmt),同时管理poll事件通知机制。
  • Media Controller(媒体控制器):属于V4L2框架的进阶特性。它不允许应用随意使能硬件模块,必须严格按拓扑图绑定。例如,使用命令 media-ctl -l "'rkisp-isp':0 -> 'rkisp-resizer':0 [1]" 来强制指定数据流下一跳。
  • videobuf2(VB2):独立于V4L2核心的内存管理层。它适配不同的内存类型(连续物理内存或不连续内存),并提供ops回调给驱动,用于通知驱动“该给这块缓冲区填数据了”。

3. 内核空间 - RK平台子设备驱动(Platform Driver)

  • Sensor驱动:通常遵循 v4l2_subdev 标准。核心工作是实现 .s_power(上电时序)、.s_fmt(设置输出分辨率/帧率)以及 s_ctrl(通过I2C写Sensor寄存器)。
  • RK CIF(Camera Interface)驱动:负责管理CSI-2 Host和VICAP硬件。它申请DMA通道,配置MIPI数据通道映射(lane映射),并处理MIPI错误中断(如ECC/CRC校验失败)。
  • RK ISP驱动:管理ISP硬件流水线。它不仅要配置硬件寄存器,还要处理ISP产生的中断(如帧结束中断、3A统计信息Ready中断),并将统计结果(Histogram/AE统计)通过特定的v4l2_event上报给用户空间的RkAiq库。

4. 硬件寄存器层(Hardware Layer)

  • 这是物理上的硬件单元。驱动层通过 writel() / readl() 函数向这些硬件单元的寄存器地址写入控制值(如启动传输、配置DMA地址)。注意:ISP驱动的参数配置和CIF驱动的启动时机必须严格配合,否则会出现“DMA写到错误地址”导致系统内存被踩坏(Memory Corruption)的严重问题。

🔗 数据流关键节点对照总结(排查宝典)

为了帮你更好地将图文对照用于实际Debug,我把关键节点做了串联描述:

关键节点对应图中的模块成功标志(Debug点)常见故障表现
物理信号锁定D-PHY接收器驱动打印 lane rate 和 hsync 成功信息驱动报错 Timeout waiting for PLL lock,无图像
协议解包成功CSI-2 Host/proc/interrupts 中 cif 中断计数增加硬件中断不增加,说明MIPI线序或电压不匹配
DMA搬运正确VICAP -> DDRcat /proc/kmsg 无 DMA FIFO overflow 错误画面出现横向撕裂或绿色噪点
缓冲区队列流转videobuf2 -> /dev/videoX应用层poll返回POLLIN,DQBUFF不阻塞DQBUFF一直阻塞,检查缓冲区是否全部出队(STREAMON未调用)
图像画面显示用户层 GStreamergst-launch-1.0 v4l2src device=/dev/video0 ! ... 出现画面花屏大概率是ISP参数错误或RAW格式匹配不对(如MIPI是RAW10,用户解析成RAW12)

思路

确保有Framebuffer

ls /dev/fb*
#返回/dev/fb0

概念

Framebuffer 就是一块内存区域,里面存放着屏幕上每个像素的颜色值。

LCD 控制器会周而复始地从 Framebuffer 中逐一取出每个像素的颜色,通过 RGB 数据线、时序信号(HSYNC、VSYNC、DE、DCLK)发送给 LCD 屏幕,屏幕就显示出图像。

Framebuffer不支持GPU加速,如果要使用GPU加速技术必须使用DRM,或者使用别人封装好的DRM库,比如openGL,Vulkan等

系统调用,打开文件

  • 使用<fcntl.h>头文件进行系统调用打开/dev/fb0
  • 使用<unistd.h>头文件进行系统调用关闭close(fb0);
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define FD "/dev/fb0"
int main(void){
    int fb0 =open(FD,O_RDWR);
    if (fb0 == -1) {
        printf("打开 %s 失败\n",FD);
        return -1;
    }
    printf("打开 %s 成功\n",FD);
    close(fb0);
    return 0;
}
  • 使用 ioctl 获取屏幕参数(分辨率、BPP)
  • 使用<linux/fb.h>拿到基本信息
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/fb.h>
#include <sys/ioctl.h>

int main(void)
{ 
    int fb0 = open("/dev/fb0",O_RDWR);
    if (fb0 == -1) {
        printf("打开失败\n");
        return -1;
    }
    // 2. 获取屏幕参数(分辨率、色深等)
    struct fb_var_screeninfo vinfo;
    struct fb_fix_screeninfo finfo;
    ioctl(fb0, FBIOGET_VSCREENINFO, &vinfo);
    ioctl(fb0, FBIOGET_FSCREENINFO, &finfo);

    // 打印屏幕信息
    printf("分辨率: %d x %d\n", vinfo.xres, vinfo.yres);
    printf("色深: %d 位\n", vinfo.bits_per_pixel);

    close(fb0);
    return 0;
}
  • /usr/include/linux/fb.h中的内容
struct fb_var_screeninfo vinfo; //获取基本的可变显示信息,分辨率,色深等。
struct fb_fix_screeninfo finfo;
  • mmap

​ mmap 是 memory map 的缩写,它将内核中的一块内存(这里是 Framebuffer)映射到用户进程的地址空间,使得可以直接像访问普通数组一样读写这块内存,而不需要通过 read/write 系统调用。

参数详解:

	addr: 建议的映射起始地址。通常设为 NULL (或 0),让内核自动选择合适的地址。
	length: 映射区域的长度(字节)。
	prot: 保护标志,决定内存页面的访问权限:
	PROT_READ: 可读
	PROT_WRITE: 可写
	PROT_EXEC: 可执行
	PROT_NONE: 不可访问
	flags: 映射类型标志:
	MAP_SHARED: 共享映射。对内存的修改会写回文件/设备,其他映射该文件的进程可见。(Framebuffer 常用此模式)
	MAP_PRIVATE: 私有映射。对内存的修改不会写回文件,而是创建副本(Copy-on-Write)。
	fd: 文件描述符(通过 open 获得)。如果是匿名映射,设为 -1。
	offset: 文件中的偏移量,必须是页面大小(通常 4KB)的倍数。
返回值:
	成功:返回映射区域的起始地址指针。
	失败:返回 MAP_FAILED (即 (void *)-1)。
在 Framebuffer 中的作用: 它将 /dev/fb0 设备的显存直接映射到用户空间的指针 fb_buf。之后你读写 fb_buf 就像读写普通数组一样,但实际上是直接操作显卡显存。
#include <stdio.h>
#include <sys/fcntl.h>
#include <sys/unistd.h>
#include <sys/ioctl.h>
#include <linux/fb.h>
#include <sys/mman.h>
#include <string.h>

/**
 * @brief 映射关系
 * 
 * @return int 
 */
int main()
{ 
    int fd0 = open("/dev/fb0", O_RDWR);
    if(fd0 == -1)
    {
        printf("open fb0 error\n");
        return -1;
    }
    struct fb_var_screeninfo varinfo;
    ioctl(fd0, FBIOGET_VSCREENINFO, &varinfo);
    struct fb_fix_screeninfo finfo;
    ioctl(fd0, FBIOGET_FSCREENINFO, &finfo);

    //获取帧缓冲区内存大小 8bit的屏幕
    long screensize = varinfo.xres * varinfo.yres * varinfo.bits_per_pixel / 8;
    printf("screensize = %ld\n", screensize);//screensize = 4096000不到4mb

    //处理mmap
    char *fbp = mmap(NULL, screensize, PROT_READ | PROT_WRITE, MAP_SHARED, fd0, 0);
    if(fbp == MAP_FAILED)
    {
        printf("mmap error\n");
        return -1;
    }
    printf("fbp = %p\n", fbp);
    printf("finfo.line_length = %d\n", finfo.line_length);
    
    //设置fbp开始到screensize的内存为0 用来画矩形
    memset(fbp, 0, screensize);

    //刷成蓝色
    for (int i = 0; i < screensize; i += 4) { // 32位色:4字节1像素
        fbp[i + 0] = 0xFF;  // 蓝色
        fbp[i + 1] = 0x00;  // 绿色
        fbp[i + 2] = 0x00;  // 红色
        // 透明度Alpha ARGB8888支持,RGB565不支持Alpha不能设置这个
        fbp[i + 3] = 0xFF;  //通常设为255表示不透明 
    }
    sleep(2);

    // 释放映射关系 fbp到screensize的内存
    munmap(fbp, screensize);
    close(fd0);
    
    return 0;
}
  • RGB565和ARGB8888

通过vinfo.bits_per_pixel拿到色深,16位为RGB565,32位为ARGB8888。可以类别为

ARGB8888为:

  • 16进制:0xAARRGGBB
  • 二进制:0bAAAAAAAA RRRRRRRR GGGGGGGG BBBBBBBB

RGB565为:

  • 二进制:0bRRRRR GGGGGG BBBBB

简化之前刷屏代码

// 刷成蓝色 (简化版)
uint32_t *fbp32 = (uint32_t *)fbp; // 将 char 指针转为 32位整数指针
long num_pixels = screensize / 4;  // 计算总像素数
    
// 0xFF0000FF 代表: Alpha=0xFF, Red=0x00, Green=0x00, Blue=0xFF (ARGB格式)
// 在小端序机器上,这会在内存中存储为: FF 00 00 FF (即 B=FF, G=00, R=00, A=FF)
uint32_t blue_color = 0xFF0000FF; 

for (long i = 0; i < num_pixels; i++) {
    fbp32[i] = blue_color;
}

命令行操作

hexdump -e '16/1 "%02x " "\n"' /dev/input/event0

通过evtest工具测试输入设备

apt install evtest
root@docker:~/# evtest
No device specified, trying to scan all of /dev/input/event*
Available devices:
/dev/input/event0:      Power Button
/dev/input/event1:      AT Translated Set 2 keyboard
/dev/input/event2:      VirtualPS/2 VMware VMMouse
/dev/input/event3:      VirtualPS/2 VMware VMMouse
/dev/input/event4:      QEMU QEMU USB Tablet
Select the device event number [0-4]: 
可用输入设备列表:
/dev/input/event0: 电源按钮(Power Button)
/dev/input/event1: 标准键盘(AT Translated Set 2 keyboard)
/dev/input/event2: 虚拟机鼠标(VirtualPS/2 VMware VMMouse)
/dev/input/event3: 虚拟机鼠标(VirtualPS/2 VMware VMMouse)
/dev/input/event4: 虚拟机平板/触摸设备(QEMU QEMU USB Tablet)
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/input.h>
#include <sys/ioctl.h>

#define INPUT_DEVICE "/dev/input/event4"

#define SCREEN_WIDTH  1280
#define SCREEN_HEIGHT 800

// 全局保存真实触摸范围
int abs_x_min, abs_x_max;
int abs_y_min, abs_y_max;

int main(void)
{
    int fd;
    struct input_event ev;
    struct input_absinfo absinfo;
    // 打开输入设备
    fd = open(INPUT_DEVICE, O_RDONLY);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    // 获取 X 轴范围
    ioctl(fd, EVIOCGABS(ABS_X), &absinfo);
    abs_x_min = absinfo.minimum;
    abs_x_max = absinfo.maximum;

    // 获取 Y 轴范围
    ioctl(fd, EVIOCGABS(ABS_Y), &absinfo);
    abs_y_min = absinfo.minimum;
    abs_y_max = absinfo.maximum;
    
    // 打印获取到的范围
    printf("EVDEV ABS_X range: %d - %d\n", abs_x_min, abs_x_max);
    printf("EVDEV ABS_Y range: %d - %d\n", abs_y_min, abs_y_max);

    while (1)
    {
        // 读取一个输入事件
        read(fd, &ev, sizeof(ev));
        // 1. 绝对坐标 X (触摸屏/鼠标)
        if (ev.type == EV_ABS && ev.code == ABS_X) {
            printf("X: %d\t", (ev.value*SCREEN_WIDTH)/abs_x_max);
        }

        // 2. 绝对坐标 Y
        if (ev.type == EV_ABS && ev.code == ABS_Y) {
            printf("Y: %d\n", (ev.value*SCREEN_HEIGHT)/abs_y_max);
        }
        // 5. 触摸屏按下/抬起
        if (ev.type == EV_KEY && ev.code == BTN_TOUCH) {
            printf("触摸状态: %s\n", ev.value ? "按下" : "抬起");
        }

        // 6. 鼠标左键按下/抬起
        if (ev.type == EV_KEY && ev.code == BTN_LEFT) {
            printf("鼠标左键: %s\n", ev.value ? "按下" : "抬起");
        }
    }
    
    close(fd);
    return 0;
}

<fcntl.h>

<unistd.h>

Lvgl

001. Lvgl控件学习

Display

每一个物理显示设备都有一个Display(lv_display)

lvgl初始化一个物理显示设备需要进行以下操作:

  • 为这个物理显示设备创建一个lv_display_t对象,并设置物理设备的宽高
  • 为这个对象提供绘图函数(硬件驱动)
  • 分配draw_buffers。把这个buffer当成lvgl的草稿纸,lvgl写完草稿了,才会写到屏幕上。使用lvgl画图,控件变化等功能,比如填充(0,0,320,240)这个区域lvgl会现在这个buffer中完成绘制,再调用绘图函数将buffer里的数据同步到显示屏。注意:buffer可能很小,比如才320*10,那么这个buffer,需要调用240/10=24次绘图函数。
static void init_lvgl_buf(void)
{
  /*Create a display buffer*/
  static lv_disp_draw_buf_t disp_buf1;//定义一个显示绘图缓冲区结构体,用于管理 LVGL 的“草稿纸”。
  static lv_color_t buf1_1[SDL_HOR_RES * 100];//分配一块内存作为缓冲区
  lv_disp_draw_buf_init(&disp_buf1, buf1_1, NULL, SDL_HOR_RES * 100);//初始化缓冲区结构体

  /*创建 lv_disp_drv_t驱动描述符 */
  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
    
  /* 给驱动描述符对象分配内存,设置Display宽高,提供绘图函数*/
  disp_drv.draw_buf = &disp_buf1;
  disp_drv.flush_cb = sdl_display_flush;
  disp_drv.hor_res = SDL_HOR_RES;
  disp_drv.ver_res = SDL_VER_RES;

  /*创建Display对象,会根据前面的驱动描述符对象中的内容创建*/
  lv_disp_t *disp = lv_disp_drv_register(&disp_drv);

}

lv_disp_drv_register做了什么

为什么**lv_scr_act()**能拿到默认显示屏的widgets根节点?

LVGL 中,显示设备通过全局链表和默认指针来管理多个显示屏。

  • 全局静态变量:在文件作用域定义链表头(_lv_ll_t)和全局 disp_default 指针,提供模块内部的“上下文”。必然有一个变量存储lv_disp_t
  • 通用链表(_lv_ll_t):LVGL 实现了一个轻量级双向链表,可容纳任意类型的节点,避免为每种对象重写链表操作。
  • 动态内存分配:为每个显示设备分配独立的 lv_disp_t 对象,插入链表后统一管理。
  1. 分配并初始化 lv_disp_t 对象
    • 存储显示驱动参数(分辨率、flush 回调、绘图缓冲区等)。
    • 将该对象挂入全局显示链表,若为第一个显示设备则设为默认显示。
  2. 创建两个屏幕对象(lv_obj_t)
    • 活动屏幕(act_scr):当前可见的主屏幕。
    • 上一个屏幕(prev_scr):用于屏幕切换动画的备用屏幕(初始与 act_scr 相同或为 NULL)。
  3. 配置屏幕对象的基本属性
    • 设置屏幕尺寸为显示设备的 hor_res × ver_res。
    • 清除可滚动标志(屏幕本身不可滚动)。
    • 标记对象类型为 LV_OBJ_CLASS_SCREEN,使其成为所有其他控件的根容器。
    • 将屏幕的 parent 设为 NULL(顶级对象)。
  4. 将屏幕与显示设备关联
    • disp->act_scr = new_scr,disp->prev_scr = NULL(或 new_scr)。
    • 设置屏幕的 disp 指针指向当前显示设备。
  5. 触发默认主题/样式的加载
    • 如果 LVGL 启用了主题功能,会自动为屏幕应用默认主题(背景色、默认样式等)。
    • 主题可能进一步初始化默认字体、颜色方案等。
  6. 强制刷新整个屏幕(首次重绘)
    • 调用 lv_obj_invalidate(act_scr) 标记整个屏幕为“脏”。
    • 随后 LVGL 的任务调度器会触发一次全屏重绘:在绘图缓冲区中绘制屏幕背景,并通过 flush_cb 发送到物理显示屏。
  7. 使 lv_scr_act() 可用
    • lv_scr_act() 定义为 lv_disp_get_scr_act(lv_disp_get_default()),此时默认显示设备已存在,act_scr 已有效,因此可以返回屏幕根控件指针。

Screen

display对象创建之后会创建4个screen。

  1. 底层(位于活动屏幕下方,透明、不可滚动,但可以点击)
  2. Active Screen
  3. 顶层(位于活动屏幕上方,透明且不可滚动或点击)
  4. 系统层(位于顶层上方,透明且不可滚动或点击)
  • lv_screen_active()
  • lv_layer_top()
  • lv_layer_sys()
  • lv_layer_bottom()

“弹出窗口”添加到 顶层既可以实现弹出框

Active Screen

当前被 LVGL 显示系统认定为“前台”的那张画布

不是什么base widget 不要搞混

Base Widget

通过lv_scr_act() 拿到base widget

所有 Widgets 中最基本的是基础 Widget,所有其他 Widgets 都基于它构建。从面向对象的角度来看,可以将基础 Widget 视作所有其他 Widgets 继承的 Widget 类。

所有的Widget返回的都是lv_obj_t 结构体指针

widgets

所有widget都有

  • 位置

  • 尺寸

  • 父级

  • 样式

  • 事件

  • ....

位置(position)

1. 绝对坐标定位 (Absolute Positioning)

  • lv_obj_set_pos(obj, x, y)

    • 用法:同时设置 X 和 Y 坐标。
    • 示例:lv_obj_set_pos(obj1, 100, 100);
    • 特点:最常用,简洁明了。坐标原点通常是父容器的内容区域左上角(扣除 padding 后)。
  • lv_obj_set_x(obj, x) / lv_obj_set_y(obj, y)

    • 用法:单独设置 X 或 Y 坐标。

    • 示例:

      lv_obj_set_x(obj1, 100);
      lv_obj_set_y(obj1, 100);
      
    • 特点:适用于只需要改变一个方向位置的场景,或者动态调整单个轴的位置。

注意:绝对坐标受父容器的 padding(内边距)影响。如果父容器设置了 padding,(0,0) 点实际上是 padding 之后的起始点。


2. 相对对齐定位 (Alignment)

这种方式基于父容器或另一个参考对象的特定锚点(如中心、左上角、右下角等)进行定位,更适合响应式布局或居中显示。

  • lv_obj_set_align(obj, align)

    • 用法:将对象对齐到其父容器的指定位置。

    • 示例:

      lv_obj_set_align(obj1, LV_ALIGN_CENTER); // 居中
      lv_obj_set_align(obj1, LV_ALIGN_TOP_MID); // 顶部中间
      
    • 特点:自动计算位置,即使父容器大小改变,对象仍保持相对位置不变。

  • lv_obj_align(obj, parent, align, x_ofs, y_ofs)

    • 用法:将对象对齐到指定 parent 的某个位置,并允许添加偏移量 (x_ofs, y_ofs)。

    • 示例:

      // 对齐到 base_widget 的中心,并向右偏移 10px,向下偏移 20px
      lv_obj_align(obj1, LV_ALIGN_CENTER, 10, 20);
      
    • 参数说明:

      • align: 对齐方式枚举(如 LV_ALIGN_CENTER, LV_ALIGN_OUT_RIGHT_MID 等)。
      • x_ofs, y_ofs: 在对齐基础上的额外像素偏移。
  • lv_obj_align_to(obj, base, align, x_ofs, y_ofs)

    • 用法:将对象对齐到任意另一个对象 base 的特定位置。

    • 示例:

      // 将 obj2 放在 obj1 的右侧中间,间隔 10px
      lv_obj_align_to(obj2, obj1, LV_ALIGN_OUT_RIGHT_MID, 10, 0);
      
    • 特点:非常强大,用于构建复杂的相对布局(如按钮旁边的提示框)。

父布局自动定位

1. Flex 布局

 // 1. 设置父容器为 Flex 布局,方向为垂直排列
lv_obj_set_flex_flow(base_widget, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(base_widget, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
//会自动排序
lv_obj_t * obj1 = lv_obj_create(base_widget);
lv_obj_set_size(obj1, 100, 100);
lv_obj_t * obj2 = lv_obj_create(base_widget);
lv_obj_set_size(obj1, 100, 100);

2. Grid 布局 (网格)

// 定义列宽:两列,每列 50px
static lv_coord_t col_dsc[] = {50, 50, LV_GRID_TEMPLATE_LAST};
// 定义行高:两行,每行 50px
static lv_coord_t row_dsc[] = {50, 50, LV_GRID_TEMPLATE_LAST};

// 1. 设置父容器的网格描述(重要设置)
lv_obj_set_grid_dsc_array(base_widget, col_dsc, row_dsc);
// 占据第0列,第0行,跨度1列,跨度1行
lv_obj_set_grid_cell(obj1, LV_GRID_ALIGN_START, 0, 1, LV_GRID_ALIGN_START, 0, 1);
    
// 占据第1列,第0行,跨度1列,跨度1行
lv_obj_set_grid_cell(obj2, LV_GRID_ALIGN_START, 1, 1, LV_GRID_ALIGN_START, 0, 1);

3. 简单的自动间距 (Pad)

lv_obj_set_flex_flow(base_widget, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(base_widget, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
//设置子对象之间的间距
lv_obj_set_style_pad_row(base_widget, 40, 0);
//会自动排序 并且间距为40
lv_obj_t * obj1 = lv_obj_create(base_widget);
lv_obj_set_size(obj1, 100, 100);
lv_obj_t * obj2 = lv_obj_create(base_widget);
lv_obj_set_size(obj1, 100, 100);

大小

//设置宽度为100
lv_obj_set_width(w1,100);
//设置高度为50
lv_obj_set_height(w1,50);
//同时设置宽度和高度 100x50
lv_obj_set_size(w1,100,50);

//如果部件没有设置,会有默认的宽高。

样式

3种设置方式

  • lv_obj_set_style_xxx(obj,value ,selector);

    • obj: 指向要设置样式的 LVGL 对象(如 lv_obj_t *, lv_label_t * 等)的指针。

    • value: 要设置的具体属性值。类型取决于具体的属性(例如颜色是 lv_color_t,宽度是 lv_coord_t,不透明度是 lv_opa_t)。

    • selector: 样式选择器,用于指定该样式应用于对象的哪个部分或状态。

      • 0: 表示默认状态(主要部分)。
      • LV_PART_MAIN: 主体部分。
      • LV_PART_SCROLLBAR: 滚动条部分。
      • LV_PART_INDICATOR: 指示器部分(如进度条、滑块)。
      • LV_STATE_PRESSED: 按下状态。
      • LV_STATE_FOCUSED: 聚焦状态。
      • 可以使用位运算组合,例如 LV_PART_MAIN | LV_STATE_PRESSED。
lv_obj_t *base_widget = lv_scr_act();

lv_obj_t *label = lv_label_create(base_widget);
lv_label_set_text(label, "This is a label");
lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 0);

lv_obj_set_style_text_color(label, lv_palette_main(LV_PALETTE_RED), 0);
  • lv_obj_add_style(obj, &style, selector)

    • 必须要创建一个lv_style_t变量,并且设置成static
    static lv_style_t label_style;
    
    • 必须初始化,声明变量只分配了内存,但并没有“初始化”其内部逻辑结构
    lv_style_init(&label_style);
    
    • 开始为这个lv_style_t添加样式
    lv_style_set_text_color(&label_style, lv_palette_main(LV_PALETTE_RED));
    
    • 把这个样子附加到控件上,可以多个控件共用一个样式
    lv_obj_add_style(label1,&label_style,0);
    lv_obj_add_style(label2,&label_style,0);
    
  • 主题 / LV_STYLE_DEFAULT,完全不设置,走全局主题或默认值

样式其他函数

lv_style_t * style_def = lv_obj_class_get_style(&lv_label_class, 0);

部件

小部件由一个或多个部件构成。例如,一个按钮只有一个部件,称为 LV_PART_MAIN。然而,一个 Slider (滑动条)(lv_slider) 有 LV_PART_MAIN、LV_PART_INDICATOR和 LV_PART_KNOB。

状态

小部件可以处于以下状态的组合中:

  • LV_STATE_DEFAULT 正常,释放状态
  • LV_STATE_CHECKED: 切换或选中状态
  • LV_STATE_FOCUSED: 通过键盘或编码器聚焦或通过触摸板/鼠标点击
  • LV_STATE_FOCUS_KEY: 通过键盘或编码器聚焦,但不通过触摸板/鼠标
  • LV_STATE_EDITED: 通过编码器编辑
  • LV_STATE_HOVERED: 被鼠标悬停
  • LV_STATE_PRESSED: 正在被按下
  • LV_STATE_SCROLLED: 正在滚动
  • LV_STATE_DISABLED: 禁用

查看小部件状态

如果处于某种状态,会返回true

lv_obj_has_state(widget, LV_STATE_...)

添加或移除状态

lv_obj_add_state(widget, LV_STATE_...);
lv_obj_remove_state(widget, LV_STATE_...);

使用定时器3s后重置按钮状态

static void time_cb(lv_timer_t * timer)
{
    lv_obj_t * w = (lv_obj_t *)timer->user_data;
    lv_obj_clear_state(w, LV_STATE_PRESSED);
}
void lv_demo_widget_part_states(void)
{
    lv_obj_t * base_widget = lv_scr_act();
    lv_obj_t * btn = lv_btn_create(base_widget);
    lv_obj_set_size(btn, 100, 50);
    lv_obj_center(btn);
    // lv_obj_set_align(btn, LV_ALIGN_CENTER);
    lv_obj_add_state(btn, LV_STATE_PRESSED);//按钮按下状态
    
    lv_timer_create(time_cb, 3000, btn);
}

有一些 Widget 属性可以通过lv_obj_add_flag(widget, LV_OBJ_FLAG_...) 和 lv_obj_remove_flag(widget, LV_OBJ_FLAG_...) 来启用或禁用。

一些例子:

/* 隐藏 Widget */
lv_obj_add_flag(widget, LV_OBJ_FLAG_HIDDEN);

/* 使 Widget 不可点击 */
lv_obj_remove_flag(widget, LV_OBJ_FLAG_CLICKABLE);
  • LV_EVENT_VALUE_CHANGED当启用了 LV_OBJ_FLAG_CHECKABLE标志并且 Widget 被点击(在切换到/从选中状态过渡时)

LVGL的定时器执行定时回调时,主线程实际是停止的。

同时有多个定时器到期,它们会按照创建顺序或优先级依次执行。

定时器里面操作控件,但是控件已经被删除了。所以要删除定时器。

/*
 * 屏幕销毁时清理定时器
 * 避免 timer 继续更新已删除的 label
 */
static void on_master_del(lv_event_t *e)
{
    if (time_timer) {
        lv_timer_del(time_timer);
        time_timer = NULL;
    }
}
//添加时间回调,当screen发生了LV_EVENT_DELETE删除事件,执行on_master_del回调
lv_obj_add_event_cb(screen, on_master_del, LV_EVENT_DELETE, NULL);

按钮切换

//输入事件
lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_KEYPAD;
indev_drv.read_cb = evdev_read;
lv_indev_t *indev_t = lv_indev_drv_register(&indev_drv);
//三个按钮
lv_btn_t *b1 = lv_btn_create(lv_scr_act());
lv_btn_t *b2 = lv_btn_create(lv_scr_act());
lv_btn_t *b3 = lv_btn_create(lv_scr_act());
//三个按钮添加到同一个组
lv_group_t *group_page1 = lv_group_create();
lv_group_add_obj(group_page1,b1);
lv_group_add_obj(group_page1,b2);
lv_group_add_obj(group_page1,b3);
//设置组和输入事件绑定
lv_indev_set_group(indev_t, group_page1);

按钮事件映射

static uint32_t key_map(uint16_t code)
{
    switch (code) {
    	case KEY_W:  return LV_KEY_PREV; //上一个
    	case KEY_S:  return LV_KEY_NEXT; //下一个
    	case KEY_A:  return LV_KEY_ENTER; //确定
    	case KEY_D:  return LV_KEY_ESC; //返回/取消
    	default:     return 0;  /* 不关心的按键 */
    }
}

对于键盘/按键驱动的菜单,LVGL 社区和官方例子里最常用的就是 lv_list + Group 焦点 + 动态页面。没有其他内置控件更适合这个场景。

输入事件和组

lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_KEYPAD;
indev_drv.read_cb = evdev_read;
lv_indev_t *indev_t = lv_indev_drv_register(&indev_drv);
lv_group_t *g = lv_group_create(); //创建组
lv_indev_set_group(indev_t, g); //与输入事件绑定

list列表

lv_obj_t * list = lv_list_create(lv_scr_act());
lv_obj_set_size(list, LV_HOR_RES, LV_VER_RES);

lv_obj_t * btn_settings = lv_list_add_btn(list,NULL,"settings");
lv_obj_t * btn_language = lv_list_add_btn(list,NULL,"language");
lv_obj_t * btn_light = lv_list_add_btn(list,NULL,"light");
lv_obj_t * btn_about = lv_list_add_btn(list,NULL,"about");

lv_group_add_obj(g, btn_settings);
lv_group_add_obj(g, btn_language);
lv_group_add_obj(g, btn_light);
lv_group_add_obj(g, btn_about);

lv_obj_add_event_cb(btn_settings,ok_cb,LV_EVENT_CLICKED,NULL);
lv_obj_add_event_cb(btn_language,ok_cb,LV_EVENT_CLICKED,NULL);
lv_obj_add_event_cb(btn_light,ok_cb,LV_EVENT_CLICKED,NULL);
lv_obj_add_event_cb(btn_about,ok_cb,LV_EVENT_CLICKED,NULL);

按钮回调函数

void ok_cb(lv_event_t *e)
{
    lv_obj_t *btn = lv_event_get_target(e);
    const char *txt = lv_list_get_btn_text(lv_obj_get_parent(btn),btn);
    printf("clicked: %s\n", txt);
}

lv_menu 是给触摸屏设计的,自带 header 和动效,去不掉的东西确实多。

概念

菜单,一般一个项目只有一个menu对象。所有的页面放到这个对象下。

pages页面

页面挂载到菜单里面。

每个页面下的项

lvgl的所有空间widget都基于lv_obj_t。t表示类型,自定义类型。

lv_btn_create()
lv_lable_create()
//都(继承)lv_obj_t
//lv_obj_t是一个结构体,其实lv_btn_create() lv_lable_create()都是用于创建lv_obj_t结构体
//类似面向对象语言中的子类型转成父类型

widget创建:

lv_obj_t *w1 = lv_obj_create(lv_scr_act());
//这里的lv_scr_act()表示整块屏幕的根父类。
lv_obj_t *w2 = lv_obj_create(w1);
//这里的w2的父类是w1。

//	w2会随w1的位置移动而相对移动。

widget属性:

lv_obj_t *w1 = lv_obj_create(lv_scr_act());
  1. 大小

    //设置宽度为100
    lv_obj_set_width(w1,100);
    //设置高度为50
    lv_obj_set_height(w1,50);
    //同时设置宽度和高度 100x50
    lv_obj_set_size(w1,100,50);
    
    //如果部件没有设置,会有默认的宽高。
    
  2. 位置

    //坐标系跟电脑屏幕的是一样的,左上角为0,0 右下角为x,y。
    //部件默认是在父类对象的左上角
    //设置的是相对位置,不是绝对位置
    
    //设置x轴坐标
    lv_obj_set_x(w1,100);
    //设置y轴坐标
    lv_obj_set_y(w1,100);
    //同时设置x,y轴坐标
    lv_obj_set_pos(w1,100,100);//posation
    
  3. 对齐*

    //1.参照父对象对齐
    lv_obj_set_align(obj,LV_ALIGN_...);
    //参照父对象对齐,在进行偏移
    lv_obj_align(obj,LV_ALIGN_...,x,y);
    
    //2.参照其他对象对齐(无父子关系之间的对象)
    //参照其他对象对齐,再进行偏移
    lv_obj_align_to(obj_to_align,obj_referece,LV_ALIGN_...,x,y)
    
  4. 样式

    static lv_style_t style; //这里如果没有设置static 或者lv_style_t没有全局,会随方法栈丢失
    lv_style_init(&style);//必须先初始化
    //设置边框
    lv_style_set_border_color(&style, lv_color_hex(0x00FF00));
    
    //创建对象
    lv_obj_t *obj = lv_obj_create(lv_scr_act());
    //应用到对象
    lv_obj_add_style(obj, &style, LV_STATE_PRESSED);
    

    上面是最常用的方式,这种方式可以将样式和代码进行分离。单独一个文件存放样式。

    //直接设置
    lv_obj_t *obj2 = lv_obj_create(lv_scr_act());
    lv_obj_set_style_bg_color(obj2, lv_color_hex(0xFF0000),LV_STATE_DEFAULT);
    lv_obj_set_style_bg_opa(obj2, LV_OPA_50,LV_STATE_DEFAULT);
    lv_obj_set_style_bg_color(obj2, lv_color_hex(0x00FF00),LV_STATE_PRESSED);
    

    这种方式也有一定的应用。

不同部件的样式
lv_obj_t *slider = lv_slider_create(lv_scr_act());
lv_obj_align(slider, LV_ALIGN_BOTTOM_MID,0,-40);
//设置slider主体颜色
lv_obj_set_style_bg_color(slider, lv_color_hex(0xFF0000),LV_STATE_DEFAULT|LV_PART_INDICATOR);
lv_obj_set_style_bg_color(slider, lv_color_hex(0xFF0000),LV_STATE_DEFAULT|LV_PART_MAIN);
lv_obj_set_style_bg_color(slider, lv_color_hex(0xFF0000),LV_STATE_DEFAULT|LV_PART_KNOB);

5.事件

lv_obj_t *btn = lv_btn_create(lv_scr_act());
static char* my_data = "Hello World";
//不能这么写 LV_EVENT_CLICKED | LV_EVENT_LONG_PRESSED只会触发一个
lv_obj_add_event_cb(btn, lv_event_handler, LV_EVENT_CLICKED, my_data);
//这里的my_data可以传任意类型的指针
lv_obj_add_event_cb(btn, lv_event_handler, LV_EVENT_LONG_PRESSED, my_data);
static void lv_event_handler(lv_event_t *e)
{
  void *user_data = lv_event_get_user_data(e);
  // 根据实际类型进行转换和使用
  char *str = (char*)user_data;
  //判断事件类型
  lv_event_code_t code = lv_event_get_code(e);
  if(code == LV_EVENT_CLICKED)
  {
    //修改寄存器值
    printf("event_code: %d,%s\n",e->code,str);
  }else if( code == LV_EVENT_LONG_PRESSED) {
    //修改寄存器值
    printf("event_code: %d,%s\n",e->code,str);
  }
  //判断是否是父级对象
}

002. 字体和i18n

  1. 用AI将win系统所有字体导出为ttf文件、

    参考

  2. lvgl官方转换网站进行转换:Font Converter — LVGL

  3. 使用:

    1. 参考

    2. 将生成的xxx_fonts_20.c文件放到src/font目录下

    3. 在src/font/lv_font.mk下添加自己的CSRCS += xxx_fonts_20.c文件(防止Makefile没写)

    4. lv_conf.h文件中将

      #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(xxx_fonts_20) LV_FONT_DECLARE(yyy_fonts_20) // 可以添加多个
      
      /*Always set a default font*/
      #define LV_FONT_DEFAULT &lv_font_montserrat_14 //可以直接把自己的设置成默认
      
    5. 高版本8.0+的lvgl需要在生成的文字文件xxx_fonts_20.c注释掉,static_bitmap = 0(注释!!)

    6. 调用

      lv_obj_t *scr = lv_scr_act();
      lv_obj_t *label = lv_label_create(scr);
      lv_label_set_text(label, "你好世界");
      lv_obj_set_style_text_font(label, &xxx_fonts_20, 0);
      lv_obj_center(label);
      
      lv_obj_t *scr = lv_scr_act();
      lv_obj_t *label = lv_label_create(scr);
      static lv_style_t font_sytle; //定义一个样式
      lv_style_set_text_font(&font_sytle,&xxx_fonts_20);
      lv_obj_add_style(label,font_sytle,LV_STATE_DEFAULT);
      

字体对照表

导出自系统 C:\Windows\Fonts,共 53 个文件。


中文字体

显示名称文件名类型说明
宋体simsun.ttcTTC衬线体,最常用的中文字体
黑体simhei.ttfTTF无衬线体,粗壮醒目
微软雅黑msyh.ttcTTCWindows 默认无衬线字体
微软雅黑 粗体msyhbd.ttcTTC微软雅黑加粗版
微软雅黑 Lightmsyhl.ttcTTC微软雅黑细体版
楷体simkai.ttfTTF手写风格,类似楷书
仿宋simfang.ttfTTF仿宋风格,常用于公文
等线Deng.ttfTTF微软 Office 默认无衬线字体
等线 粗体Dengb.ttfTTF等线加粗版
等线 LightDengl.ttfTTF等线细体版
隶书SIMLI.TTFTTF隶书风格
幼圆SIMYOU.TTFTTF圆体风格
华文仿宋STFANGSO.TTFTTF华文字体 - 仿宋
华文楷体STKAITI.TTFTTF华文字体 - 楷体
华文隶书STLITI.TTFTTF华文字体 - 隶书
华文宋体STSONG.TTFTTF华文字体 - 宋体
华文细黑STXIHEI.TTFTTF华文字体 - 细黑
Noto Sans SCNotoSansSC-VF.ttfTTFGoogle 思源黑体可变版
细明体 ExtBmingliub.ttcTTC明体(香港/台湾地区常用)

英文字体

显示名称文件名类型说明
Arialarial.ttfTTF标准无衬线体
Arial Boldarialbd.ttfTTFArial 粗体
Arial Italicariali.ttfTTFArial 斜体
Arial Bold Italicarialbi.ttfTTFArial 粗斜体
Times New Romantimes.ttfTTF标准衬线体
Times New Roman Boldtimesbd.ttfTTFTimes New Roman 粗体
Times New Roman Italictimesi.ttfTTFTimes New Roman 斜体
Times New Roman Bold Italictimesbi.ttfTTFTimes New Roman 粗斜体
Courier Newcour.ttfTTF等宽衬线体
Courier New Boldcourbd.ttfTTFCourier New 粗体
Courier New Italiccouri.ttfTTFCourier New 斜体
Courier New Bold Italiccourbi.ttfTTFCourier New 粗斜体
Calibricalibri.ttfTTFOffice 2007+ 默认无衬线体
Calibri Boldcalibrib.ttfTTFCalibri 粗体
Calibri Italiccalibrii.ttfTTFCalibri 斜体
Calibri Bold Italiccalibriz.ttfTTFCalibri 粗斜体
Calibri Lightcalibril.ttfTTFCalibri 细体
Calibri Light Italiccalibrili.ttfTTFCalibri 细斜体
Cambriacambria.ttcTTCOffice 衬线体
Cambria Boldcambriab.ttfTTFCambria 粗体
Cambria Italiccambriai.ttfTTFCambria 斜体
Cambria Bold Italiccambriaz.ttfTTFCambria 粗斜体
Verdanaverdana.ttfTTF无衬线体,小字号清晰
Verdana Boldverdanab.ttfTTFVerdana 粗体
Verdana Italicverdanai.ttfTTFVerdana 斜体
Verdana Bold Italicverdanaz.ttfTTFVerdana 粗斜体
Tahomatahoma.ttfTTF无衬线体,Windows 经典 UI 字体
Tahoma Boldtahomabd.ttfTTFTahoma 粗体
Cascadia MonoCascadiaMono.ttfTTF微软等宽字体(编程用)
Comic Sans MScomic.ttfTTF手写风格
Comic Sans MS Boldcomicbd.ttfTTFComic Sans 粗体
Comic Sans MS Italiccomici.ttfTTFComic Sans 斜体
Comic Sans MS Bold Italiccomicz.ttfTTFComic Sans 粗斜体
Impactimpact.ttfTTF粗体字,标题用

文件名后缀说明

后缀含义
ttfTrueType 字体,单个样式
ttcTrueType Collection,多个字体合集
bdBold(粗体)
i / itItalic(斜体)
biBold Italic(粗斜体)
lLight(细体)
liLight Italic(细斜体)
zBold Italic 的简写(某些字体)
VFVariable Font(可变字体)

f1

  • Name(名称):生成的 C 语言结构体变量的名字(如填 arial_40,代码中就用 &arial_40 来引用)。建议:包含字体名和大小,方便区分。
  • Size(大小):字体的输出高度(像素)。注意,这里指的是字体的“身体”高度(通常指大写字母高度或 EM 框),并不是屏幕上的物理点阵,最终显示大小由这个值决定。
  • Bpp(每像素位数):抗锯齿级别。
    • 1 bpp:无抗锯齿(黑白分明,体积最小,边缘有锯齿)。
    • 2 bpp:4级灰度(常用,平衡体积与平滑度)。
    • 3 bpp:8级灰度(比2更平滑一点)。
    • 4 bpp:16级灰度(很平滑,体积较大)。
    • 8 bpp:256级灰度(最平滑,体积最大,通常用于带颜色信息的图标或特殊效果)。
  • Fallback(后备字体):当转换的字体里缺少某个生僻字时,系统会去这个指定的 LVGL 内置字体(如 lv_font_montserrat_24)里找替代字形。作用:防止显示“方框”乱码。
  • Output format(输出格式):选择 C file,生成 .c 文件直接放入工程编译;如果选 Bin 则生成二进制文件,需配合文件系统加载。
  • Enable Font compression(启用压缩):开启后使用 LZ4 压缩算法,字体文件体积会大幅减小(节省 Flash),但每次渲染显示字符时需要解压,会略微增加 CPU 耗时。Flash 紧张时建议开启。
  • Horizontal subpixel rendering(水平子像素渲染):利用液晶屏 RGB 像素排列来提高横向清晰度。开启后字体边缘更锐利,但生成的字体数据会变大,且只在特定屏幕排列下有效,一般不建议新手开启。
  • Try to use glyph color info...(尝试使用字形颜色信息生成灰度图标):针对彩色字体(如 Emoji 或图标字体)。因为 LVGL 常规字体只支持灰度(单色),开启后会尝试把彩色的字形信息“翻译”成灰度透明度蒙版。注意:它在纯色背景上效果尚可,在渐变或复杂背景上边缘可能会有黑边。

实现思路

  • 一个枚举存储语言类型
typedef enum {
    LANG_ZH,  // 中文
    LANG_EN,  // English
    LANG_MAX
} lang_t;
  • 一个全局变量存储 现在的语言类型。
static lang_t s_lang = LANG_EN;
  • get方法获取当前的语言类型
lang_t i18n_get_lang(void)
{
    return s_lang;
}
  • set方法设置当前语言类型
void i18n_set_lang(lang_t lang)
{
    if (lang < LANG_MAX)
        s_lang = lang;
}
  • 一个二维数组存储语言类型和真实文本(类比)

    • 第一个维度 存储 语言类型
    • 第二个维度 存储 文字key和文本value

    编译器在编译阶段,会做两件事:

    1. 把标识符替换成数字(枚举值或宏值)。

    2. 计算偏移量,把字符串的地址(指针)写入可执行文件的数据段(.rodata 或 .data)中对应的内存位置。

    3. enum { LANG_ZH = 0, LANG_EN = 1 };
      enum { settings_language = 0, settings_units = 1, ... };
      
static const char *table[LANG_MAX][STR_MAX] = {
    [LANG_ZH] = {
        [STR_ERROR_TIPS]    = "错误提示",
        [STR_TEMPERATURE]   = "温度"
    },
    [LANG_EN] = {
        [STR_ERROR_TIPS]    = "Error Tips",
        [STR_TEMPERATURE]   = "Temperature"
    },
};
  • 一个枚举列举当前拥有的文本key
typedef enum {
    STR_ERROR_TIPS,
    STR_TEMPERATURE,
    STR_DISTANCE,
    STR_TIME,
    STR_EMISSIVITY,
    STR_MAX
} str_key_t;
  • 对外提供获取文本的方法
const char *tr(str_key_t key)
{
    if (id >= STR_MAX)
        return "?";
    return table[s_lang][key];
}
  • 外部使用
//头文件引入i18n.h
int main(void)
{
    //获取文字
	char * error_tips = tr(STR_ERROR_TIPS);
    //切换语言
    i18n_set_lang(LANG_ZH);
    //再获取文字
    char * error_tips = tr(STR_ERROR_TIPS);
}

lvgl字体文件一般这样子命名

lv_font_xxx_20.c

xxx : 字体名称

20 : 字体大小

内容解析

/*******************************************************************************
 * Size: 20 px
 * Bpp: 2
 * Opts: --bpp 2 --size 20 --no-compress --stride 1 --align 1 --font simhei.ttf --symbols 设备 --format lvgl -o xxx_20.c
 ******************************************************************************/

#ifdef __has_include
    #if __has_include("lvgl.h")
        #ifndef LV_LVGL_H_INCLUDE_SIMPLE
            #define LV_LVGL_H_INCLUDE_SIMPLE
        #endif
    #endif
#endif

#ifdef LV_LVGL_H_INCLUDE_SIMPLE
    #include "lvgl.h"
#else
    #include "lvgl/lvgl.h"
#endif



#ifndef XXX_20
#define XXX_20 1
#endif

#if XXX_20

/*-----------------
 *    BITMAPS
 *----------------*/

/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
    /* U+5907 "备" */
    0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x2c, 0x0,
    0x0, 0x0, 0x0, 0xe0, 0x0, 0x0, 0x0, 0xf,
    0xff, 0xff, 0x40, 0x0, 0xbc, 0x0, 0x38, 0x0,
    0xb, 0x7c, 0x3, 0xc0, 0x0, 0x74, 0x3c, 0x3c,
    0x0, 0x0, 0x0, 0x3f, 0x80, 0x0, 0x0, 0x6,
    0xff, 0x90, 0x0, 0x5b, 0xfd, 0xb, 0xff, 0xe3,
    0xfe, 0x40, 0x1, 0xab, 0x40, 0x3f, 0xff, 0xff,
    0x80, 0x0, 0xe0, 0x34, 0xe, 0x0, 0x3, 0x80,
    0xe0, 0x34, 0x0, 0xf, 0xff, 0xff, 0xd0, 0x0,
    0x38, 0xd, 0x3, 0x40, 0x0, 0xe5, 0x79, 0x5d,
    0x0, 0x3, 0xff, 0xff, 0xf8, 0x0, 0xe, 0x0,
    0x0, 0xe0, 0x0,

    /* U+8BBE "设" */
    0x0, 0x0, 0x0, 0x0, 0x0, 0x7, 0x0, 0x15,
    0x54, 0x0, 0x7, 0xc0, 0x7f, 0xfc, 0x0, 0x1,
    0xe0, 0x70, 0x1c, 0x0, 0x0, 0x60, 0x70, 0x1c,
    0x0, 0x0, 0x0, 0xb0, 0x1c, 0x0, 0x0, 0x0,
    0xe0, 0x1d, 0x0, 0x3f, 0xc7, 0xc0, 0x1f, 0xf4,
    0x1, 0xc2, 0x0, 0x0, 0x0, 0x1, 0xc3, 0xff,
    0xff, 0x80, 0x1, 0xc1, 0xb5, 0x5b, 0x0, 0x1,
    0xc0, 0x34, 0xf, 0x0, 0x1, 0xc0, 0x3c, 0x1d,
    0x0, 0x1, 0xc8, 0xd, 0x38, 0x0, 0x1, 0xfd,
    0xb, 0xf0, 0x0, 0x1, 0xf4, 0x3, 0xe0, 0x0,
    0x3, 0xd0, 0x2f, 0xbd, 0x0, 0x1, 0x86, 0xf4,
    0xb, 0xf8, 0x0, 0xa, 0x40, 0x0, 0x60
};


/*---------------------
 *  GLYPH DESCRIPTION
 *--------------------*/

static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
    {.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
    {.bitmap_index = 0, .adv_w = 320, .box_w = 19, .box_h = 19, .ofs_x = 0, .ofs_y = -2},
    {.bitmap_index = 91, .adv_w = 320, .box_w = 20, .box_h = 19, .ofs_x = 0, .ofs_y = -2}
};

/*---------------------
 *  CHARACTER MAPPING
 *--------------------*/

static const uint16_t unicode_list_0[] = {
    0x0, 0x32b7
};

/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
    {
        .range_start = 22791, .range_length = 12984, .glyph_id_start = 1,
        .unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 2, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
    }
};



/*--------------------
 *  ALL CUSTOM DATA
 *--------------------*/

#if LVGL_VERSION_MAJOR == 8
/*Store all the custom data of the font*/
static  lv_font_fmt_txt_glyph_cache_t cache;
#endif

#if LVGL_VERSION_MAJOR >= 8
static const lv_font_fmt_txt_dsc_t font_dsc = {
#else
static lv_font_fmt_txt_dsc_t font_dsc = {
#endif
    .glyph_bitmap = glyph_bitmap,
    .glyph_dsc = glyph_dsc,
    .cmaps = cmaps,
    .kern_dsc = NULL,
    .kern_scale = 0,
    .cmap_num = 1,
    .bpp = 2,
    .kern_classes = 0,
    .bitmap_format = 0,
#if LVGL_VERSION_MAJOR == 8
    .cache = &cache
#endif

};



/*-----------------
 *  PUBLIC FONT
 *----------------*/

/*Initialize a public general font descriptor*/
#if LVGL_VERSION_MAJOR >= 8
const lv_font_t xxx_20 = {
#else
lv_font_t xxx_20 = {
#endif
    .get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt,    /*Function pointer to get glyph's data*/
    .get_glyph_bitmap = lv_font_get_bitmap_fmt_txt,    /*Function pointer to get glyph's bitmap*/
    .line_height = 19,          /*The maximum line height required by the font*/
    .base_line = 2,             /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
    .subpx = LV_FONT_SUBPX_NONE,
#endif
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
    .underline_position = -2,
    .underline_thickness = 1,
#endif
    .static_bitmap = 0,
    .dsc = &font_dsc,          /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
    .fallback = NULL,
#endif
    .user_data = NULL,
};



#endif /*#if XXX_20*/

1. 核心数据结构

lv_font_t – 字体对象

这是 LVGL 使用的字体描述符,包含:

  • get_glyph_dsc / get_glyph_bitmap:函数指针,用于获取字形描述符和位图数据。
  • line_height / base_line:行高和基线偏移。
  • dsc:指向具体的字体格式数据(lv_font_fmt_txt_dsc_t)。
  • 其他辅助字段(下划线、子像素等)。

在你的代码中:

c

const lv_font_t xxx_20 = {
    .get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt,
    .get_glyph_bitmap = lv_font_get_bitmap_fmt_txt,
    .line_height = 19,
    .base_line = 2,
    .dsc = &font_dsc,
    ...
};

lv_font_fmt_txt_dsc_t – 格式描述

包含:

  • glyph_bitmap:所有字形像素数据的连续存储区。
  • glyph_dsc:每个字形的度量数组(宽、高、偏移、在 bitmap 中的起始位置)。
  • cmaps:字符映射表(cmap),负责将 Unicode 编码映射到字形索引。
  • bpp:每个像素的比特数(此处为 2,即 4 级灰度)。
  • bitmap_format:0 表示未压缩。

2. 字形描述数组 glyph_dsc[]

每个字形对应的描述符(lv_font_fmt_txt_glyph_dsc_t)包含:

  • bitmap_index:在 glyph_bitmap 中的起始偏移(字节)。
  • box_w / box_h:字形实际占用的像素宽度和高度。
  • ofs_x / ofs_y:相对于字符原点的偏移(用于垂直或水平对齐)。
  • adv_w:推进宽度(即下一个字符的 x 坐标增量)。

你的代码中定义了两个字形(“备”和“设”):

c

static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
    {.bitmap_index = 0, ...},       // 保留
    {.bitmap_index = 0, .adv_w = 320, .box_w = 19, .box_h = 19, ...}, // "备"
    {.bitmap_index = 91, .adv_w = 320, .box_w = 20, .box_h = 19, ...} // "设"
};

索引 1 对应“备”,索引 2 对应“设”。bitmap_index 指明每个字形的像素数据在 glyph_bitmap 中的起始位置(第一个从 0 开始,第二个从 91 字节开始)。


3. 字符映射表(cmap)

用于将 Unicode 码点转换成 glyph index。当前使用 LV_FONT_FMT_TXT_CMAP_SPARSE_TINY 类型,即稀疏的紧凑存储:

  • range_start = 22791(即 0x5907,“备”的 Unicode)。
  • range_length = 12984(覆盖到 0x8BBE,恰好包含“设”)。
  • glyph_id_start = 1(glyph 索引从 1 开始)。
  • unicode_list 显式列出所有需要映射的字符码点(这里是两个:0x0 和 0x32B7,但注意到 0x0 是保留,第二个 0x32B7 对应“设”?实际上需要计算:0x5907 是“备”,0x8BBE 是“设”。在 sparse 方式中,unicode_list 列出相对于 range_start 的偏移:第一个偏移 0x0 对应 0x5907,第二个偏移 0x32B7(即 12983)对应 0x5907+0x32B7 = 0x8BBE,符合“设”。所以映射如下:
    • 查找字符 0x5907:在 range 内,偏移为 0,对应列表第 0 项,glyph_id = 1。
    • 查找字符 0x8BBE:偏移为 0x32B7,对应列表第 1 项,glyph_id = 2。

4. 位图数据 glyph_bitmap

存储的是所有字形的像素数据,按字形顺序依次放置。每个像素使用 2 位(BPP=2),即 4 级灰度(0~3)。数据按行连续排列,每行字节数由 box_w 和 BPP 决定:row_bytes = (box_w * bpp + 7) / 8,但 LVGL 内部有专门的解码函数。

你的 “备” 字形大小为 19×19,位图数据从偏移 0 开始,共 (19*2+7)/8 * 19 ≈ 5*19=95 字节,但实际 glyph_bitmap 中“备”占用 91 字节(因为第二个起始是 91),可能由于对齐或压缩,但无需深究。


5. 渲染流程(运行时)

当 LVGL 需要绘制文本时(例如调用 lv_label_set_text),会依次处理每个字符:

  1. 获取字符编码(例如 '备' = 0x5907)。
  2. 调用字体的 get_glyph_dsc(这里为 lv_font_get_glyph_dsc_fmt_txt),该函数会:
    • 遍历 cmaps,找到匹配的 cmap 条目(根据 range)。
    • 在 unicode_list 中查找偏移,确定 glyph index。
    • 从 glyph_dsc 数组中取出对应的描述符。
    • 若未找到,则返回空(可能用后备字符)。
  3. 调用 get_glyph_bitmap(这里为 lv_font_get_bitmap_fmt_txt),传入 glyph index,根据描述符中的 bitmap_index 从 glyph_bitmap 中提取原始像素数据。
  4. 渲染到画布:
    • 根据 box_w/box_h 和 ofs_x/ofs_y 确定在缓冲区中的绘制位置。
    • 逐像素读取位图数据(每像素 2 位),转化为颜色(通常将灰度映射到当前文本颜色透明度)。
    • 绘制到当前渲染缓冲区(lv_draw_ctx)。

此外,adv_w 用于更新下一个字符的绘制 x 坐标。


6. 相关宏与兼容性

代码中包含了版本检测宏(LV_LVGL_H_INCLUDE_SIMPLE),确保无论在独立 LVGL 环境还是 Arduino 等环境中都能正确包含头文件。同时,#if XXX_20 允许条件编译,方便在多个字体间切换。


7. 字体生成工具

这类 C 文件通常由 lv_font_conv(或在线转换工具)生成。输入 TTF/OTF 字体文件,指定字符集、大小、BPP、压缩选项,输出包含上述所有静态数据的 .c 文件。开发者只需在项目中包含该文件,并声明 extern const lv_font_t xxx_20,即可像使用内置字体一样使用它。


总结

LVGL 自定义字体通过 静态数据 + 函数回调 实现了高效、灵活的字形渲染:

  • 数据:位图、度量、映射表均在编译时生成,占用 ROM。
  • 接口:标准化的 get_glyph_dsc 和 get_glyph_bitmap 使得 LVGL 内核无需关心字形来源。
  • 性能:BPP 可选,支持灰度抗锯齿;cmap 采用稀疏索引,减少内存占用。

在开发过程中,一般需要不断新增新需要字文字。

而我们的自定义字体一开始不可能包含所有文字。

这个文件主要是学会:自定义的字体xxx.c中怎么添加一个新的文字。

lv_font_conv工具

  • 安装,官方web网站也是用这个工具
npm install lv_font_conv
lv_font_conv -v 
  • 执行命令
lv_font_conv \
	--bpp 2 \
	--size 20 \
	--no-compress \
    --font 'E:\fonts\simhei.ttf' \
    --symbols '你好沟槽的世界新' \
    --range 0-127 \
    --format lvgl \
    -o 'SimHei_20.c'
    
lv_font_conv ^
	--bpp 2 ^
	--size 20 ^
	--no-compress ^
	--font "E:\fonts\simhei.ttf" ^
	--symbols "你好沟槽的世界新" ^
	--range 0-127 ^
	--format lvgl ^
	-o "SimHei_20.c"
参数值含义与影响
--bpp 22设置每像素位数。2 表示 4 级抗锯齿(2^2=4),字体边缘会有淡淡的过渡灰阶。相比 bpp=4(16级)文件更小,比 bpp=1(无锯齿)更平滑,是嵌入式场景的折中之选。1,2,3,4,8几种可选
--size 2020输出字体的像素高度。生成的字模高为 20px,宽度按字符比例自动缩放。
--no-compress开启关闭 RLE 压缩。开启后位图数据会原样存储,文件体积会显著变大(尤其对汉字而言),但 LVGL 渲染时无需解压,读取速度更快(以 Flash 空间换 CPU 算力)。
--font路径指定源字体为黑体 (SimHei)。注意路径中的 C:\path\ 是占位符,实际执行时必须替换为真实路径(如 C:\Windows\Fonts\simhei.ttf)。
--symbols字符串显式白名单。仅包含 你好沟槽的世界新 这 10 个汉字。
--range 0-127ASCII额外包含基础 ASCII 码(英文字母、数字、标点)。这样你的 UI 既能显示英文,也能显示指定的中文。
--format lvgllvgl输出适配 LVGL 内部结构体(lv_font_t)的 C 数组格式。
-o文件名输出文件名为 SimHei_20.c。

python脚本

#!/usr/bin/env python3
"""向 lv_font_conv 生成的 LVGL 字体 C 文件增量添加字符。

用法(--font 输入字体、--out 输出文件为必填, 相对/绝对路径均可):
  python3 add_font_chars.py --font simhei.ttf --out lvgl/src/font/songti_20.c 新
  python3 add_font_chars.py --font /abs/path/a.ttf --out /abs/path/b.c 新 操 满   # 一次添加多个字
  python3 add_font_chars.py --font simhei.ttf --out out.c --gen generated.c 新     # 用官网生成的文件代替 lv_font_conv
--gen 是数据源替换选项:跳过本地 lv_font_conv,改用你自己准备的生成文件来提供新字的位图数据。

说明: 已存在于字体中的字符会自动跳过并打印日志, 不会报错。
     超出当前 cmap 范围的字符会自动扩展 range_start/range_length(中文字符的 rcp 必然在
     uint16 范围内, 无需新增 cmap)。
原理:
  1. 用 lv_font_conv 只生成新增字符的字形数据(与目标文件同 bpp/size/无压缩, 格式保证一致)
  2. 解析出每个新字的位图字节和字形参数(adv_w/box_w/box_h/ofs_x/ofs_y)
  3. 按 LVGL fmt_txt 规则拼入目标文件:
     - glyph_bitmap: 字节追加到数组末尾, bitmap_index = 当前总字节数
     - glyph_dsc:   按 glyph_id (= glyph_id_start + 在 unicode_list 中的下标)插入, 后续条目顺延
     - unicode_list: 保持升序插入(LVGL 二分查找要求), list_length 同步加
     - cmap:         range_start/range_length 按需自动扩展
  4. 自校验: 升序性 / 位图区间无重叠越界 / 字节数一致 / 字形映射正确
"""

import argparse
import bisect
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile

TOKEN_RE = re.compile(r'0x[0-9a-fA-F]+')
DSC_RE = re.compile(
    r'\.bitmap_index = (\d+),\s*\.adv_w = (\d+),\s*\.box_w = (\d+),\s*\.box_h = (\d+),\s*\.ofs_x = (-?\d+),\s*\.ofs_y = (-?\d+)')
GLYPH_COMMENT_RE = re.compile(r'/\* U\+([0-9A-Fa-f]{1,6}) "([^"]*)" \*/')
CMAP_FIRST_LINE_RE = re.compile(
    r'\.range_start = (\d+),\s*\.range_length = (\d+),\s*\.glyph_id_start = (\d+),')
LISTLEN_RE = re.compile(r'\.list_length = (\d+)')


def die(msg):
    print("错误: " + msg, file=sys.stderr)
    sys.exit(1)


def find_conv(explicit):
    if explicit:
        if os.path.isfile(explicit):
            return explicit
        die("找不到 lv_font_conv: %s" % explicit)
    env = os.environ.get("LV_FONT_CONV")
    if env and os.path.isfile(env):
        return env
    which = shutil.which("lv_font_conv")
    if which:
        return which
    here = os.path.dirname(os.path.abspath(__file__))
    for cand in ("/tmp/opencode/node_modules/.bin/lv_font_conv",
                 os.path.join(here, "node_modules/.bin/lv_font_conv")):
        if os.path.isfile(cand):
            return cand
    die("未找到 lv_font_conv。请先安装 (npm install lv_font_conv), 或用 --gen 提供已生成的 C 文件")


def run_conv(conv, ttf, size, bpp, symbols):
    tmpdir = tempfile.mkdtemp(prefix="lvfont_")
    out = os.path.join(tmpdir, "gen.c")
    cmd = [conv, "--bpp", str(bpp), "--size", str(size), "--no-compress",
           "--font", ttf, "--symbols", symbols, "--format", "lvgl", "-o", out]
    print("运行: " + " ".join(cmd))
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        die("lv_font_conv 执行失败:\n" + r.stdout + r.stderr)
    return out


def extract_array(text, decl):
    """定位数组 { 后内容起点, 返回 (内容起点, '\\n};' 的位置)"""
    m = re.search(re.escape(decl) + r'\s*\[[^\]]*\]\s*=\s*\{', text)
    if not m:
        die("在文件中找不到数组: " + decl)
    return m.end(), text.index("\n};", m.end())


def parse_gen_glyphs(text):
    """解析生成文件: 返回 [(code, char, bytes列表)] 按 unicode 升序"""
    start, end = extract_array(text, "glyph_bitmap")
    body = text[start:end]
    comments = list(GLYPH_COMMENT_RE.finditer(body))
    glyphs = []
    for i, m in enumerate(comments):
        code = int(m.group(1), 16)
        seg_start = m.end()
        seg_end = comments[i + 1].start() if i + 1 < len(comments) else len(body)
        tokens = TOKEN_RE.findall(body[seg_start:seg_end])
        glyphs.append((code, chr(code), [int(t, 16) for t in tokens]))
    glyphs.sort(key=lambda g: g[0])
    return glyphs


def parse_dsc(text):
    """解析 glyph_dsc 数组, 按数组顺序返回 dict 列表(含 id=0 保留项)"""
    start, end = extract_array(text, "glyph_dsc")
    body = text[start:end]
    return [dict(bitmap_index=int(m.group(1)), adv_w=int(m.group(2)),
                 box_w=int(m.group(3)), box_h=int(m.group(4)),
                 ofs_x=int(m.group(5)), ofs_y=int(m.group(6)))
            for m in DSC_RE.finditer(body)]


def parse_cmaps(text):
    """解析 cmaps, 返回 (稀疏cmap字典, 各字段在文本中的绝对位置)"""
    m = re.search(r'static const lv_font_fmt_txt_cmap_t cmaps\[\] =', text)
    if not m:
        die("文件中找不到 cmaps")
    end = text.index("\n};", m.end())
    seg = text[m.end():end]
    for fm in CMAP_FIRST_LINE_RE.finditer(seg):
        after = seg[fm.end():]
        nxt = re.search(r'\.range_start = \d+', after)
        tail = after if not nxt else after[:nxt.start()]
        if 'SPARSE' in tail:
            llen_m = LISTLEN_RE.search(tail)
            cmap = dict(range_start=int(fm.group(1)), range_length=int(fm.group(2)),
                        glyph_id_start=int(fm.group(3)), list_length=int(llen_m.group(1)))
            off = m.end()
            spans = {
                'range_start': (off + fm.start(1), off + fm.end(1)),
                'range_length': (off + fm.start(2), off + fm.end(2)),
                'glyph_id_start': (off + fm.start(3), off + fm.end(3)),
                'list_length': (off + fm.end() + llen_m.start(1), off + fm.end() + llen_m.end(1)),
            }
            return cmap, spans
    die("文件中没有 SPARSE 型 cmap")


def find_font_meta(text):
    bpp_m = re.search(r'\.bpp = (\d+)', text)
    lh_m = re.search(r'\.line_height = (\d+)', text)
    if not bpp_m or not lh_m:
        die("文件中找不到 .bpp 或 .line_height")
    return int(bpp_m.group(1)), int(lh_m.group(1))


def glyph_size(dsc, bpp):
    return math.ceil(dsc["box_w"] * dsc["box_h"] * bpp / 8)


def splice(target_path, glyphs, bpp):
    """glyphs: [(code, char, bytes列表, dsc)], 修改目标文件"""
    with open(target_path, "r", encoding="utf-8") as f:
        text = f.read()

    cmap, spans = parse_cmaps(text)
    rs, rl, gs = cmap["range_start"], cmap["range_length"], cmap["glyph_id_start"]

    bm_start, bm_end = extract_array(text, "glyph_bitmap")
    dsc_start, dsc_end = extract_array(text, "glyph_dsc")
    ul_m = re.search(r'unicode_list_1\[\] = \{', text)
    if not ul_m:
        die("目标文件中找不到 unicode_list_1")
    ul_end = text.index("\n};", ul_m.end())

    existing = [int(t, 16) for t in TOKEN_RE.findall(text[ul_m.end():ul_end])]
    dsc_body = text[dsc_start:dsc_end]
    dsc_entries = [(m.start(), m.end(), dict(bitmap_index=int(m.group(1)), adv_w=int(m.group(2)),
                                             box_w=int(m.group(3)), box_h=int(m.group(4)),
                                             ofs_x=int(m.group(5)), ofs_y=int(m.group(6))))
                   for m in DSC_RE.finditer(dsc_body)]
    if len(dsc_entries) != len(existing) + gs:
        die("解析异常: glyph_dsc 条目数(%d) 与 预期(%d) 不符" % (len(dsc_entries), len(existing) + gs))

    # ---- 自动扩展 cmap 范围 ----
    # 稀疏 cmap 的 unicode_list 偏移是 uint16_t(0-65535), 中文字符的 rcp 必然在此范围内,
    # 无需新增 cmap, 只需扩展 range_start(向下)/range_length(向上)
    new_rs = min(rs, min((code for code, ch, bts, dsc in glyphs), default=rs))
    delta = rs - new_rs
    if delta:
        existing = [v + delta for v in existing]
    rl_new = rl + delta

    # ---- 生成插入记录 ----
    records = []  # (k, rcp, code, char, bytes, dsc)
    for code, ch, bts, dsc in glyphs:
        rcp = code - new_rs
        if rcp < 0:
            die("字符 %s (U+%04X) 的 rcp=0x%x 为负, 无法放入 uint16 偏移" % (ch, code, rcp))
        if rcp > 65535:
            die("字符 %s (U+%04X) 的 rcp=0x%x 超过 uint16 上限, 需要新增独立 cmap(本脚本不支持)" % (ch, code, rcp))
        if rcp > rl_new:
            rl_new = rcp
        k = bisect.bisect_left(existing, rcp)
        if k < len(existing) and existing[k] == rcp:
            print("跳过: %s (U+%04X) 已存在于字体中" % (ch, code))
            continue
        expect = math.ceil(dsc["box_w"] * dsc["box_h"] * bpp / 8)
        if len(bts) != expect:
            die("字符 %s 位图字节数校验失败: 期望 %d, 实际 %d (生成参数与目标文件不一致?)" % (ch, expect, len(bts)))
        records.append((k, rcp, code, ch, bts, dsc))
    if not records:
        print("没有需要添加的字符")
        return []

    if new_rs != rs:
        print("cmap range_start 扩展: U+%04X → U+%04X" % (rs, new_rs))
    if rl_new > rl + delta:
        print("cmap range_length 扩展: %d → %d" % (rl, rl_new))
    rs = new_rs
    rl = rl_new

    # 多个新字可能落在同一区间: 按最终合并列表重新计算每个字的下标
    new_list = sorted(existing + [r[1] for r in records])
    pos_of = {v: i for i, v in enumerate(new_list)}
    records = [(pos_of[rcp], rcp, code, ch, bts, dsc) for k, rcp, code, ch, bts, dsc in records]
    records.sort(key=lambda r: r[0])

    # ---- 计算 glyph_id / bitmap_index ----
    total_bytes = len(TOKEN_RE.findall(text[bm_start:bm_end]))
    info = []
    for k, rcp, code, ch, bts, dsc in records:
        new_dsc = dict(dsc, bitmap_index=total_bytes)
        info.append((gs + k, rcp, code, ch, bts, new_dsc))
        total_bytes += len(bts)
    print("新增字符: " + ", ".join("%s → glyph_id #%d (bitmap 偏移 %d, %d 字节)" %
                                    (ch, gid, d["bitmap_index"], len(bts)) for gid, _, _, ch, bts, d in info))

    edits = []  # (pos, old_len, new_text), 按 pos 降序应用

    # ---- 1. glyph_bitmap: 末尾追加 ----
    ins = ""
    if text[bm_end - 1] != ',':
        ins += ","
    for idx, (gid, rcp, code, ch, bts, dsc) in enumerate(info):
        ins += "\n    /* U+%04X \"%s\" */\n" % (code, ch)
        lines = [bts[i:i + 8] for i in range(0, len(bts), 8)]
        last_global = (idx == len(info) - 1)
        for i, ln in enumerate(lines):
            comma = "," if (i < len(lines) - 1 or not last_global) else ""
            ins += "    " + ", ".join("0x%x" % b for b in ln) + comma + "\n"
    edits.append((bm_end, 0, ins))

    # ---- 2. glyph_dsc: 按 glyph_id 插入 ----
    # 锚点: 插入到"最终会落在下标 gid 的原条目"之前。
    # 原数组中被 gid 之前的插入挤占的条目, 其原下标 = gid - 之前插入数
    anchored = []  # (anchor_original_index, gid, dsc, ch)
    for i, (gid, rcp, code, ch, bts, dsc) in enumerate(info):
        anchored.append((gid - i, gid, dsc, ch))
    for anchor, gid, dsc, ch in sorted(anchored, key=lambda a: a[1], reverse=True):
        line = "    {.bitmap_index = %d, .adv_w = %d, .box_w = %d, .box_h = %d, .ofs_x = %d, .ofs_y = %d},\n" % (
            dsc["bitmap_index"], dsc["adv_w"], dsc["box_w"], dsc["box_h"], dsc["ofs_x"], dsc["ofs_y"])
        if anchor < len(dsc_entries):
            es, ee, _ = dsc_entries[anchor]
            line_start = dsc_body.rfind("\n", 0, es) + 1
            edits.append((dsc_start + line_start, 0, line))
        else:
            # 追加到数组末尾(每轮最多一个): 给上一行补逗号
            sep = "" if text[dsc_end - 1] == ',' else ","
            edits.append((dsc_end, 0, sep + "\n" + line))

    # ---- 3. unicode_list_1: 重写为升序 ----
    edits.append((ul_m.end() + 1, ul_end - ul_m.end() - 1, "    " + ", ".join("0x%x" % v for v in new_list)))

    # ---- 4. 稀疏 cmap 的 range_start / range_length / list_length ----
    edits.append((spans['range_start'][0], spans['range_start'][1] - spans['range_start'][0], str(rs)))
    edits.append((spans['range_length'][0], spans['range_length'][1] - spans['range_length'][0], str(rl)))
    edits.append((spans['list_length'][0], spans['list_length'][1] - spans['list_length'][0], str(len(new_list))))

    # ---- 5. 头注释 --symbols ----
    syms = "".join(chr(rs + v) for v in sorted(new_list))
    hdr_m = re.search(r'--symbols (\S+)', text)
    if hdr_m:
        edits.append((hdr_m.start(1), len(hdr_m.group(1)), syms))

    # ---- 应用编辑 ----
    for pos, old_len, new_text in sorted(edits, key=lambda e: e[0], reverse=True):
        text = text[:pos] + new_text + text[pos + old_len:]

    with open(target_path, "w", encoding="utf-8") as f:
        f.write(text)
    print("已写入: " + target_path)
    return info


def verify(target_path, added_chars, bpp, expected_dscs=None):
    with open(target_path, "r", encoding="utf-8") as f:
        text = f.read()
    errs = []

    cmap, _ = parse_cmaps(text)
    rs = cmap["range_start"]
    gs = cmap["glyph_id_start"]

    ul_m = re.search(r'unicode_list_1\[\] = \{', text)
    ul_end = text.index("\n};", ul_m.end())
    lst = [int(t, 16) for t in TOKEN_RE.findall(text[ul_m.end():ul_end])]
    if any(lst[i] >= lst[i + 1] for i in range(len(lst) - 1)):
        errs.append("unicode_list_1 不是严格升序")

    start, end = extract_array(text, "glyph_bitmap")
    total = len(TOKEN_RE.findall(text[start:end]))

    # C 语法粗查 1: 位图段去掉注释后, token 之间必须由逗号分隔
    body = re.sub(r'/\*.*?\*/', '', text[start:end])
    body = re.sub(r'\s+', '', body)
    if not re.fullmatch(r'0x[0-9a-fA-F]+(?:,0x[0-9a-fA-F]+)*', body):
        errs.append("glyph_bitmap 段 token 分隔有误(缺逗号?)")

    # C 语法粗查 2: glyph_dsc 每个条目必须是完整合法的单行结构
    dsc_start, dsc_end = extract_array(text, "glyph_dsc")
    dsc_sec = text[dsc_start:dsc_end]
    dsc_entry_re = re.compile(
        r'(?:\s*\{\.bitmap_index = \d+, \.adv_w = \d+, \.box_w = \d+, \.box_h = \d+, \.ofs_x = -?\d+, \.ofs_y = -?\d+\}'
        r'(?:\s*/\*.*\*/)?\s*,?\s*$|\s*$)')
    for i, line in enumerate(dsc_sec.splitlines(), 1):
        if not dsc_entry_re.match(line):
            errs.append("glyph_dsc 第 %d 行格式异常: %s" % (i, line.strip()[:60]))
    dscs = parse_dsc(text)
    ranges = []
    size_sum = 0
    for i, d in enumerate(dscs):
        if i == 0:
            continue  # 保留项
        size = math.ceil(d["box_w"] * d["box_h"] * bpp / 8)
        if d["bitmap_index"] < 0 or d["bitmap_index"] + size > total:
            errs.append("glyph_dsc[%d].bitmap_index=%d 越界 (总字节 %d)" % (i, d["bitmap_index"], total))
        ranges.append((d["bitmap_index"], d["bitmap_index"] + size))
        size_sum += size
    if size_sum != total:
        errs.append("glyph_bitmap 总字节数 %d, 字形字节和 %d, 不一致" % (total, size_sum))
    ranges.sort()
    for i in range(len(ranges) - 1):
        if ranges[i][1] > ranges[i + 1][0]:
            errs.append("字形位图区间重叠: %r 与 %r" % (ranges[i], ranges[i + 1]))

    for ch in added_chars:
        rcp = ord(ch) - rs
        if rcp not in lst:
            errs.append("字符 %s (rcp=0x%x) 未出现在 unicode_list_1" % (ch, rcp))
            continue
        gid = gs + lst.index(rcp)
        if gid >= len(dscs):
            errs.append("字符 %s 的 glyph_id %d 超出 dsc 数组" % (ch, gid))
            continue
        if expected_dscs and ch in expected_dscs:
            d = dscs[gid]
            e = expected_dscs[ch]
            for f in ("adv_w", "box_w", "box_h", "ofs_x", "ofs_y"):
                if d[f] != e[f]:
                    errs.append("字符 %s glyph_id %d 的 %s = %d, 期望 %d" % (ch, gid, f, d[f], e[f]))
                if f == "box_w" and d["box_w"] > 0 and d["box_h"] > 0 and d["bitmap_index"] + \
                        math.ceil(d["box_w"] * d["box_h"] * bpp / 8) > total:
                    errs.append("字符 %s glyph_id %d 位图越界" % (ch, gid))

    if errs:
        die("自校验失败:\n  " + "\n  ".join(errs))
    print("自校验通过: unicode_list 升序 / 位图区间无重叠越界 / 字节总数一致 / %d 个请求字符全部可查" % len(added_chars))


def main():
    ap = argparse.ArgumentParser(description="LVGL 字体增量加字脚本")
    ap.add_argument("chars", nargs="+", help="要添加的字符(可多个, 已存在的自动跳过)")
    ap.add_argument("--font", required=True, help="TTF 字体文件(必填, 相对/绝对路径均可)")
    ap.add_argument("--out", required=True, help="目标 C 文件(必填, 相对/绝对路径均可)")
    ap.add_argument("--conv", help="lv_font_conv 可执行文件路径")
    ap.add_argument("--gen", help="已生成的 C 文件(含新字形), 跳过 lv_font_conv")
    ap.add_argument("--size", type=int, default=None, help="字号(默认取目标文件 line_height)")
    ap.add_argument("--bpp", type=int, default=None, help="位深(默认取目标文件 .bpp)")
    args = ap.parse_args()

    out = os.path.abspath(args.out)
    ttf = os.path.abspath(args.font)
    if not os.path.isfile(out):
        die("目标文件不存在: " + out)
    if not args.gen and not os.path.isfile(ttf):
        die("字体文件不存在: " + ttf)

    with open(out, "r", encoding="utf-8") as f:
        text = f.read()
    bpp, line_height = find_font_meta(text)
    size = args.size or line_height
    bpp = args.bpp or bpp

    print("输入字体: %s (%dpx, bpp=%d)" % (ttf, size, bpp))
    print("输出文件: %s" % out)
    print("添加字符: %s" % " ".join(args.chars))

    symbols = "".join(dict.fromkeys(args.chars))
    if args.gen:
        gen_path = args.gen
        if not os.path.isfile(gen_path):
            die("生成文件不存在: " + gen_path)
    else:
        conv = find_conv(args.conv)
        gen_path = run_conv(conv, ttf, size, bpp, symbols)

    with open(gen_path, "r", encoding="utf-8") as f:
        gen_text = f.read()
    glyphs = parse_gen_glyphs(gen_text)
    dscs = parse_dsc(gen_text)
    if len(glyphs) != len(dscs) - 1:
        die("生成文件解析异常: 字形数 %d 与 dsc 数 %d 不匹配" % (len(glyphs), len(dscs) - 1))
    got = {ch for _, ch, _ in glyphs}
    missing = set(args.chars) - got
    if missing:
        die("以下字符在字体 %s 中不存在: %s" % (os.path.basename(ttf), "".join(sorted(missing))))

    glyphs = [(code, ch, bts, dscs[i + 1]) for i, (code, ch, bts) in enumerate(glyphs)]
    info = splice(out, glyphs, bpp)
    expected = {ch: dsc for gid, rcp, code, ch, bts, dsc in info}
    verify(out, list(dict.fromkeys(args.chars)), bpp, expected)
    print("完成: 新增 %d 个字符 → %s" % (len(info), out))


if __name__ == "__main__":
    main()

字符映射表(cmap)使用的是 LV_FONT_FMT_TXT_CMAP_SPARSE_TINY 类型,其核心原理是:

  • 通过 range_start(uint32_t)定位起始码点。
  • 通过 unicode_list(uint16_t 数组)存储相对于 range_start 的偏移量。
  • 因为偏移量是 16 位无符号整数,最大只能表示 65535。

如果字符码点为 0x1F600(😀),而 range_start 设为 0x0,则偏移量为 0x1F600 - 0x0 = 128512,远超 65535,无法存入 uint16_t,导致索引溢出或查找失败。

LVGL 字体格式中定义了另一种映射类型 LV_FONT_FMT_TXT_CMAP_SPARSE_FULL(相对于LV_FONT_FMT_TXT_CMAP_SPARSE_TINY),它与 TINY 的区别在于:

  • unicode_list 存储的是 完整的 32 位 Unicode 码点(绝对地址),而非 16 位偏移量。
  • 因此可以覆盖整个 Unicode 空间(0x0000 ~ 0x10FFFF)。

003. 按键切换菜单功能

嵌入式菜单功能,多层层级。项数据调节,各项功能点。

lvgl没有连接触摸屏功能时使用该功能

由于输入只有按钮。需要做到菜单切换等功能需要废一些力气。特地使用该功能。

如果不使用lvgl如何实现等

1.先用控制台做一次模拟以学习关于切换的基本概念。

2.使用0.96寸oled屏幕做一个菜单功能。

3.lvgl实现该功能。

单数组实现

只要在回调里面给到按下,弹起这个。

Linux

在linux下,一般按键输入被映射成linux内置的按键编码。比如说产品只有4个物理按钮,那么我们在写linux驱动的时候会把这这4个按钮映射为:上,下,ok,back。

所以,lvgl在linux下直接读取系统输入就好了。

lv_indev_drv_t indev_drv; //新增一个输入设备对象
lv_indev_drv_init(&indev_drv);//初始化这个设备对象,主要是分配内存等操作
indev_drv.type = LV_INDEV_TYPE_KEYPAD;//设置这个输入设备接受的是键盘类型
indev_drv.read_cb = evdev_read;// 回调,比较重要,lvgl会每隔一段时间去回调这个函数,判断是否有输入事件
lv_indev_drv_register(&indev_drv);//注册这个输入设备
static int evdev_fd = -1; //打开的linux系统中的某个输入事件,对应文件描述符
static uint32_t last_key = 0;//当前输入键对应在linux中的值
static lv_indev_state_t last_state = LV_INDEV_STATE_RELEASED;//按下类型
void evdev_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    (void)drv;

    data->key = last_key;//赋值为上一次的值
    data->state = last_state;//赋值为上一次的值,为了lvgl如果这次的值和上一次的值一样,那就判断没有新事件
    data->continue_reading = false;

    if (evdev_fd < 0) return;
    
    struct input_event ev;
    if (read(evdev_fd, &ev, sizeof(ev)) != sizeof(ev)) return;

    uint32_t k = ev.code;
   	//按下类型,长按2,短按1,松开0
    printf("key=%d, code=%d, val=%d\n", k, ev.code, ev.value);
    if (!k) return;

    /* 只处理按下(1)和弹起(0),忽略按住不放的重复事件(2) */
    if (ev.value != 0 && ev.value != 1) return;

    /* --- 驱动只需要给到lvgl按下松开就好。不需要提供长按 --- */
    last_key = k;
    last_state = ev.value == 1 ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; //按下或者松开

	//将这一次的按键值重新给到lvgl
    data->key = last_key;
    data->state = last_state;
    data->continue_reading = true;
}
  • continue_reading 作用:按照lvgl原来的方式是每次lv_timer_handler()之后才会重新调用我们提供的回调,现在这种情况,我们设置了continue_reading=true,立马又再次调用我们提供的回调,达到快速处理事件的效果

MCU

在mcu下由于没有像linux下的按键映射。

004. 主题

005. Label recolor

lv_obj_t *label = lv_label_create(parent);

lv_label_set_recolor(label, true);

lv_label_set_text(label,
    "普通文字 #FF0000 红色文字# 普通文字");

#FF0000 红色文字#

16进制颜色 空格 文字内容

099. Lvgl集成其他功能

ov5640摄像头,头文件

#ifndef OV5640_H
#define OV5640_H

#include <stdint.h>
#include <stdbool.h>

/* Camera image resolution */
#define CAMERA_WIDTH  640
#define CAMERA_HEIGHT 480

/**
 * Initialize the OV5640 camera via V4L2.
 * @param device The V4L2 device path (e.g., "/dev/video1").
 * @return 0 on success, -1 on failure.
 */
int ov5640_init(const char * device);

/**
 * Capture a frame from the camera.
 * @param buf Pointer to the buffer where the frame data will be stored.
 *            The buffer must be at least CAMERA_WIDTH * CAMERA_HEIGHT * 2 bytes (for RGB565).
 * @return 0 on success, -1 on failure.
 */
int ov5640_capture_frame(void * buf);

/**
 * Deinitialize the camera.
 */
void ov5640_uninit(void);

#endif /* OV5640_H */

ov5640摄像头,c文件

#include "ov5640.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/videodev2.h>
#include <errno.h>

#define BUFFER_COUNT 4

struct buffer {
    void *start;
    size_t length;
};

static int cam_fd = -1;
static struct buffer *buffers = NULL;
static unsigned int n_buffers = 0;

int ov5640_init(const char *device) {
    struct v4l2_capability cap;
    struct v4l2_format fmt;
    struct v4l2_requestbuffers req;
    struct v4l2_buffer buf;
    unsigned int i;

    /* 1. Open device */
    cam_fd = open(device, O_RDWR);
    if (cam_fd == -1) {
        perror("Opening video device");
        return -1;
    }

    /* 2. Check capability */
    if (ioctl(cam_fd, VIDIOC_QUERYCAP, &cap) == -1) {
        perror("Querying Capabilities");
        goto error;
    }

    if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
        fprintf(stderr, "%s is no video capture device\n", device);
        goto error;
    }

    /* 3. Set format (RGB565) */
    memset(&fmt, 0, sizeof(fmt));
    fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    fmt.fmt.pix.width = CAMERA_WIDTH;
    fmt.fmt.pix.height = CAMERA_HEIGHT;
    fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB565; // LVGL native format
    fmt.fmt.pix.field = V4L2_FIELD_INTERLACED;

    if (ioctl(cam_fd, VIDIOC_S_FMT, &fmt) == -1) {
        perror("Setting Pixel Format");
        goto error;
    }

    /* 4. Request buffers */
    memset(&req, 0, sizeof(req));
    req.count = BUFFER_COUNT;
    req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    req.memory = V4L2_MEMORY_MMAP;

    if (ioctl(cam_fd, VIDIOC_REQBUFS, &req) == -1) {
        perror("Requesting Buffers");
        goto error;
    }

    /* 5. Map buffers */
    buffers = calloc(req.count, sizeof(*buffers));
    for (n_buffers = 0; n_buffers < req.count; ++n_buffers) {
        memset(&buf, 0, sizeof(buf));
        buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        buf.index = n_buffers;

        if (ioctl(cam_fd, VIDIOC_QUERYBUF, &buf) == -1) {
            perror("Querying Buffer");
            goto error;
        }

        buffers[n_buffers].length = buf.length;
        buffers[n_buffers].start = mmap(NULL, buf.length,
                                        PROT_READ | PROT_WRITE, MAP_SHARED,
                                        cam_fd, buf.m.offset);

        if (buffers[n_buffers].start == MAP_FAILED) {
            perror("mmap");
            goto error;
        }
    }

    /* 6. Queue buffers */
    for (i = 0; i < n_buffers; ++i) {
        memset(&buf, 0, sizeof(buf));
        buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        buf.index = i;
        if (ioctl(cam_fd, VIDIOC_QBUF, &buf) == -1) {
            perror("Queueing Buffer");
            goto error;
        }
    }

    /* 7. Start streaming */
    enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    if (ioctl(cam_fd, VIDIOC_STREAMON, &type) == -1) {
        perror("Starting Streaming");
        goto error;
    }

    return 0;

error:
    ov5640_uninit();
    return -1;
}

int ov5640_capture_frame(void *dest_buf) {
    struct v4l2_buffer buf;
    memset(&buf, 0, sizeof(buf));
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;

    /* 1. Dequeue buffer */
    if (ioctl(cam_fd, VIDIOC_DQBUF, &buf) == -1) {
        if (errno == EAGAIN) return 0; // No frame yet
        perror("Dequeueing Buffer");
        return -1;
    }

    /* 2. Copy data to user buffer (assuming it's RGB565) */
    memcpy(dest_buf, buffers[buf.index].start, CAMERA_WIDTH * CAMERA_HEIGHT * 2);

    /* 3. Re-queue buffer */
    if (ioctl(cam_fd, VIDIOC_QBUF, &buf) == -1) {
        perror("Re-queueing Buffer");
        return -1;
    }

    return 0;
}

void ov5640_uninit(void) {
    if (cam_fd != -1) {
        enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        ioctl(cam_fd, VIDIOC_STREAMOFF, &type);
        for (unsigned int i = 0; i < n_buffers; ++i)
            munmap(buffers[i].start, buffers[i].length);
        free(buffers);
        close(cam_fd);
        cam_fd = -1;
    }
}

lvgl调用摄像头

static void camera_timer_cb(lv_timer_t * timer)
{
    lv_obj_t * canvas = (lv_obj_t *)timer->user_data;
    void * buf = lv_canvas_get_img(canvas)->data;
    ov5640_capture_frame(buf);
    lv_obj_invalidate(canvas);
}

if (ov5640_init("/dev/video1") == 0) {
    /* Create a canvas for camera display */
    static lv_color_t cbuf[CAMERA_WIDTH * CAMERA_HEIGHT];
    lv_obj_t * canvas = lv_canvas_create(lv_scr_act());
    lv_canvas_set_buffer(canvas, cbuf, CAMERA_WIDTH, CAMERA_HEIGHT, LV_IMG_CF_TRUE_COLOR);
    lv_obj_center(canvas);
    lv_obj_set_style_border_width(canvas, 2, 0);
    lv_obj_set_style_border_color(canvas, lv_palette_main(LV_PALETTE_RED), 0);
    /* Create a timer to update the camera feed */
    lv_timer_create(camera_timer_cb, 50, canvas); // 20 FPS
} else {
    printf("Failed to initialize camera /dev/video1\n");
}

移植

Vscode模拟lvgl环境

下载 https://github.com/Kitware/CMake/releases/download/v3.31.11/cmake-3.31.11-windows-x86_64.msi

如果vscode找不到cmake需要在

.vscode/settings.json文件下配置

{
    "cmake.cmakePath": "E:\\Program Files\\CMake\\bin\\cmake.exe"
}

如果运行后没有反应,将SDL2-2.30.1\x86_64-w64-mingw32\bin\SDL2.dll复制到示例中的bin文件夹(编译后生成)

移植通用流程

  1. 下载lvgl源码
https://github.com/lvgl/lvgl/
  1. 删除没有用的文件和文件夹

1

  1. 将lv_conf_template.h复制并改名为lv_conf.h

  2. 修改lv_conf.h中的#if 0为#if 1

  3. lvgl代码初始化流程:

    1. 设置分辨率和缓冲区大小。
    #define LV_HOR_RES_MAX 480   // 水平分辨率
    #define LV_VER_RES_MAX 320   // 垂直分辨率
    #define LV_BUF_SIZE (LV_HOR_RES_MAX * 5) // 缓冲区大小(行数)
    
    static lv_disp_draw_buf_t draw_buf;     // LVGL绘制缓冲区 静态分配内存?
    static lv_color_t buf1[LV_BUF_SIZE];    // 第一缓冲区
    static lv_color_t buf2[LV_BUF_SIZE];    // 第二缓冲区(双缓冲)
    
    lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_BUF_SIZE);
    
    1. 给lvgl提供心跳时钟
    lv_tick_inc(1); // 可以在定时中断或者linux定时函数中调用
    
    1. 注册显示驱动,为lvgl提供绘图函数
    static lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.hor_res = 480;//设置LVGL实际分辨率
    disp_drv.ver_res = 320;
    disp_drv.flush_cb = my_disp_flush;  // 注册刷新回调
    disp_drv.draw_buf = &draw_buf;
    lv_disp_drv_register(&disp_drv);
    
    1. 提供绘图函数给lvgl
    void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
    	//调用硬件进行画矩形或者画像素点
        
        // 通知LVGL绘画完成
        lv_disp_flush_ready(disp); 
    }
    
    1. 输入设备(伪代码)
    void evdev_read(lv_indev_drv_t * drv, lv_indev_data_t * data){
    	data->point.x = x;//具体的x坐标
        data->point.y = y;//具体的y坐标
        data->state = type;//按下的类型
    }
    static lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb = evdev_read;
    lv_indev_drv_register(&indev_drv);
    
    1. 在main里面调用lv_init(),在while(1)调用lv_timer_handler()。

    lv_timer_handler()LVGL调度函数,发现需要重绘,安排刷新,发现LVGL定时器完成处理定时回调,分发用户输入事件的回调。

MCU移植

ESP(arduino)

具体移步到

099_lvgl初始化基于esp8266.md

ESP(idf)

待写

常规mcu

待写

Linux移植

具体移步到

099_lvgl移植到imx6ull.md

099_lvgl移植linux-通用.md

示例代码

#include <Arduino_GFX_Library.h>
#include <lvgl.h>
#include <Ticker.h>
//定时器
Ticker lvglTicker;

// 定时器回调函数
void lvglTickCallback() {
  lv_tick_inc(1);  // 每毫秒增加一次
}

// ESP12E 引脚定义
#define TFT_CS   15   // GPIO15
#define TFT_DC   4    // GPIO4
#define TFT_RST  2    // GPIO2
#define TFT_BL   5    // GPIO5(背光控制)

// 使用硬件SPI
Arduino_DataBus *bus = new Arduino_ESP8266SPI(TFT_DC, TFT_CS);
Arduino_GFX *gfx = new Arduino_ILI9488_18bit(bus, TFT_RST); // 使用18位模
/* LVGL配置 */
#define LV_HOR_RES_MAX 480   // 水平分辨率
#define LV_VER_RES_MAX 320   // 垂直分辨率
#define LV_BUF_SIZE (LV_HOR_RES_MAX * 5) // 缓冲区大小(行数)

static lv_disp_draw_buf_t draw_buf;     // LVGL绘制缓冲区
static lv_color_t buf1[LV_BUF_SIZE];    // 第一缓冲区
static lv_color_t buf2[LV_BUF_SIZE];    // 第二缓冲区(双缓冲)
void initGFX(){
    // 复位显示屏
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, LOW);
  delay(100);
  digitalWrite(TFT_RST, HIGH);
  delay(200);

  // 初始化背光
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);  // 保持常亮

  // 初始化显示屏
  if (!gfx->begin(27000000)) { // 27MHz SPI速度
    Serial.println("Display init failed!");
    while(1); // 停止执行
  }
  // 设置横屏模式
  gfx->setRotation(1); // 1表示90度旋转,通常是横屏
  Serial.println("Display init success!");
}
/* 显示刷新回调函数 */
void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);
  //绘图
  //开始绘制以area->x1, area->y1为起点,颜色为color_p(这里包含了x1,y1,w,h的所有颜色值),终点为w,h的矩形
  gfx->draw16bitRGBBitmap(
    area->x1,        // x起始位置
    area->y1,        // y起始位置
    (uint16_t *)color_p, // 数据指针
    w,               // 宽度
    h                // 高度
  );
  lv_disp_flush_ready(disp); // 通知LVGL刷新完成
}
void lvgl_init(){
  lv_init();
  lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_BUF_SIZE);
  /* 注册显示驱动 */
  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res = LV_HOR_RES_MAX;//设置LVGL display的分辨率
  disp_drv.ver_res = LV_VER_RES_MAX;
  disp_drv.flush_cb = my_disp_flush;  // 注册刷新回调
  disp_drv.draw_buf = &draw_buf;
  lv_disp_drv_register(&disp_drv);
}
lv_obj_t *label;
void setup(void) {
  initGFX();
  // testGFX();
  lvgl_init();
  // 启动定时器,每1ms调用一次
  lvglTicker.attach_ms(1, lvglTickCallback);
    
  label = lv_label_create(lv_scr_act());
}

void loop()
{
  
  static char buffer[32];
  sprintf(buffer, "Hello World! counter = %d", counter);
  lv_label_set_text(label, buffer);
  counter++;

  lv_timer_handler();

  Serial.printf("Counter: %d\n", counter);
}

1.初始化显示器。

​ GFX初始化主要是为了给lvgl提供一个绘图函数,lvgl需要一个绘图函数。这时候lvgl会接管调用显示驱动。也就是我们主动调用绘图函数在屏幕上打点画图:(交给lvgl去做了)

//GFX 自己在屏幕上绘图
gfx->fillScreen(BLACK);//将屏幕设置成黑色
gfx->setTextColor(WHITE, BLACK);//字体颜色白色,背景黑色
gfx->setTextSize(2, 2);//字体大小
gfx->setCursor(30, 140);//所在位置
gfx->println("ILI9488 3.5\"");//开始打印字体在屏幕上

​ 可以给lvgl提供一个打点函数,也可以提供一个画矩形的函数

void Arduino_GFX::writePixel(int16_t x, int16_t y, uint16_t color)
void Arduino_GFX::draw16bitRGBBitmap(int16_t x, int16_t y,
                                     uint16_t *bitmap, int16_t w, int16_t h)

2.设置lvgl绘制缓冲区

2.1. 为什么需要设置绘制缓冲区?

LVGL是一个嵌入式图形库,通常运行在资源有限的微控制器上。它采用了一种部分刷新的策略,即每次只刷新屏幕的一部分,而不是整个屏幕(全屏刷新会消耗大量资源且速度慢)。

​为了高效地管理图形绘制,LVGL使用一个或多个缓冲区(称为绘制缓冲区)来暂存即将绘制到屏幕上的像素数据。这样,图形绘制操作可以在内存中完成,然后一次性将数据发送到显示设备,提高效率。

​如果没有缓冲区,每次绘制操作(比如画一个点)都可能直接操作显示设备(通过接口如SPI、并行接口等),这样效率极低,因为每次操作都会有通信开销。

2.2.缓冲区的作用

临时画布:存储即将渲染到屏幕的像素数据。
​图形加速:在内存中高效完成复杂绘制(如透明度混合、渐变)。
​同步控制:协调绘制与屏幕刷新时序,确保画面稳定。
​工作原理:LVGL在buf1渲染时,GPU同时从buf2读取数据到屏幕,交替使用避免视觉撕裂
​缓冲区大小:480*5=2400像素,每次渲染5行(平衡性能与内存)
static lv_disp_draw_buf_t draw_buf;     // LVGL绘制缓冲区
static lv_color_t buf1[LV_BUF_SIZE];    // 第一缓冲区
static lv_color_t buf2[LV_BUF_SIZE];    // 第二缓冲区(双缓冲)

3.lvgl初始化

void lvgl_init(){
  lv_init();  // 核心库初始化
  lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_BUF_SIZE);  // 双缓冲配置
  
  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);  // 驱动结构体初始化
  disp_drv.hor_res = 480;       // 设置水平分辨率
  disp_drv.ver_res = 320;       // 设置垂直分辨率
  disp_drv.flush_cb = my_disp_flush;  // 注册刷新回调
  disp_drv.draw_buf = &draw_buf;      // 绑定缓冲区
  lv_disp_drv_register(&disp_drv);    // 注册驱动
}
组件作用代码示例
lv_init()初始化LVGL内核数据结构(内存管理/定时器等)lv_init()
绘制缓冲区双缓冲机制防止撕裂lv_disp_draw_buf_init(&draw_buf, buf1, buf2, 2400)
显示驱动结构体承载显示设备的配置参数lv_disp_drv_t disp_drv
分辨率设置定义物理屏幕尺寸disp_drv.hor_res = 480
刷新回调注册连接LVGL渲染引擎与硬件驱动程序disp_drv.flush_cb = my_disp_flush
驱动注册将配置注入LVGL系统lv_disp_drv_register(&disp_drv)

4.回调函数

void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  // 1. 计算刷新区域尺寸
  uint32_t w = area->x2 - area->x1 + 1;
  uint32_t h = area->y2 - area->y1 + 1;
  
  // 2. 调用硬件加速绘图
  //开始绘制以area->x1, area->y1为起点,颜色为color_p(这里包含了x1,y1,w,h的所有颜色值),终点为w,h的矩形
  gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)color_p, w, h); 
  
  // 3. 通知LVGL刷新完成
  lv_disp_flush_ready(disp);
}
  1. 接收LVGL传递的矩形区域(area)和像素数据(color_p)
  2. 通过draw16bitRGBBitmap将RGB565数据写入显存
  3. 必须调用lv_disp_flush_ready()解除LVGL阻塞

​ 简单来说就是,lvgl要开始画图了。画的区域是x,y,w,h这个区域,颜色值为color。然后传递给GFX硬件绘图驱动去绘图。接着绘图完成之后调用lv_disp_flush_ready()解除LVGL阻塞。

5.*手动给lvgl定时器时钟。更新这个内部时钟计数器

#include <Ticker.h>
Ticker lvglTicker;
// 定时器回调函数
void lvglTickCallback() {
  lv_tick_inc(1);  // 每毫秒增加一次 
}
// 启动定时器,每1ms调用一次
lvglTicker.attach_ms(1, lvglTickCallback);

lv_tick_inc(1);

​ 这个必须要的,如果不给lvgl更新时钟计数器,lvgl所有工作都不能正常运行。

​ /手动提供LVGL tick //可以使用其他时钟源在#define LV_TICK_CUSTOM 0中开启

lv_timer_handler()

​ LVGL调度函数,发现需要重绘,安排刷新,发现LVGL定时器完成处理定时回调,分发用户输入事件的回调。

​

前置条件

  • 会使用Linux FrameBuffer

前往:上级目录的 Linux-Framebuffer编程.md

  • 会使用/dev/input/event*

前往:上级目录的 Linux-input-event.md

流程

  1. 下载lvgl源码
https://github.com/lvgl/lvgl/
  1. 删除没有用的文件和文件夹

1

  1. 将lv_conf_template.h复制并改名为lv_conf.h

  2. 修改lv_conf.h:

    1. lv_conf.h中的**#if 0**为#if 1,
    2. 修改lv_conf.h中LV_COLOR_DEPTH 色深
    3. 修改lv_conf.h中LV_MEM_CUSTOM开启自动分配内存
  3. 在src同级目录新建porting文件夹用来存放lvgl整合触摸和显示代码

LVGL v8.3.11 移植到正点原子 i.MX6ULL 指南

本文档记录了将 LVGL v8.3.11 移植到正点原子 i.MX6ULL Linux 开发板的详细步骤和配置说明。


1. 环境准备

  • 开发板: 正点原子 i.MX6ULL (ALPHA/MINI)
  • 操作系统: Linux (Ubuntu 18.04+ 推荐)
  • 交叉编译器: arm-linux-gnueabihf-gcc
  • 依赖库: 标准 C 库、pthread、math

2. 项目瘦身 (可选但推荐)

为了保持工程清爽,删除了以下不必要的文件和文件夹:

  • demos/, docs/, examples/, scripts/, tests/
  • .github/, env_support/
  • CMakeLists.txt, Kconfig, SConscript 等非 Makefile 构建文件

1


3. 核心配置 (lv_conf.h)

基于 lv_conf_template.h 创建 lv_conf.h 并进行了以下关键配置:

  • 启用内容: #if 1
  • 颜色深度: #define LV_COLOR_DEPTH 16 (匹配 i.MX6ULL 的 16 位 Framebuffer)
  • 内存管理:
    • #define LV_MEM_CUSTOM 1 (直接使用 Linux 的 malloc/free)
  • 系统 Tick:
    • #define LV_TICK_CUSTOM 1
    • 实现 custom_tick_get() 函数,通过 gettimeofday 获取毫秒级时间。
  • 日志系统:
    • #define LV_USE_LOG 1
    • #define LV_LOG_PRINTF 1 (通过 printf 输出调试信息)

4. 驱动实现 (porting/)

4.1 显示驱动 (Linux Framebuffer)

  • 文件: porting/fbdev.c, porting/fbdev.h
  • 设备: /dev/fb0
  • 实现:
    • fbdev_init(): 打开设备并使用 mmap 映射显存。
    • fbdev_flush(): 将 LVGL 渲染的缓存拷贝到显存对应区域。

4.2 输入驱动 (Linux evdev)

  • 文件: porting/evdev.c, porting/evdev.h
  • 设备: 默认 /dev/input/event1 (根据实际情况修改 EVDEV_NAME)
  • 实现:
    • evdev_read(): 解析触摸屏产生的 input_event 数据,转换为 LVGL 的坐标和按键状态。

5. 编译系统 (Makefile)

创建了支持交叉编译的 Makefile,关键配置如下:

  • 编译器: CC = arm-linux-gnueabihf-gcc
  • 包含路径: -I./
  • 编译标准: -std=gnu99 (解决 for 循环中定义变量的编译报错)
  • 链接库: -lm -lpthread
  • 核心规则: 包含 lvgl.mk 以自动引入 LVGL 源码。

6. 使用说明

6.1 编译

在项目根目录下执行:

make clean
make -j4

生成可执行文件 lvgl_demo。

6.2 运行

将 lvgl_demo 拷贝到开发板后运行:

chmod +x lvgl_demo
./lvgl_demo

7. 常见问题排查

  1. 编译报错 error: ‘for’ loop initial declarations...:
    • 确保 Makefile 中 CFLAGS 包含 -std=gnu99。
  2. 头文件找不到 lvgl/lvgl.h:
    • 由于项目瘦身,直接引用 #include "lvgl.h" 即可。
  3. 运行显示 cannot open framebuffer device:
    • 检查设备节点是否存在,或使用 sudo 运行程序。
  4. 触摸无响应:
    • 运行 cat /proc/bus/input/devices 确认触摸屏对应的 event 编号,并修改 porting/evdev.c 中的 EVDEV_NAME。

前言

本篇文章使用STM32CubeIDE进行开发,移植LVGL到STM32F103C8T6。F103C8T6刚好卡在LVGL能用的范围。

**开源地址:**https://gitee.com/wei-yuliu/stm32-f103-c8-t6-lvgl-stm32-cube-ide.git

一年前尝试过移植LVGL到STM32F103C8T6,各种问题,一直失败,放弃了。最近突然想起这件事,发现其实不难(兴许是当时能力还不够)。文章可能会有很多问题讲述不是很清楚,也可能有很多错误,欢迎指正!

挖个坑:

移植到Linux-x86:文章还没写

移植到Linux-ARM:文章还没写

学到什么

  • 使用STM32CubeIDE/CubeMX配置单片机的时钟,GPIO,外设,中断等。
  • 使用STM32Hal库代码编写,函数调用。
  • STM32CubeIDE/CubeMX项目配置,会合理工具使用会让编程事半功倍。
  • 屏幕厂商提供的示例,移植驱动到自己开发板。
  • 懂得LVGL移植的那点东西:
    • 裁剪&配置(lv_conf.h);
    • 提供时钟心lv_tick_inc(1)跳给lvgl;
    • 提供绘图函数LCD_DrawArea()给lvgl;
    • 时不时问问lvgl是否要刷新了lv_task_handler();
    • 写lvgl应用(main.c的例子);
  • 为后续Linux驱动开发,应用开发提供思维。我认为linux驱动和应用本质是硬件驱动提供接口给应用调用。你能移植设备厂商的代码了,跟应用开发人员(也还是你- -)规定一下接口需要什么参数,剩下的就不需要驱动工程师考虑的问题了,到应用开发人员头疼了(也还是你- -)。linux一堆规定,规定驱动要怎么写(mmp),规定提供给应用开发人员的接口(读写文件),这里就不深究了,后面移植到linux的时候再细细品味。

LVGL

下载

https://github.com/lvgl/lvgl/releases

stm32-lvgl-1


裁剪

stm32-lvgl-2

stm32-lvgl-3


STM32Cube IDE配置

项目配置

stm32-lvgl-4

stm32-lvgl-5

stm32-lvgl-6

stm32-lvgl-7

stm32-lvgl-8

stm32-lvgl-9

生成的目录结构

stm32-lvgl-10

配置LVGL目录

stm32-lvgl-11

stm32-lvgl-12

stm32-lvgl-13

移植屏幕厂商驱动测试

  • 移植屏幕驱动最重要的一点是:给LVGL提供绘图函数。

  • 由于每个人使用的屏幕不同,这里不写代码了,可以前往开原仓库中查看参考,地址:

画矩形函数:

void LCD_DrawArea(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t *colors);
  • 在Core文件夹新增Hardware文件夹
  • 新增lcd.h和lcd.c存放移植屏幕厂商代码

LVGL移植代码编写

裁剪lvgl功能

修改后的lv_conf.h文件可以前往:https://gitee.com/wei-yuliu/stm32-f103-c8-t6-lvgl-stm32-cube-ide.git,直接改一下LV_HOR_RES_MAX,LV_VER_RES_MAX就能用了。

  • 详细说明

在lv_conf.h文件(原lv_conf_template.h)做如下修改:

  1. #if 0改成#if 1:这个是开启lvgl,如果为0为不使用lvgl,为0整个.h文件失效

  2. 屏幕大小:LV_HOR_RES_MAX,LV_VER_RES_MAX设置成自己屏幕大小

  3. 颜色翻转:如果是SPI屏要设置LV_COLOR_16_SWAP为1

  4. 屏幕旋转(0,90°,180°,270°)缓冲区:LV_DISP_ROT_MAX_BUF调小一点,10u变成1u,测试没有用到屏幕旋转功能,LV_DISP_ROT_MAX_BUF很小都没事。

  5. LV_MEM_SIZE:内存池,32 * 1024(32KB)改成2 * 1024(2KB),RAM占用2KB,C8T6只有20KB,所以我们这里调小一点。这里就不得不说LVGL内存的两种分配方式了:

    1. 手动分配LV_MEM_CUSTOM为0时,一个大小固定的数组(2KB)
    2. 自动分配LV_MEM_CUSTOM为1时,由malloc分配,单片机不怎么使用。移植LVGL到linux这种带操作系统才会用。
  6. 功能裁剪:

功能模块修改后说明
LV_USE_ANIMATION0禁用动画
LV_USE_SHADOW0禁用阴影绘制
LV_USE_PATTERN0禁用矩形图案填充
LV_USE_VALUE_STR0禁用矩形上的数值字符串绘制
LV_USE_BLEND_MODES0禁用混合模式(仅正常混合)
LV_USE_OPA_SCALE0禁用整体透明度缩放
LV_USE_IMG_TRANSFORM0禁用图像旋转和缩放
LV_USE_API_EXTENSION_V60禁用 v6 API 兼容
LV_USE_API_EXTENSION_V70禁用 v7 API 兼容
LV_USE_DEBUG0完全禁用调试断言
  1. 对象裁剪:

    1. 保留对象:

      • LV_USE_BTN → 1
      • LV_USE_CONT → 1
      • LV_USE_LABEL → 1
    2. 裁剪掉的对象:

      对象修改后控件名称
      LV_USE_ARC0Arc(圆弧)
      LV_USE_BAR0Bar(进度条)
      LV_USE_BTNMATRIX0Button Matrix(按钮矩阵)
      LV_USE_CALENDAR0Calendar(日历)
      LV_USE_CANVAS0Canvas(画布)
      LV_USE_CHECKBOX0Checkbox(复选框)
      LV_USE_CHART0Chart(图表)
      LV_USE_CPICKER0Color Picker(颜色选择器)
      LV_USE_DROPDOWN0Dropdown List(下拉列表)
      LV_USE_GAUGE0Gauge(仪表盘)
      LV_USE_IMG0Image(图像)
      LV_USE_IMGBTN0Image Button(图像按钮)
      LV_USE_KEYBOARD0Keyboard(虚拟键盘)
      LV_USE_LED0LED(指示灯)
      LV_USE_LINE0Line(线条)
      LV_USE_LIST0List(列表)
      LV_USE_LINEMETER0Line Meter(线性仪表)
      LV_USE_OBJMASK0Object Mask(对象遮罩)
      LV_USE_MSGBOX0Message Box(消息框)
      LV_USE_PAGE0Page(页面)
      LV_USE_SPINNER0Spinner(加载动画)
      LV_USE_ROLLER0Roller(滚轮选择器)
      LV_USE_SLIDER0Slider(滑动条)
      LV_USE_SPINBOX0Spinbox(数值调节框)
      LV_USE_SWITCH0Switch(开关)
      LV_USE_TEXTAREA0Textarea(文本区域)
      LV_USE_TABLE0Table(表格)
      LV_USE_TABVIEW0Tabview(选项卡视图)
      LV_USE_TILEVIEW0Tileview(磁贴视图)
      LV_USE_WIN0Window(窗口)

配置LVGL

  • 在Core/Hardware文件夹下新增lvglc.h和lvglc.c
/*
 * lvglc.h
 */
#ifndef HARDWARE_LVGLC_H_
#define HARDWARE_LVGLC_H_
#include "lcd.h" //引入lcd驱动
#include "lvgl.h" //引入lvgl

void lvgl_init(void);
void lvgl_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p);
#endif
/*
 * lvglc.c
 */
#include "lvglc.h"

#define LV_BUF_SIZE (LCD_WIDTH * 5) // 缓冲区大小(行数)
static lv_disp_buf_t draw_buf;     // LVGL绘制缓冲区
static lv_color_t buf1[LV_BUF_SIZE];    // 第一缓冲区
/**
 * lvgl初始化函数
 *
 */
void lvgl_init() {
  lv_init();
  lv_disp_buf_init(&draw_buf, buf1, NULL, LV_BUF_SIZE);

  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res = LCD_WIDTH; //屏幕宽度
  disp_drv.ver_res = LCD_HEIGHT; //屏幕高度
  disp_drv.flush_cb = lvgl_disp_flush;//刷新回调
  disp_drv.buffer = &draw_buf; //缓冲区
  lv_disp_drv_register(&disp_drv);
}

/* 显示刷新回调函数 */
void lvgl_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {

  //绘图矩形这里调用的就是lcd驱动,也就是说,lcd只需要给lvgl提供一个绘图函数就可以了。这也就是为什么lvgl能移植到各种地方的原因之一
  LCD_DrawArea(area->x1, area->y1, area->x2, area->y2, (uint16_t*)color_p);

  lv_disp_flush_ready(disp); // 通知LVGL刷新完成
}

给lvgl提供时钟

  • stm32f103c8t6需要配置时钟和定时器为lvgl提供时钟
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) //定时中断,在main.c中直接定义这个函数,按需修改
{
	if(htim->Instance==TIM2)//如果是定时器2
		lv_tick_inc(1); //给lvgl提供时钟
	}
}

使用lvgl!

int main(void)
{
	//其他代码
	//....
	//....
	
	//启动定时器
	HAL_TIM_Base_Start_IT(&htim2);
	LCD_Init();//lcd初始化
  	lvgl_init();//lvgl初始化,lvglc.c中的函数
  	
  	lv_obj_t *btn = lv_btn_create(lv_scr_act(), NULL);  //创建按钮
  	lv_obj_set_size(btn, 120, 50);               // 宽度120像素,高度50像素
  	lv_obj_align(btn, NULL, LV_ALIGN_CENTER, 0, 0); // 居中显示
  	//设置按钮背景颜色
  	lv_obj_set_style_local_bg_color(btn, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, lv_color_hex(0x0000FF));
    //在while中调用lv_task_handler()
    while(1)
    {
        lv_task_handler();
        HAL_Delay(5);
    }
}

代码优化

  • 不开启代码优化是绝对超出flash的!

properties -> c/c++build -> settings -> optimization level设置成**-Os**

stm32-lvgl-14

编译烧录

stm32-lvgl-16

LVGL(Light and Versatile Graphics Library,轻量多功能图形库)是目前嵌入式领域最主流的开源图形库之一。它主要用于为资源受限的微控制器(MCU)和低性能微处理器(MPU)创建现代、美观且流畅的图形用户界面(GUI)。

下面从几个核心维度来介绍它:

1. 核心特点

  • 免费开源:采用 MIT 许可证,非常宽松,可以免费用于商业项目,无需公开代码。
  • 硬件要求低:最低仅需 几十 KB 的 RAM 和几百 KB 的 Flash 即可运行,非常适合单片机。
  • 现代化 UI 组件:内置超过 30 种基础控件,如按钮、滑块、图表、仪表、键盘、列表、图片、文本区域等,并支持动画、透明度、阴影和抗锯齿字体。
  • 强大的绘图能力:支持多种边框、渐变、圆角、阴影、不透明度等风格设置,无需额外图片就能实现毛玻璃、3D 感等视觉效果。
  • 事件驱动:采用类似 Qt 或网页前端的信号/槽机制,通过回调函数响应用户的点击、拖拽等输入。
  • 支持多种输入设备:可同时驱动触摸屏、鼠标、键盘、编码器、实体按钮等多种输入。
  • 多语言与 UTF-8:支持中、日、韩等语言显示,只需将字库文件包含进来即可。
  • 无需操作系统:可以跑在裸机程序上,也可以方便地移植到 FreeRTOS、RT-Thread 等实时系统上。

2. 基本工作流程

使用 LVGL 开发界面的大致步骤:

  1. 初始化:调用 lv_init() 并初始化显示驱动和输入设备驱动(如触摸屏)。
  2. 创建对象:在屏幕上创建各种控件(如 lv_btn_create 创建按钮,lv_label_create 创建文字标签)。
  3. 设置属性:调整控件的位置、大小、颜色、文字内容、样式等。
  4. 添加事件:为控件绑定回调函数,例如“点击按钮时 LED 闪烁”。
  5. 循环运行:在主循环中周期性调用 lv_timer_handler() 处理 LVGL 的内部任务(如动画、事件响应),间隔建议为 5-10 毫秒。

3. 硬件与性能

  • 典型配置:
    • Cortex-M3/M4 单片机(如 STM32F4 系列)或以上。
    • 约 64 KB RAM、200 KB Flash 可以跑基础界面。
    • 带帧缓冲区的显示屏(SPI、8080、RGB 接口均可)。
  • 性能优化:支持将部分运算或绘图交给 GPU、DMA2D 等硬件加速单元;支持双缓冲或部分刷新以减少内存占用。

4. 生态与工具

  • SquareLine Studio:官方推出的拖拽式 UI 设计器(类似 Qt Designer),可大幅提升开发效率。社区版有限制,付费版可商用。
  • 模拟器:可以在 Windows、Linux、macOS 上用 VS Code、Code::Blocks、Qt Creator 等直接模拟运行 LVGL 代码,无需硬件。
  • 大量移植示例:官方和社区提供了适配常见开发板(STM32、ESP32、NXP i.MX RT、Raspberry Pi)和显示屏驱动芯片的范例。
  • 绑定语言:除了 C 语言主库,还有 MicroPython、Arduino 语言、Rust 等绑定,方便不同背景的开发者。

5. 与其他 GUI 方案对比

方案特点适合场景对比 LVGL
emWin老牌商业库,稳定高可靠性商业产品需付费,界面风格较老旧
TouchGFX效果华丽,但硬件要求高STM32 高性能单片机与 STM32 绑定较紧,占用资源更多
Qt for MCU与 Qt 生态兼容想复用 Qt 技能的开发者商业授权,硬件要求比 LVGL 高
uGFX轻量,商业化收费极小资源设备社区活跃度和文档不如 LVGL

image-20250812232709242

lvgl配置时钟有两种方式:

  • lv_tick_inc(uint time);

    比较简单一般用在mcu

  • lv_conf.h文件中定义 LV_TICK_CUSTOM为1

    一般用在linux和RTOS。需要给LV_TICK_CUSTOM_SYS_TIME_EXPR提供一个函数

    #define LV_TICK_CUSTOM 1
    #if LV_TICK_CUSTOM
        #define LV_TICK_CUSTOM_INCLUDE <stdint.h>
        extern uint32_t custom_tick_get(void);
        #define LV_TICK_CUSTOM_SYS_TIME_EXPR (custom_tick_get())
    #endif  
    

    linux

    uint32_t custom_tick_get(void)
    {
        struct timeval tv;
        gettimeofday(&tv, NULL);
        static uint64_t start = 0;
        if (!start) start = tv.tv_sec*1000ULL + tv.tv_usec/1000;
        return (uint32_t)(tv.tv_sec*1000ULL + tv.tv_usec/1000 - start);
    }
    

Screen Manager

管理多个界面(Screen)的创建、切换与输入事件分发。LVGL 中推荐基于 lv_group_t 的原生事件机制(方式一),仅在资源极度受限且交互极简的场景下才考虑手动路由(方式二)。

方式一

利用 lv_group_t 接收输入事件,通过 lv_indev_set_group() 切换焦点,由 LVGL 事件循环自动派发 LV_EVENT_KEY。

lv_obj_add_event_cb(screen, key_cb, LV_EVENT_KEY, NULL);
lv_group_add_obj(group, screen);

detail.c

static lv_obj_t *screen;
static lv_group_t *group;
// 对完提供获取group
lv_group_t * detail_get_group(void)
{
    return group;
}
// 接收 LV_EVENT_KEY 的回调,在这里根据输入的按键处理业务需求
static void key_cb(lv_event_t *e)
{
    uint32_t key = lv_event_get_key(e);
    printf("key=%d\n", key);
}
// 创建界面
lv_obj_t * create_detail(void)
{
    screen = lv_obj_create(NULL);
    group = lv_group_create();
	//这里重要,screen
    lv_obj_add_event_cb(screen, key_cb, LV_EVENT_KEY, NULL);
    lv_group_add_obj(group, screen);
    return scr;
}

screen_manager.c

// 界面枚举,每个界面对应一个枚举
typedef enum {
    SCREEN_MASTER,
    SCREEN_SETTINGS,
    SCREEN_DETAIL,
    SCREEN_COUNT,
} screen_id_t;
// 界面结构体,
typedef struct {
    lv_obj_t *screen; // 界面screen本身
    lv_group_t *group;// group组
} screen_entry_t;
// 保存界面的数组
static screen_entry_t screens[SCREEN_COUNT];

// 初始化创建所有界面
void screen_mgr_init(void)
{
    screens[SCREEN_MASTER]  = (screen_entry_t){ create_master(),   master_get_group() };
    screens[SCREEN_SETTINGS]= (screen_entry_t){ create_settings(), settings_get_group() };
    screens[SCREEN_DETAIL]  = (screen_entry_t){ create_detail(),   detail_get_group() };
}
// 界面切换
void screen_mgr_switch(screen_id_t id) //传入screen_id_t枚举
{
    lv_scr_load(screens[id].scr);//切换时,加载一个界面
    lv_indev_set_group(indev_get(), screens[id].grp);//切换界面后,设置输入事件的焦点到这个界面的group
}

方式二

判断当前所在的界面,输入事件触发时调用这个界面的回调key_cb

detail.c

static lv_obj_t *scr;
// 只需要提供回调函数就好
void detail_key_cb(uint32_t key)
{
    printf("detail->key=%d\n", key);
    //处理
}

lv_obj_t * create_detail(void)
{
    scr = lv_obj_create(NULL);
	// 不需要任何操作
    return scr;
}

screen_manager.c

typedef enum {
    SCREEN_MASTER,
    SCREEN_SETTINGS,
    SCREEN_DETAIL,
    SCREEN_COUNT,
} screen_id_t;
static lv_obj_t *screens[SCREEN_COUNT];
static screen_id_t now_screen; //当前界面

void screen_mgr_init(void)
{
    screens[SCREEN_MASTER]  = create_master();
    screens[SCREEN_SETTINGS]= create_settings();
    screens[SCREEN_DETAIL]  = create_detail();
}

void screen_mgr_switch(screen_id_t id)
{
    lv_scr_load(screens[id]);//切换时,加载一个界面
    now_screen = id;
}
//获取当前界面
screen_id_t get_now_screen(void)
{
    return now_screen;
}
//界面路由处理
typedef void (*key_handler_t)(uint32_t);

static const key_handler_t key_handlers[SCREEN_COUNT] = {
    [SCREEN_MASTER]   = master_key_cb,
    [SCREEN_SETTINGS] = settings_key_cb,
    [SCREEN_DETAIL]   = detail_key_cb,
};

void screen_mgr_dispatch_key(uint32_t key)
{
    if (now_screen < SCREEN_COUNT && key_handlers[now_screen]) {
        key_handlers[now_screen](key);
    }
}

evdev.c

void evdev_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    data->key = last_key;
    data->state = last_state;
    
    struct input_event ev;
    if (read(evdev_fd, &ev, sizeof(ev)) != sizeof(ev))
        return;

    last_key = k;
    last_state = ev.value == 1 ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;

    if (ev.value == 1) {                        // ← 只处理按下
        screen_mgr_dispatch_key(k);
    }
}

Screen Stack

方式一:

方式二:

LVGL官方转换网站

Image Converter — LVGL

使用流程

下载一张图片。

进入到网站

LVGL8和LVGL9的转换区别是:LVGL9只能生成高指令的图片。

images

LVGL8使用

// 1. 声明图片变量
LV_IMG_DECLARE(my_image_name); 

// 2. 创建图像对象
lv_obj_t * img_obj = lv_img_create(lv_scr_act()); 

// 3. 设置图像源
lv_img_set_src(img_obj, &my_image_name); 

//用的时候必须自己上色:
lv_obj_set_style_img_recolor(img_obj, lv_color_white(), 0);

LVGL v8 与 v9 图像处理核心区别

除了使用方式,v8 和 v9 在图像处理的底层机制上也有显著差异。

特性LVGL v8LVGL v9
声明宏LV_IMG_DECLARELV_IMAGE_DECLARE
图像对象lv_img_...lv_image_...
图像描述符lv_img_dsc_t 结构体,包含 .header 等字段结构体简化,去除了冗余的 .header 封装,直接包含 cf, w, h, data_size 等字段
颜色格式枚举LV_IMG_CF_... (如 LV_IMG_CF_TRUE_COLOR_ALPHA)LV_COLOR_FORMAT_... (如 LV_COLOR_FORMAT_RGB565A8)
颜色深度lv_color_t 类型取决于 LV_COLOR_DEPTH 配置lv_color_t 始终为 RGB888,与显示颜色深度解耦
缓冲区大小单位像素单位 (buf_size_px)字节单位 (buf_size_byte)
转换工具兼容性官方在线转换器输出 v8 格式官方在线转换器的 v8 输出与 v9 不兼容,需使用 v9 工具或 Python 脚本

LVGL 本身会完成:

  • 触摸坐标命中检测;
  • 判断最上层可点击对象;
  • 按下、移动、释放;
  • 点击和长按;
  • 列表拖动滚动;
  • 按钮、开关、滑块状态变化;
  • 弹窗遮罩和控件层级处理。

我们只需要做两件事:

  1. 把触摸芯片坐标送给 LVGL;
  2. 给按钮注册 LV_EVENT_CLICKED 等事件。

触摸驱动

定时调用触摸驱动,判断是否有触摸和当前触摸的坐标

void Touch_Read(uint16_t *x, uint16_t *y)
{
	//触摸驱动
}

LVGL输入

LVGL 主动调用 read_cb,把内部 lv_indev_data_t 的地址放进来;回调里把坐标和按下状态写进 data->point / data->state,LVGL 返回后自己读走这些值。

static void touch_read_cb(lv_indev_drv_t *drv,lv_indev_data_t *data)
{
    uint16_t x;
    uint16_t y;
    bool pressed;
    (void)drv;

    pressed = Touch_Read(&x, &y);

    data->point.x = x;
    data->point.y = y;
    data->state = pressed ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
}

void touch_indev_init(void)
{
    static lv_indev_drv_t driver;

    lv_indev_drv_init(&driver);
    driver.type = LV_INDEV_TYPE_POINTER;
    driver.read_cb = touch_read_cb; // lvgl内部定时回调

    lv_indev_drv_register(&driver);
}

按钮

lv_obj_t *button = lv_btn_create(parent);
lv_obj_set_pos(button, 20, 60);
lv_obj_set_size(button, 120, 40);

static void button_clicked_cb(lv_event_t *event)
{
    /* 执行按钮业务 */
}

lv_obj_add_event_cb(button,button_clicked_cb,LV_EVENT_CLICKED,NULL); //点击事件

当用户触摸 (30, 70) 时,LVGL 会自动:

读取触摸坐标
→ 遍历当前页面对象树
→ 找到坐标下最上层的按钮
→ 发送 LV_EVENT_PRESSED
→ 触摸释放
→ 发送 LV_EVENT_RELEASED
→ 满足点击条件
→ 发送 LV_EVENT_CLICKED
→ button_clicked_cb()

应用层完全不需要写:

if (x >= 20 && x <= 140 &&
    y >= 60 && y <= 100) {
    /* 用户点了这个按钮 */
}
LVGL 页面
    ↓
UI Action / Controller
    ↓
应用服务
    ↓
驱动、协议、存储

无消息队列

按钮触发推送事件

static void button_clicked(lv_event_t *event)
{
    (void)event;
    AppAction_Post(APP_ACTION_START_MOTOR); //开启电机事件
}

事件处理函数

专门负责处理事件,所有的事件都有此进行分发。状态机的状态也在这里进行修改。

void AppAction_Process(const AppAction *action)
{
    switch (action->type) {
        case APP_ACTION_START_MOTOR: //接收到该事件进行处理
            Motor_Start();
            break;
    }
}

有消息队列结合状态机

一般使用Freertos的queue,不用自己写消息队列。

GUI层

gui/
├─ screens/         页面
├─ components/      可复用控件
├─ navigation/      页面路由
├─ model/           UI 显示状态
├─ actions/         用户操作
├─ theme/           样式
├─ assets/          字体图片
└─ port/            显示与输入适配

services/
├─ storage/
├─ camera/
├─ measurement/
├─ network/
└─ settings/
  • 只有 GUI 任务调用 LVGL;

  • 其他任务通过队列发送状态;

  • 页面不阻塞等待硬件;

  • 页面不保存硬件状态;

  • 动态页面按需创建和销毁;

  • 图片、字体和缓存统一管理。

  • Model(模型层):负责数据和业务逻辑。通常定义为struct,并提供一个数据变更的通知机制。
  • View(视图层):即LVGL的界面元素(lv_obj_t)。它不直接操作业务逻辑,而是通过数据绑定来展示数据,通过命令绑定来响应用户操作。
  • ViewModel(视图模型层):这是承上启下的核心。它从Model获取数据,并转换成View可以直接展示的形式;同时,它接收View的用户操作指令,并调用Model的业务方法。

AWTK-MVVM框架

手动实现

Mcu

ESP32 P4

001. USB摄像头

1. 声明组件依赖
2. Component Manager下载组件
3. menuconfig配置功能
4. 调用install/init安装驱动
5. 创建后台任务或事件循环
6. 注册Client或回调
7. 打开设备
8. 启动传输
9. 消费数据并归还缓冲
10. 停止、关闭、注销、卸载

USB Host、Wi-Fi、蓝牙、以太网、摄像头等经常这样设计。

开发板

ESP32-P4 USB HS 控制器
        │
        │ D+ / D-
        ▼
板载 USB Hub 上行端口
        │
        ├── 下行端口1 → USB摄像头
        ├── 下行端口2 → 外部USB接口
        └── 下行端口3 → 其他板载USB设备

Hub 会完成:

  • 为多个下行设备转发USB事务
  • 检测每个端口的插拔
  • 控制端口复位
  • 管理设备速度
  • 管理下行端口供电
  • 将多个设备汇聚到ESP32-P4的一个Host控制器

idf_component.yml文件

idf_component.yml属于哪个ESP-IDF组件,就放在那个组件的根目录下。

如:

project/
├── CMakeLists.txt
├── main/
│   ├── CMakeLists.txt
│   ├── idf_component.yml       # main组件的依赖
│   └── main.c
├── ports/
│   ├── CMakeLists.txt
│   ├── idf_component.yml       # ports组件的依赖
│   └── ...
└── managed_components/

如果ports/CMakeLists.txt中存在:

idf_component_register(
    SRCS ...
    INCLUDE_DIRS ...
)

ports就是一个ESP-IDF组件,它可以拥有自己的:

ports/idf_component.yml

ESP-IDF构建系统 + ESP-IDF Component Manager

idf.py reconfigure/build
        ↓
idf.py调用CMake
        ↓
ESP-IDF的project.cmake初始化项目
        ↓
扫描项目中的ESP-IDF组件
        ↓
Component Manager查找每个组件的idf_component.yml
        ↓
解析dependencies
        ↓
计算依赖关系和兼容版本
        ↓
读取/更新dependencies.lock
        ↓
下载缺少的组件到managed_components
        ↓
把下载的组件加入CMake构建

ports/idf_component.yml

dependencies:
  idf: ">=6.1"
  espressif/usb_host_uvc: "2.6.0"
  espressif/esp_h264: "1.4.1"
  espressif/esp_muxer: "1.2.3"
ESP-IDF Component Manager读取这个文件,下载:
  • usb_host_uvc
  • usb
  • H.264组件
  • MP4复用组件
  • 它们的传递依赖

下载结果放在:

managed_components/

也可以用命令添加依赖,例如:

idf.py add-dependency "espressif/usb_host_uvc^2.6.0"

项目配置

必须在sdkconfig配置
CONFIG_USB_HOST_HUBS_SUPPORTED=y

如果只有一级Hub,可以关闭多级Hub:

# CONFIG_USB_HOST_HUB_MULTI_LEVEL is not set

安装 ESP-IDF USB Host

这里会初始化:

  • ESP32-P4 USB 2.0 High-Speed控制器
  • USB PHY
  • USB Host协议栈
  • Root Hub:usb支持很多hub枚举,这里是根hub。板子高速USB口接了USB HUB
  • USB设备枚举能力
#include "usb/usb_host.h" //主要头文件
const usb_host_config_t config = {
    .skip_phy_setup = false,
    .intr_flags = ESP_INTR_FLAG_LEVEL1, //USB控制器产生的中断使用Level 1中断优先级。
    .peripheral_map = BIT1 //BIT0 → USB 2.0 High-Speed DWC控制器; BIT1 → Full-Speed USB控制器  默认的BIT0
};
esp_err_t err = usb_host_install(&config);
if (err != ESP_OK) {
    return err;
}

static usb_host_client_handle_t s_probe_client;

static void usb_probe_event(const usb_host_client_event_msg_t *event, void *arg)
{
    (void)arg;
    switch (event->event) {
    case USB_HOST_CLIENT_EVENT_NEW_DEV:
        inspect_usb_device(event->new_dev.address);
        break;
    case USB_HOST_CLIENT_EVENT_DEV_REMOVED:
        ESP_LOGI(TAG, "USB device address=%u removed", event->dev_removed.address);
        break;
    default:
        break;
    }
}
const usb_host_client_config_t probe_config = {
    .is_synchronous = false, //采用异步事件模式
    .max_num_event_msg = 4, 
    .flags = {
        .notify_dev_removed = 1,
    },
    .async.client_event_callback = usb_probe_event, //消息回调 上面的函数
    .async.callback_arg = NULL,
};
usb_host_client_register(&probe_config, &s_probe_client);

xTaskCreate(usb_library_task, "usb_lib", 4096, NULL, 15, NULL); //见下usb_library_task
usb_host_install安装的是整个USB Host系统
但是具体业务模块还需要作为Client注册进去。

USB Host事件处理任务

启动一个任务用来处理

这个任务负责处理:

  • USB设备插入
  • USB设备拔出
  • USB传输完成
  • Hub端口变化
  • 设备地址分配
  • 设备资源释放
xTaskCreate(usb_library_task, "usb_lib", 4096, NULL, 15, NULL)

static void usb_library_task(void *arg)
{
    (void)arg;
    for (;;) {
        uint32_t flags = 0;
        esp_err_t err = usb_host_lib_handle_events(portMAX_DELAY, &flags); //维护整个USB总线正常运行
        if (err != ESP_OK) {
            vTaskDelay(pdMS_TO_TICKS(100));
        } else if (flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) {
            (void)usb_host_device_free_all(); //Host栈准备退出时,释放仍由Host库管理的全部USB设备。
        }
    }
}
usb_host_lib_handle_events

它会处理:

  • USB控制器事件
  • USB传输完成
  • Root Hub端口变化
  • 外部Hub端口变化
  • 设备插入和拔出
  • 设备复位
  • 分配USB地址
  • 枚举状态机
  • 断开设备后的资源回收

如果不持续调用usb_host_lib_handle_events(),可能出现:

  • 设备不能完成枚举
  • 插拔事件不处理
  • 传输完成事件积压
  • 拔出后资源无法释放
  • Hub下游设备无法继续工作
static void usb_probe_task(void *arg)
{
    while (true) {
        esp_err_t err = usb_host_client_handle_events(
            s_probe_client,
            portMAX_DELAY
        );

        if (err != ESP_OK) {
            break;
        }
    }

    vTaskDelete(NULL);
}

Client的事件需要有人持续处理。

安装UVC Class驱动,识别摄像头并获取视频流

app/tasks/task_capture.c
			↓(uvc_port_start)
			↓
ports\uvc\uvc_port.c

uvc_port.c

esp_err_t uvc_port_start(uvc_frame_callback_t frame_cb,
                         uvc_state_callback_t state_cb,
                         void *ctx)
{
    s_callbacks = (callbacks_t){.frame_cb = frame_cb, .state_cb = state_cb, .ctx = ctx};
    s_frames = xQueueCreate(3, sizeof(uvc_host_frame_t *)); // uvc帧缓冲
    if (!s_frames) {
        return ESP_ERR_NO_MEM;
    }
    const uvc_host_driver_config_t config = {
        .driver_task_stack_size = 6144,//UVC驱动后台任务栈大小
        .driver_task_priority = 16, //UVC驱动后台任务的FreeRTOS优先级
        .xCoreID = tskNO_AFFINITY, //表示不固定运行在哪一个CPU核心上,由FreeRTOS调度器安排
        .create_background_task = true, //让官方UVC组件自己创建后台任务。
        .event_cb = driver_connected, //当UVC驱动识别到符合标准的摄像头时调用driver_connected
    };
    esp_err_t err = uvc_host_install(&config); //主要是这里,安装驱动
    if (err != ESP_OK) {
        return err;
    }
    if (xTaskCreate(stream_task, "uvc_stream", 8192, NULL, 12, NULL) != pdPASS) { //持续获取流的任务
        return ESP_ERR_NO_MEM;
    }
    return ESP_OK;
}

uvc_port_start该任务需要处理:

  • UVC设备连接
  • 描述符解析
  • 传输完成事件
  • UVC数据包
  • 帧组装
  • 断开事件
stream_task
static void stream_task(void *arg)
{
    (void)arg;
    const uvc_host_stream_config_t config = {
        .event_cb = driver_event, 
        .frame_cb = driver_frame,
        .user_ctx = &s_frames,
        .usb = {
            .dev_addr = UVC_HOST_ANY_DEV_ADDR,
            .vid = UVC_HOST_ANY_VID,
            .pid = UVC_HOST_ANY_PID,
            .uvc_stream_index = 0,
        },
        .vs_format = {
            .h_res = MEDIA_VIDEO_WIDTH,
            .v_res = MEDIA_VIDEO_HEIGHT,
            .fps = MEDIA_VIDEO_FPS,
            .format = UVC_VS_FORMAT_YUY2,
        },
        .advanced = {
            .number_of_frame_buffers = 3,
            .frame_size = MEDIA_VIDEO_WIDTH * MEDIA_VIDEO_HEIGHT * 2,
            .frame_heap_caps = MALLOC_CAP_SPIRAM,
            .number_of_urbs = 4,
            .urb_size = 16 * 1024,
        },
    };

    for (;;) {
        uvc_host_stream_hdl_t stream = NULL;
        ESP_LOGI(TAG, "Waiting for YUY2 %dx%d@%d UVC camera",
                 MEDIA_VIDEO_WIDTH, MEDIA_VIDEO_HEIGHT, MEDIA_VIDEO_FPS);
        esp_err_t err = uvc_host_stream_open(&config, pdMS_TO_TICKS(5000), &stream);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "Open failed: %s", esp_err_to_name(err));
            vTaskDelay(pdMS_TO_TICKS(2000));
            continue;
        }
        s_connected = true;
        if (s_callbacks.state_cb) {
            s_callbacks.state_cb(true, s_callbacks.ctx);
        }
        err = uvc_host_stream_start(stream);
        if (err != ESP_OK) {
            (void)uvc_host_stream_close(stream);
            s_connected = false;
            if (s_callbacks.state_cb) {
                s_callbacks.state_cb(false, s_callbacks.ctx);
            }
            continue;
        }

        while (s_connected) {
            uvc_host_frame_t *frame = NULL;
            if (xQueueReceive(s_frames, &frame, pdMS_TO_TICKS(500)) != pdPASS) {
                continue;
            }
            if (s_callbacks.frame_cb && frame->vs_format.format == UVC_VS_FORMAT_YUY2) {
                s_callbacks.frame_cb(frame->data, frame->data_len, s_callbacks.ctx);
            }
            (void)uvc_host_frame_return(stream, frame);
        }

        (void)uvc_host_stream_stop(stream);
        uvc_host_frame_t *frame = NULL;
        while (xQueueReceive(s_frames, &frame, 0) == pdPASS) {
            (void)uvc_host_frame_return(stream, frame);
        }
        (void)uvc_host_stream_close(stream);
        if (s_callbacks.state_cb) {
            s_callbacks.state_cb(false, s_callbacks.ctx);
        }
    }
}

002. 网口

E:_1\ESP32-P4\esp32-p4-jw03这个项目,我要将YUV数据通过web网页的方式将流解码显示到web页面上。注意,我们现在拿到的摄像头数据是YUV2,特有的码流8红外帧头+红外数据+YUV。我们要放到界面上的是YUV数据。

003. LCD屏幕

**驱动芯片:**ST7701P

**分辨率:**640x480

**支持RGB:**24bit、18bit、16bit。需要SPI对RGB进行初始化

MCU8080并口:

**MIPI-DSI接口:**通过DSI命令包初始化

模式初始化命令图像数据
RGB独立的CS/SCL/SDA串行接口RGB数据线+时钟/同步线
8080 MCU8080总线上的命令写入同一8080总线上的数据写入
MIPI-DSIDSI命令包DSI视频数据包
  • 交叉编译工具链:基于 GCC 的 Xtensa 或 RISC-V 架构交叉编译器,用于在 PC 上生成能在 ESP 芯片上运行的机器码。不同芯片系列对应不同的工具链前缀,例如 xtensa-esp32-elf(ESP32)或 riscv32-esp-elf(ESP32-C 系列)。

  • 构建系统:基于 CMake 和 Ninja。CMake 负责读取项目配置并生成构建文件,Ninja 则负责高效执行实际的编译和链接过程。

  • 令行前端 idf.py:这是开发者的主要操作入口。它封装了 CMake、Ninja 以及烧录工具 esptool.py,提供 idf.py build、idf.py menuconfig、idf.py flash 等简洁命令,极大地简化了项目构建、配置和烧录流程。

  • 其他工具:还包括用于调试的 OpenOCD 和用于下载/烧录的 esptool.py 等。

教程地址:

方式一:

在 Windows 上安装 ESP-IDF 及工具链 - ESP32 - — ESP-IDF 编程指南 latest 文档

方式二:(推荐)

在vscode下载esp-idf插件

之后会自动弹出esp-idf的安装引导。照着改就好了。

新建项目

ctrl+shift+p

输入ESP-IDF: new projet

1 2

helloworld

#include <stdio.h>

void app_main(void)
{
    printf("Hello, World!\n");
}

问题修改

在生成的项目目录下有:sdkconfig文件用来配置sdk的信息

修改版本信息
CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y
# CONFIG_ESP32P4_REV_MIN_0 is not set
# CONFIG_ESP32P4_REV_MIN_1 is not set
CONFIG_ESP32P4_REV_MIN_100=y
CONFIG_ESP32P4_REV_MIN_FULL=100
CONFIG_ESP_REV_MIN_FULL=100
CONFIG_ESP32P4_REV_MAX_FULL=199
CONFIG_ESP_REV_MAX_FULL=199
Bootloader CPU 时钟
-CONFIG_BOOTLOADER_CPU_CLK_FREQ_MHZ=100
+CONFIG_BOOTLOADER_CPU_CLK_FREQ_MHZ=90
移除 v1.x 不适用的电源管理选项
CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP
CONFIG_PM_CPU_RETENTION_DYNAMIC
CONFIG_PM_CPU_RETENTION_STATIC
CONFIG_PM_ESP_SLEEP_POWER_DOWN_CPU
CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU
移除不适用的 Flash suspend 参数
CONFIG_SPI_FLASH_SUSPEND_TRS_VAL_US
降低CPU频率
# default:
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360=y
# default:
# CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_400 is not set
# default:
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=360
MCU引脚SD卡引脚SPI引脚
GPIO44SD1_CMDMOSI
GPIO43SD1_CLKCLK
GPIO42SD1_D3CS
GPIO41SD1_D2
GPIO40SD1_D1
GPIO39SD1_D0MISO

ESP8266

ESP8266_RTOS_SDK: 由乐鑫官方推出的针对 ESP8266 的 SDK。

clone之后切换到最新版本

分支

获取交叉编译链,在官方SDK的readme文件中有说明

交叉编译器获取

点击高版本的Windows,会直接下载交叉编译器(xtensa-lx106-elf),但是解压会有权限问题。需要在linux下去做解压。

Proj MH2457QAUZ0D0

001. TM30和DCMI

96和240数据帧

一帧全屏测温+YUV 实时图像数据流由“测温头+全屏测温数据MT+YUV 数据”构成。 该码流下测温头固定为4640 字节,通过28 行(共5376 字节)进行填充,不足28 行 部分填充固定字节0xCC。

图片

96x96模式下

192 字节/行  ×  220 行  =  42,240 字节
     ↑             ↑
  行宽固定      28 + 96 + 96

220 行怎么来的

段行数每行字节小计作用
测温头281925,376(前4636+4B可用)帧头 + 温度极值/坐标
温度矩阵MT9619218,43296×96 个温度采样点
UYVY9619218,432伪彩后的可见光图像
合计22042,240

192 这个行宽是怎么定的

96 点  ×  2 字节/点  =  192 字节/行

UYVY 是每像素 16 位(U Y0 V Y1 四字节表示两个像素),所以 96 个像素就是 192 字节。三段的行宽都是 192,所以能拼成一个统一行宽的帧。

240x240模式下

364 行怎么来的

段行数每行字节小计作用
测温头2848013,440(前4636+4B可用)帧头 + 温度极值/坐标
温度矩阵MT9648046,08096×96 个温度采样点
UYVY240480115,200伪彩后的可见光图像
合计240174,720

MT解析

MT 每行就是 96 个采样点 = 192 字节(每点 2 字节)。240 模式下行宽被撑到 480,所以每行是:

[0, 192)    ← 96 个有效采样点
[192, 480)  ← 288 字节填充(这部分是"哑数据")

每行只取前 192 字节,串起来就是标准的 96×96 矩阵

YUV+RAW+附加行图像数据流

YUV+RAW+附加行图像数据流为

RAW 图像与 YUV 图像的上下拼接且各增加了 4 行附 加行信息。

图片

Digital Camera Interface(数字摄像头接口)

  • HSYNC(行同步):指示一行数据的开始和结束

  • VSYNC(帧同步):指示一帧数据的开始和结束

  • PIXCLK(像素时钟):由摄像头提供给主控芯片的同步时钟,每个时钟周期传输一个像素的数据

  • 数据总线D[0:13]:并行输入数据线,位宽可达14位,根据摄像头支持的像素深度传输数据

  • **XCLK/MCLK:**有些摄像头需要MCU/SOC提供基准时钟。有些摄像头自带晶振提供基准时钟。有些摄像头自带了晶振,但这个晶振只给摄像头外围电路提供时钟,没有给摄像头提供,还是要MCU/SOC提供基准时钟。

FIFO

第一步:摄像头如何“吐”数据?(物理层)

DVP摄像头有两条关键信号线:

  • PCLK(像素时钟):好比心跳,每跳一次,摄像头就吐出一个像素的数据。
  • 数据线(D0-D7):一个心跳周期内,线上传输1个字节(比如YUV格式的灰度值,或RAW RGB的低8位)。

假设你的屏幕分辨率是 640x480,那就是 307,200 个像素。摄像头会以极快的心跳(比如每秒 30 帧,PCLK 高达 24MHz~80MHz),把这 30 多万个像素一个一个地、像流水一样推出来。


第二步:DCMI 接收数据(接口层)

DCMI 的作用是接口协议解析。它负责在 PCLK、VSYNC(帧同步)、HREF(行同步)等信号的控制下,把这些串行(8位/10位/12位/14位)的像素数据捕获下来。

关键点来了: DCMI 收到一个字节,就把这个字节放在一个临时的寄存器里。但是,系统总线(AHB)不是随时随地都能被占用的,CPU或DMA可能正在忙别的。


第三步:FIFO 插入(核心缓冲层)

如果 DCMI 每收到一个字节,就立刻去请求总线写内存,会产生巨大的开销,系统会卡死。 FIFO(先进先出队列) 就是解决这个问题的**“数据蓄水池”**。

实际流程是这样的:

  1. 攒够一波再出发:DCMI 收到第1个字节,放进 FIFO 的第1格;收到第2个字节,放进第2格;收到第3个、第4个……
  2. 触发“批发”传输:当 FIFO 里攒够了 4个字(16个字节,即32bit x 4) 时,DCMI 会立刻向 DMA(直接内存访问控制器)发出一个请求:“我这里满了,快来一次性搬走这16个字节!”
  3. DMA 响应:DMA 收到请求,暂停一下当前工作,通过总线把这16个字节一口气搬运到你指定的内存缓冲区(SRAM)里。
  4. 清空蓄水池:搬走后,FIFO 里的数据被清空,腾出格子继续接收摄像头新吐出来的像素。

第四步:直到攒成一整帧(内存层)

摄像头持续吐像素,DCMI 持续往 FIFO 里塞,DMA 持续把 FIFO 里的数据搬到 SRAM。

  • 当 VSYNC(帧同步)信号告诉你“这一帧结束了”时,SRAM 里的那个缓冲区恰好被填满了 307,200 个像素的数据。
  • 此时,你内存里才有了这一帧完整的图像数据。
FIFO 的作用到底是什么?

如果去掉 FIFO:DCMI 收到一个字节,就叫 DMA 来搬一次。DMA 被频繁打断,搬数据的时间还没等请求的时间长,CPU和总线效率极其低下,甚至因为搬运太慢,下一个像素来了上一个还没搬走,导致数据覆盖丢失。

有了 FIFO(蓄水池): DCMI 把零散的字节拼装成 16 字节的数据包再发给 DMA。DMA 一次性搬走一大块。

4.1 电源、时钟位置和控制串口

TM30 信号MH2457 引脚当前配置方向(以 MCU 为准)作用
电源使能PH15普通推挽输出,无上下拉输出高电平给 TM30 上电
MCLK/XCLK 位置PC13模拟模式、无上下拉、AF 清零高阻当前不向 TM30 输出 9 MHz
控制 TXPA2USART2 AF7,上拉输出MCU 向 TM30 发配置命令
控制 RXPA3USART2 AF7,上拉输入MCU 接收 TM30 应答
控制串口参数—115200、8 数据位、无校验、1 停止位、无流控—只传控制协议

4.2 DVP/DCMI 图像引脚

按下面的固定顺序配置为 AF13、高速、无上下拉。这里保留的是当前板引脚,没有套用参考板引脚。

DVP 信号MH2457 引脚方向(以 MCU 为准)含义
PCLKPA6输入TM30 输出的像素采样时钟
HSYNCPH8输入行同步/行有效
VSYNCPB7输入帧同步
D0PC6输入像素数据 bit 0
D1PH10输入像素数据 bit 1
D2PC8输入像素数据 bit 2
D3PH12输入像素数据 bit 3
D4PH14输入像素数据 bit 4
D5PB6输入像素数据 bit 5
D6PI6输入像素数据 bit 6
D7PI7输入像素数据 bit 7
XCLK/MCLK无-由于TM30自己有时钟

DVP摄像头可以通过UART,IIC等进行摄像头内部通讯配置摄像头参数。

TM30红外摄像头

这款摄像头可以通过串口发送命令进行摄像头参数配置。

TM30 信号单片机引脚电气属性方向以 MCU 为准作用
控制 TXPA2USART2 AF7,上拉输出MCU 向 TM30 发配置命令
控制 RXPA3USART2 AF7,上拉输入MCU 接收 TM30 应答

初始化串口

USART_InitTypeDef serial = {0};
NVIC_InitTypeDef nvic = {0};
//开启时钟功能,复位USART2外设。
PeripheralEnable(PeripheralUSART2, true);
PeripheralReset(PeripheralUSART2);

//设置串口参数
serial.USART_BaudRate = 115200U; //波特率
serial.USART_WordLength = USART_WordLength_8b; //8位数据位
serial.USART_StopBits = USART_StopBits_1;// 1停止位
serial.USART_Parity = USART_Parity_No; //无校验
serial.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//无硬件流控
serial.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; //串口模式,同时启用接收和发送
//初始化串口
USART_Init(USART2, &serial); 
USART_Cmd(USART2, ENABLE);

//设置串口引脚复用和电气属性:USART2复用,内部上拉,GPIO高速模式,使用较低驱动强度配置
IOConfigStruct pins = MakeIOConfig(IOModeAlternate, GPIO_AF_USART2,
                                       IOPullUp, IOSpeedHigh, IODriveLow);
IOSetup(PA2, pins);
IOSetup(PA3, pins);

//发送数据
void TM30_Uart_Write(const uint8_t *data, uint16_t length)
{
    uint16_t index;
    for (index = 0U; index < length; ++index) {
        while (USART_GetFlagStatus(TM30_UART, USART_FLAG_TXE) == RESET) {}
        USART_SendData(TM30_UART, data[index]);
    }
    while (USART_GetFlagStatus(TM30_UART, USART_FLAG_TC) == RESET) {}
}

//接收数据用到了环形队列
/*
* 	USART收到字节
*	→ 触发RXNE中断
*	→ 字节存入环形队列
*	→ 协议层之后慢慢读取
*/

MCU和TM30串口数据交换流程

① 清空环形队列中的旧数据
② 向TM30发送读命令
③ 等待10ms
④ 调用receive_response()解析应答

TM30响应帧结构

代码按下面的方式解析响应:

F0 | SIZE | 36 | CMD_H | CMD_L | STATUS | PAYLOAD... | CHECKSUM | FF

响应和发送命令的主要区别是:

发送命令第4个正文内容:DIR
响应帧第4个正文内容:STATUS

含义:

STATUS含义
03命令执行成功
04TM30报告命令执行失败
其他未知或非法状态

假设TM30当前已经是Stream-8,它可能返回:

F0 05 36 7D 16 03 08 D4 FF

解释:

字节含义
F0响应开始
05正文长度5
36固定TAG
7D 16对应输出模式命令
03执行成功
08当前输出模式是Stream-8
D4校验和
FF响应结束

写入输出模式的正常ACK示例

写入完成后,如果响应没有额外payload,响应可能是:

F0 04 36 7D 16 03 CC FF

解释:

字节含义
F0帧头
04正文长度4
36TAG
7D 16对应命令
03写入成功
CC校验和
FF帧尾

校验:

36 + 7D + 16 + 03 = CC
DCMI_InitTypeDef dcmi = {0};
DCMI_CROPInitTypeDef crop = {0};

PeripheralEnable(PeripheralDCMI, true);
DCMI_CaptureCmd(DISABLE);
DCMI_Cmd(DISABLE);

static const IOEnum pins[] = {
    PA6, PH8, PB7, //plck像素时钟,行同步,帧同步
    PC6, PH10, PC8, PH12, PH14,PB6, PI6, PI7 //数据引脚
};
//复用功能AF13,无上下拉,高速,使用较低驱动强度配置
for (index = 0U; index < (sizeof(pins) / sizeof(pins[0])); ++index) {
    IOSetup(pins[index], MakeIOConfig(IOModeAlternate, GPIO_AF_DCMI,
                                      IOPullNone, IOSpeedHigh, IODriveLow));
}

dcmi.DCMI_CaptureMode = DCMI_CaptureMode_Continuous; //连续模式
dcmi.DCMI_SynchroMode = DCMI_SynchroMode_Hardware; //硬件同步
dcmi.DCMI_PCKPolarity = DCMI_PCKPolarity_Rising; //PCLK上升沿采样
dcmi.DCMI_VSPolarity = DCMI_VSPolarity_Low; //VSYNC低有效
dcmi.DCMI_HSPolarity = DCMI_HSPolarity_Low; //HSYNC低有效
dcmi.DCMI_CaptureRate = DCMI_CaptureRate_All_Frame; //每一帧都接收
dcmi.DCMI_ExtendedDataMode = DCMI_ExtendedDataMode_8b; //8位数据总线
dcmi.DCMI_ByteSelectMode = DCMI_ByteSelect_Mode_AllByte; //不跳字节
dcmi.DCMI_LineSelectMode = DCMI_LineSelect_Mode_AllLine; //不跳行
DCMI_Init(&dcmi); //确认配置

//TM30的42240字节不是随便切成192×220,它刚好对应协议结构。
// CROP的实际作用
// 	启用以后,每帧DCMI只接受:
// 	从第0行开始
// 	一共220行
// 	每行从第0个PCLK开始
// 	每行接受192个字节
crop.DCMI_HorizontalOffsetCount = 0U;
crop.DCMI_CaptureCount = (uint16_t)(192 - 1U);
crop.DCMI_VerticalStartLine = 0U;
crop.DCMI_VerticalLineCount = (uint16_t)(220 - 1U); //字节数,不是像素
DCMI_CROPConfig(&crop);
DCMI_CROPCmd(ENABLE);

给TM30供电

static void enable_power(void)
{
    GPIO_InitTypeDef output = {0};

    PeripheralEnable(PeripheralGPIOH, true);
    output.GPIO_Pin = GPIO_Pin_15;
    output.GPIO_Speed = GPIO_Speed_25MHz;
    output.GPIO_Mode = GPIO_Mode_OUT;
    output.GPIO_OType = GPIO_OType_PP;
    output.GPIO_PuPd = GPIO_PuPd_NOPULL;
    GPIO_Init(GPIOH, &output);
    GPIO_SetBits(GPIOH, GPIO_Pin_15);
}

初始化LTDC

参考:LTDC外设实现

其实就是分配一块内存当做显存,然后LTDC配置好之后会一直读这个显存然后一系列操作,比如ARGB8888裁成RGB666,输出到18根引脚上从而刷新屏幕。

lvgl初始化配置缓冲区

给两块内存地址给到lvgl

Buffer0:0x60000000
Buffer1:0x6004B000

每块显存大小:

240 × 320 × 4 = 307200字节 = 0x4B000
两块显存怎么交替工作

假设当前LTDC正在显示Buffer0:

LTDC:读取Buffer0,从屏幕顶部扫描到底部
LVGL:在Buffer1中绘制下一张完整画面

Buffer1绘制完成以后,LVGL调用:

display_flush()

里面执行:

LTDC_Layer1->CFBAR = published;
LTDC_ReloadConfig(LTDC_VBReload);

这里把LTDC下一帧要读取的地址设置为Buffer1。

但是不能立即切换,要等垂直消隐:

while ((LTDC->ISR & LTDC_ISR_RRIF) == 0U) {
}

垂直消隐到来后:

LTDC完成Buffer0这一帧
→ CFBAR切换到Buffer1
→ LTDC开始显示Buffer1
→ LVGL下一次可以改写Buffer0

配置DCMI

参考:001-z4-DCMI配置

取帧和截取


YUV数据转ARGB8888

略

96x96放大为240x240

略

LVGL绘制完成写入显存

略,lvgl内部直接做了,我们只需要给到地址lvgl就好了

切换LTDC的显存缓冲区地址

// 在lvgl驱动接口里面
// 在lvgl执行刷新操作时
TDC_Layer1->CFBAR = published;
LTDC_ReloadConfig(LTDC_VBReload);
TM30模组
  │
  │ DVP:PCLK/VSYNC/HSYNC/D0~D7
  ▼
DCMI外设
  │
  │ DCMI->DR
  ▼
DMA2 Stream7 Channel1
  │
  ├── RAW_BUFFER0
  └── RAW_BUFFER1
        │
        │ CPU解析温度、YUV转ARGB、96×96放大到240×240
        ▼
ARGB_STAGE0 / ARGB_STAGE1
        │
        │ FreeRTOS长度1队列交给GUI任务
        ▼
LVGL图片对象、文字、最高温/最低温标记
        │
        │ LVGL合成完整240×320界面
        ▼
DISPLAY_BUFFER0 / DISPLAY_BUFFER1
        │
        │ 垂直消隐期间切换LTDC CFBAR
        ▼
LTDC持续读取显存
        │
        │ RGB666共18根数据线 + PCLK/DE/HSYNC/VSYNC
        ▼
LCD屏幕

发送ISR切换到TM30硬件

bool TM30_Protocol_SetYuvResolution(uint16_t width, uint16_t height)
{
    uint8_t payload[7];
    bool ok;

    if (!((width == 96U && height == 96U) ||
          (width == 240U && height == 240U))) {
        return false;
    }
    payload[0] = (uint8_t)((width >> 16) & 0xFFU);
    payload[1] = (uint8_t)((width >> 8) & 0xFFU);
    payload[2] = (uint8_t)(width & 0xFFU);
    payload[3] = 0x00U;
    payload[4] = (uint8_t)((height >> 16) & 0xFFU);
    payload[5] = (uint8_t)((height >> 8) & 0xFFU);
    payload[6] = (uint8_t)(height & 0xFFU);
    if (!protocol_lock()) return false; //是否有其他业务在占用硬件通道
    ok = write_parameter(0x704AU, payload, sizeof(payload));//发送命令,发送硬件执行完成返回ok
    protocol_unlock();
    return ok;
}

读取是否写入成功(不用)

bool TM30_Protocol_ReadYuvResolution(uint8_t *payload, uint8_t *length)
{
    bool ok;

    if (payload == NULL) return false;
    if (!protocol_lock()) return false;
    ok = read_parameter(0x704AU, payload, length);//发送读取命令
    protocol_unlock();
    return ok;
}

修改DMA去适配

002. LTDC

连线

  • HSYNC(水平同步):用于指示一行数据的开始。

  • VSYNC(垂直同步):用于指示一帧数据的开始。

  • DE(数据使能):指示当前传输的数据是有效的。

  • CLK(时钟信号):用于同步数据传输。

  • hsync-active = 0 HSYNC低有效

  • vsync-active = 0 VSYNC低有效

  • de-active = 1 DE高有效

  • pixelclk-active = 0 使用反相像素时钟配置

  • BL(背光):一般是PWM背光

  • 数据引脚

    • RGB565 :16根数据线
    • RGB666 :18根数据线
    • RGB888 :24根数据线
  • SPI控制线。

一行由以下部分组成:

HSYNC 10
→ HBP 10
→ 有效像素240
→ HFP 38

一帧由以下部分组成:

VSYNC 4行
→ VBP 4行
→ 有效图像320行
→ VFP 8行
水平参数数字单位物理含义
HSYNCPCLK周期行同步脉冲宽度
HBPPCLK周期同步结束到有效像素开始之间的空白
HACTIVEPCLK周期/像素240个有效像素
HFPPCLK周期有效像素结束到同步开始之间的空白
阶段:  HSYNC      HBP           有效显示区             HFP
长度:  10PCLK    10PCLK           240PCLK           38PCLK
DE:      0          0                 1               0
RGB:    忽略       忽略       P0、P1……P239             忽略
SDRAM中的ARGB8888
        ↓
LTDC按照ARGB8888解析
        ↓
分离出A8、R8、G8、B8
        ↓
Alpha混合
        ↓
得到最终R8、G8、B8
        ↓
形成内部24根颜色信号
R7~R0、G7~G0、B7~B0
        ↓
GPIO只引出高18根
R7~R2、G7~G2、B7~B2
        ↓
屏幕收到RGB666

LTDC内部有24根独立电线:

红色:
R7 R6 R5 R4 R3 R2 R1 R0

绿色:
G7 G6 G5 G4 G3 G2 G1 G0

蓝色:
B7 B6 B5 B4 B3 B2 B1 B0

当前代码只做了:

R7 R6 R5 R4 R3 R2
G7 G6 G5 G4 G3 G2
B7 B6 B5 B4 B3 B2

这些信号的GPIO复用。

而:

R1 R0
G1 G0
B1 B0

没有进入屏幕。

所以不是LTDC检测到“接了18根线”,而是硬件设计者主动选择了高18位。

SDRAM帧缓冲
     ↓
LTDC AHB取数单元
     ↓
LTDC FIFO
     ↓
像素格式转换
     ↓
Layer1 / Layer2处理
     ↓
Alpha混合
     ↓
背景色
     ↓
可选Dither
     ↓
R/G/B输出通道
     ↓
GPIO复用
     ↓
屏幕RGB引脚

Alpha不通过RGB屏引脚传输。

Alpha只用于LTDC内部图层混合:

Layer1颜色 × Alpha
+
Layer2或背景颜色 × (1-Alpha)
  • 内部 SRAM:0x20000000 起,大小 0x140000 = 1.25MB。片上 RAM。

  • 外部 SDRAM:0x60000000 起 = 8MB。

LTDC

LTDC = LCD-TFT 显示控制器,一个专用显示外设,用它来配置屏幕的各项连接数据。他还能直接将一段内存通过DMA直接渲染到界面上。

它:

  • 用内部计数器自己产生 VSYNC/HSYNC/DE/PCLK 时序
  • 作为总线主设备,按像素时钟不断从显存(CFBAR)取像素发到屏
  • 做图层混合(Layer1/Layer2)、透明度、RGB565/RGB888 格式
  • 用影子寄存器 + 消隐期重载实现无撕裂换帧

屏幕参数

enum {
    LCD_WIDTH = 240U, //屏幕像素
    LCD_HEIGHT = 320U,//屏幕像素
    
    LCD_HSYNC = 10U, //行同步信号
    LCD_VSYNC = 4U,//帧同步信号
    
    LCD_HBACK_PORCH = 10U, //
    LCD_VBACK_PORCH = 4U,
    
    LCD_HFRONT_PORCH = 38U,
    LCD_VFRONT_PORCH = 8U
};

配置时钟

LTDC提供了多个4时钟源:PLL1R,PLL2P,PLL2Q,PLL2R

LTDC-clock-tree

static void pixel_clock_init(void)
{
    /*
 	* PLL2复用PLL1M预分频后的1MHz参考时钟:
 	* PLL2 VCO = 1MHz × 84 = 84MHz;
 	* PLL2R    = 84MHz ÷ 12 = 7MHz。
 	*/
    ClockEnable(ClockNodePLL2G, false);//先关闭时钟
    ClockMultiply(ClockNodePLL2, 84U); //ClockMultiply 倍频x84 = 84MHz
    ClockDivide(ClockNodePLL2R, 12U); //ClockDivide 分频为÷12 = 7MHz
    ClockEnable(ClockNodePLL2G, true);//配置好后再开启时钟
    while (RCC_GetFlagStatus(RCC_FLAG_PLL2RDY) == RESET) {//等待PLL2锁定
    }
    ClockSelect(ClockNodeDPC, ClockNodePLL2R);//选择ClockNodePLL2R作为LTDC时钟输入
}

配置io脚

static void ltdc_gpio_init(void)
{
    GPIO_InitTypeDef gpio;

    PeripheralEnable(PeripheralGPIOA, true);
    /*省略GPIOB-H*/
    PeripheralEnable(PeripheralGPIOI, true);
    PeripheralEnable(PeripheralPCFG, true);
	//配置电气特性
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    gpio.GPIO_Mode = GPIO_Mode_AF;
    gpio.GPIO_OType = GPIO_OType_PP;
    gpio.GPIO_PuPd = GPIO_PuPd_NOPULL;
	
    /*配置DE PCLK VSYNC HSYNC*/
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource10, GPIO_AF_LTDC); /* DE */
    gpio.GPIO_Pin = GPIO_Pin_10;
    GPIO_Init(GPIOF, &gpio);

    GPIO_PinAFConfig(GPIOG, GPIO_PinSource7, GPIO_AF_LTDC);  /* PCLK */
    gpio.GPIO_Pin = GPIO_Pin_7 ;
    GPIO_Init(GPIOG, &gpio);
    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource9, GPIO_AF_LTDC);  /* VSYNC */
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource10, GPIO_AF_LTDC); /* HSYNC */
    gpio.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10;
    GPIO_Init(GPIOI, &gpio);
    
    /*配置18线RGB的引脚*/
    /*省略*/
}

初始化LTDC

void ltdc_init(void)
{
	LTDC_InitTypeDef controller; //配置LTDC结构体
    
    PeripheralEnable(PeripheralLTDC, true);//开启LTDC功能
    
    LTDC_StructInit(&controller); //初始化LTDC结构体主要是清零
    
    //配置信号极性 HSYNC低有效 VSYNC低有效 DE高有效 PCLK反相
    controller.LTDC_HSPolarity = LTDC_HSPolarity_AL;//(uint32_t)0x00000000
    controller.LTDC_VSPolarity = LTDC_VSPolarity_AL;//(uint32_t)0x00000000
    controller.LTDC_DEPolarity = LTDC_DEPolarity_AH; //(uint32_t)0x20000000
    controller.LTDC_PCPolarity = LTDC_PCPolarity_IIPC; //(uint32_t)0x10000000
    
    //配置时钟同步 LTDC计数器从0开始 所以要-1U
    controller.LTDC_HorizontalSync = LCD_HSYNC - 1U;
    controller.LTDC_VerticalSync = LCD_VSYNC - 1U;
    //配置后肩  LTDC计数器从0开始 所以要-1U
    controller.LTDC_AccumulatedHBP = LCD_HSYNC + LCD_HBACK_PORCH - 1U;
    controller.LTDC_AccumulatedVBP = LCD_VSYNC + LCD_VBACK_PORCH - 1U;
    //有效区结束位置 LTDC计数器从0开始 所以要-1U
    controller.LTDC_AccumulatedActiveW = LCD_HSYNC + LCD_HBACK_PORCH + LCD_WIDTH - 1U;
    controller.LTDC_AccumulatedActiveH = LCD_VSYNC + LCD_VBACK_PORCH + LCD_HEIGHT - 1U;
    //整行和整帧结束位置 LTDC计数器从0开始 所以要-1U
    controller.LTDC_TotalWidth = LCD_HSYNC + LCD_HBACK_PORCH +
                                 LCD_WIDTH + LCD_HFRONT_PORCH - 1U;
    controller.LTDC_TotalHeigh = LCD_VSYNC + LCD_VBACK_PORCH +
                                 LCD_HEIGHT + LCD_VFRONT_PORCH - 1U;
    
    LTDC_Init(&controller);//写入配置
    
    /*
    *	配置Layer
    */
    LTDC_Layer_InitTypeDef layer; //图层,支持多个图层
    LTDC_LayerStructInit(&layer);
    
    //图层水平起点终点
    layer.LTDC_HorizontalStart = LCD_HSYNC + LCD_HBACK_PORCH;
    layer.LTDC_HorizontalStop = layer.LTDC_HorizontalStart + LCD_WIDTH - 1U;
    
    //图层垂直起点终点
    layer.LTDC_VerticalStart = LCD_VSYNC + LCD_VBACK_PORCH;
    layer.LTDC_VerticalStop = layer.LTDC_VerticalStart + LCD_HEIGHT - 1U;
    
    //输入这个Layer的显存像素格式、alpha、首地址
    layer.LTDC_PixelFormat = LTDC_Pixelformat_ARGB8888;
    layer.LTDC_ConstantAlpha = 0xFFU;
    layer.LTDC_CFBStartAdress = framebuffer_address;
    
    //行有效显存数据 & 当前行到下一行跨过多少字节
    layer.LTDC_CFBLineLength = LCD_WIDTH * 4U;
    layer.LTDC_CFBPitch = LCD_WIDTH * 4U;
    
    //帧缓冲行数
    layer.LTDC_CFBLineNumber = LCD_HEIGHT;
    
    //混合因子1,混合因子2,每个图层混合情况
    layer.LTDC_BlendingFactor_1 = LTDC_BlendingFactor1_PAxCA;
    layer.LTDC_BlendingFactor_2 = LTDC_BlendingFactor2_PAxCA;
    
    //开始写入配置
    LTDC_LayerInit(LTDC_Layer1, &layer);
    
    //关闭Layer2,开启关闭Layer1
    LTDC_LayerCmd(LTDC_Layer2, DISABLE);
    LTDC_LayerCmd(LTDC_Layer1, ENABLE);
    
    //LTDC->AHBCFG |= 0x07U;
    LTDC->AHBCFG |= 0x07U;
    
    //Reload配置
    /*
     CPU写配置
   		↓
	Shadow Register
   		↓ Reload
	Active Register
   		↓
	LTDC实际使用
	*/
    LTDC_ReloadConfig(LTDC_IMReload);//LTDC_IMReload 立即加载
    
    //启动LTDC
    LTDC_Cmd(ENABLE);
}

003. FSMC&SDRAM

SDRAM起始地址和大小

0x60000000 ~ 0x607FFFFF共8M

#define MH2457_SDRAM_BASE                  0x60000000UL
#define MH2457_SDRAM_SIZE                  (8UL * 1024UL * 1024UL)

屏幕显存大小

240x320 ARGB8888 = 307,200 b

#define MH2457_LCD_WIDTH                   240U
#define MH2457_LCD_HEIGHT                  320U
#define MH2457_LCD_BYTES_PER_PIXEL         4U
#define MH2457_LCD_FRAME_BYTES             (MH2457_LCD_WIDTH * MH2457_LCD_HEIGHT * MH2457_LCD_BYTES_PER_PIXEL)

LVGL双缓冲(两块显存)

第二块显存的起始地址刚好是0x60000000 + 显存大小

#define MH2457_SDRAM_DISPLAY_BUFFER0       0x60000000UL
#define MH2457_SDRAM_DISPLAY_BUFFER1       (0x60000000UL + MH2457_LCD_FRAME_BYTES)

完整布局

实用和占用

对齐4 KiB(技术下限其实是 64 B)

0x60000000  ├──────────────────────────────────┤
             │  DISPLAY_BUFFER0     307,200 B   │  fb缓冲1
0x6004B000  ├──────────────────────────────────┤
             │  DISPLAY_BUFFER1     307,200 B   │  fb缓冲2
0x60096000  ├──────────────────────────────────┤
             │  TM30_RAW_FRAME0     196,608 B   │  (红外RAW1,实用 178,816)
0x600C6000  ├──────────────────────────────────┤
             │  TM30_RAW_FRAME1     196,608 B   │  (红外RAW2,实用 178,816)
0x600F6000  ├──────────────────────────────────┤
             │  TM30_ARGB_STAGE0    233,472 B   │  (YUV数据1,实用 230,400)
0x6012F000  ├──────────────────────────────────┤
             │  TM30_ARGB_STAGE1    233,472 B   │  (YUV数据2,实用 230,400)
0x60168000  ├──────────────────────────────────┤
             │  PHOTO_ARGB          307,200 B   │  拍照
0x601B3000  ├──────────────────────────────────┤
             │  PHOTO_THERMAL        24,576 B   │  (拍照红外及帧头,实用 23,072 = 两段之和)
             │    ├ 段1 协议头        4,640 B   │
             │    └ 段2 温度矩阵     18,432 B   │
0x601B9000  ├──────────────────────────────────┤
             │  PHOTO_JPEG          524,288 B   │  jpeg编解码占用
0x60239000  ├──────────────────────────────────┤
             │  PHOTO_YUV           233,472 B   │  (解码YUV占用,实用 230,400)
0x60272000  ├──────────────────────────────────┤
             │  空闲              5,828,608 B   │
0x60800000  └──────────────────────────────────┘
外设能接什么典型应用
SDRAM 控制器SDRAM 颗粒大容量内存:显存、大缓冲区、跑大模型/大数据

FSMC

  • 通用并行静态总线,能接 NOR/SRAM/NAND,也能把"带嵌入式控制器的 LCD 模块"当 SRAM 无缝挂上(8080/6800 模式)多少行,多少列,多少bit的寄存器大小

FSMC-LCD 方案(带 GRAM 的屏)

SDRAM

只接动态内存(SDRAM)

  • 独立的 SDRAM 控制器
  • 或者FMC

FMC

在一些芯片,如STM32,FMC = FSMC + SDRAM控制器

芯片硬件写死的SDRAM的起始地址和最大值

Cortex-M 把 4 GB 地址空间按用途做了硬性划分,这是 ARM 架构定义的,所有 Cortex-M 芯片都遵循:

地址区间用途MH2457 上是什么
0x00000000 - 0x1FFFFFFFCode内部 Flash
0x20000000 - 0x3FFFFFFFSRAM内部 RAM(1.25 MiB)
0x40000000 - 0x5FFFFFFFPeripheral所有外设寄存器(UART/DCMI/LTDC…)
0x60000000 - 0x9FFFFFFFExternal RAM← SDRAM 落在这里
0xE0000000 - 0xE00FFFFF内核私有NVIC / SysTick / DWT(代码里用的 DWT->CYCCNT)

我们的SDRAM:0x60000000 ~ 0x607FFFFF共8M

static void SDRAM_Config(void);
static void SDRAM_PinConfig(void);
void BoardSdram_Init(void)
{
    SDRAM_PinConfig();
    SDRAM_Config();
}

[重点是初始化配置] (# 初始化)

配置引脚

static void SDRAM_PinConfig(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOF | RCC_AHB1Periph_GPIOG | RCC_AHB1Periph_GPIOH | RCC_AHB1Periph_GPIOI, ENABLE);
    
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource0, GPIO_AF_SDRAM);     //DQM[0]
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource1, GPIO_AF_SDRAM);     //DQM[1]    
    
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_AF;
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
    GPIO_InitStructure.GPIO_Pin   = GPIO_Pin_0 | GPIO_Pin_1;
    GPIO_InitStructure.GPIO_PuPd  = GPIO_PuPd_NOPULL;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOE, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOE, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);

    
    GPIO_PinAFConfig(GPIOG, GPIO_PinSource8, GPIO_AF_SDRAM);     //CLK    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8;
    GPIO_Init(GPIOG, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOG, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOH, GPIO_PinSource2, GPIO_AF_SDRAM);     //CKE    
    GPIO_PinAFConfig(GPIOH, GPIO_PinSource3, GPIO_AF_SDRAM);     //CSN[0]    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
    GPIO_Init(GPIOH, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOH, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOG, GPIO_PinSource4, GPIO_AF_SDRAM);     //BA0    
    GPIO_PinAFConfig(GPIOG, GPIO_PinSource5, GPIO_AF_SDRAM);     //BA1    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5; 
    GPIO_Init(GPIOG, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOG, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOF, GPIO_PinSource11, GPIO_AF_SDRAM);     //RAS_N    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;
    GPIO_Init(GPIOF, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOF, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOG, GPIO_PinSource15, GPIO_AF_SDRAM);     //CAS_N    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_15;
    GPIO_Init(GPIOG, &GPIO_InitStructure); 

    GPIO_DriveStrengthConfig(GPIOG, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOH, GPIO_PinSource5, GPIO_AF_SDRAM);     //WE_N    
    
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
    GPIO_Init(GPIOH, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOH, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOF, GPIO_PinSource0, GPIO_AF_SDRAM);     //A0    
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource1, GPIO_AF_SDRAM);     //A1
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource2, GPIO_AF_SDRAM);     //A2    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource13, GPIO_AF_SDRAM);     //A3    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource14, GPIO_AF_SDRAM);     //A4    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource15, GPIO_AF_SDRAM);     //A5
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource12, GPIO_AF_SDRAM);     //A6    
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource13, GPIO_AF_SDRAM);     //A7
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource14, GPIO_AF_SDRAM);     //A8
    GPIO_PinAFConfig(GPIOF, GPIO_PinSource15, GPIO_AF_SDRAM);     //A9    
    GPIO_PinAFConfig(GPIOG, GPIO_PinSource0, GPIO_AF_SDRAM);     //A10    
    GPIO_PinAFConfig(GPIOG, GPIO_PinSource1, GPIO_AF_SDRAM);     //A11    

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15;
    GPIO_Init(GPIOF, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOF, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15;
    GPIO_Init(GPIOI, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOI, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
    GPIO_Init(GPIOG, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOG, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);


    GPIO_PinAFConfig(GPIOD, GPIO_PinSource14, GPIO_AF_SDRAM);     //D0    
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource15, GPIO_AF_SDRAM);     //D1
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_SDRAM);     //D2    
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource1, GPIO_AF_SDRAM);     //D3    
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource7, GPIO_AF_SDRAM);     //D4    
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource8, GPIO_AF_SDRAM);     //D5
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource9, GPIO_AF_SDRAM);     //D6    
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource10, GPIO_AF_SDRAM);     //D7
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource8, GPIO_AF_SDRAM);     //D8
    GPIO_PinAFConfig(GPIOC, GPIO_PinSource12, GPIO_AF_SDRAM);     //D9    
    GPIO_PinAFConfig(GPIOG, GPIO_PinSource3, GPIO_AF_SDRAM);     //D10    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource11, GPIO_AF_SDRAM);     //D11    
    GPIO_PinAFConfig(GPIOI, GPIO_PinSource12, GPIO_AF_SDRAM);     //D12
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource8, GPIO_AF_SDRAM);     //D13    
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource9, GPIO_AF_SDRAM);     //D14    
    GPIO_PinAFConfig(GPIOD, GPIO_PinSource10, GPIO_AF_SDRAM);     //D15

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_14 | GPIO_Pin_15;
    GPIO_Init(GPIOD, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOD, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10;
    GPIO_Init(GPIOE, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOE, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_11 | GPIO_Pin_12;
    GPIO_Init(GPIOI, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOI, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);
 
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
    GPIO_Init(GPIOC, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOC, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High); 

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;
    GPIO_Init(GPIOG, &GPIO_InitStructure);

    GPIO_DriveStrengthConfig(GPIOG, GPIO_InitStructure.GPIO_Pin, GPIO_DriveStrength_High);      
}

初始化

static void SDRAM_Config(void)
{
    SDRAM_InitTypeDef   SDRAM_InitStructure;
    SDRAM_TimingTypeDef timing;

    PeripheralEnable(PeripheralSDRAM, 1);
    PeripheralEnable(PeripheralSYSCFG, 1);
    
    SDRAM_CLKDivConfig(SDRAM_CLKDIV_Div2);
    SDRAM_SampleDelayConfig(0x02);
    
    SDRAM_DeInit();

    SDRAM_InitStructure.MemoryDataWidth     = SDRAM_MemoryDataWidth_16bit;
    SDRAM_InitStructure.BankAddrBitsNumber  = SDRAM_BankAddrBitsNumber_2bit;
    SDRAM_InitStructure.ColumnBitsNumber    = SDRAM_ColumnBitsNumber_8bit;
    SDRAM_InitStructure.RowBitsNumber       = SDRAM_RowBitsNumber_12bit;
    SDRAM_InitStructure.PrechargeAlgorihm   = SDRAM_PrechargeAlgorihm_Delayed;
    SDRAM_InitStructure.FullRefreshBeforeSR = SDRAM_RefreshOneRowBeforeEnterSR;
    SDRAM_InitStructure.FullRefreshAfterSR  = SDRAM_RefreshOneRowAfterEnterSR;
    SDRAM_InitStructure.ClkEdgeSel          = SDRAM_ClkEdgeSelectRising;
    SDRAM_InitStructure.ReadPipe            = 2;
    SDRAM_Init(&SDRAM_InitStructure);

    timing.CASLatency        = SDRAM_CASLatency_3CLK;
    timing.RasMinDelay       = 7;     // t_ras 40ns
    timing.RCDDelay          = 2;     // t_rcd 15ns
    timing.RPDelay           = 2;     // t_rp 15ns
    timing.WriteRecoveryTime = 1;     // t_wr 10ns
    timing.RCARTime          = 7;     // t_rc 55ns
    timing.XSRDelay          = 7;     // t_xsr 57ns
    timing.RCTime            = 7;     // t_rc 55ns
    timing.InitDelay         = 30000; // 200us
    timing.InitRefNumber     = 7;

    /* Target SDK runs SYSCLK/APB at 300/75 MHz. Keep the vendor 2292-cycle
     * refresh value; 2200 belongs to the separate 288/72 MHz project. */
    timing.RefCycle = 2292;
    SDRAM_TimingConfig(&timing);

    SDRAM_SendCommand(SDRAM_CommandEnterInitialize);
    while (SDRAM_GetCommandStatus(SDRAM_CommandStatus_Initialize) == SET);
}
  • SDRAM_InitStructure.MemoryDataWidth = SDRAM_MemoryDataWidth_16bit;

    bank 数 = 2^2 = 4

  • SDRAM_InitStructure.BankAddrBitsNumber = SDRAM_BankAddrBitsNumber_2bit;

    行数 = 2^12 = 4096

  • SDRAM_InitStructure.ColumnBitsNumber = SDRAM_ColumnBitsNumber_8bit;

    列数 = 2^8 = 256

  • SDRAM_InitStructure.RowBitsNumber = SDRAM_RowBitsNumber_12bit;

    每单元宽度 = 16 bit = 2 字节

4 × 4096 × 256 × 2 = 8,388,608 字节 = 8 MiB

一次写入的完整旅程

代码里写 *(volatile uint32_t *)0x60001234 = 0x12345678 时,发生这些事——没有一件需要软件介入:

① CPU 执行 STR 指令,地址 = 0x60001234
        ↓
② 地址译码器(组合逻辑)判断:落在 0x60000000-0x9FFFFFFF → 外部 RAM 区
        ↓
③ 总线矩阵把这个访问路由给 SDRAM 控制器
        ↓
④ SDRAM 控制器把地址拆解:
     0x60001234 - 0x60000000 = 0x1234 偏移
     → bank 位 / row 位 / column 位
   加上 CAS 延迟、RAS/CAS 时序
        ↓
⑤ 驱动物理引脚(地址线、数据线、RAS/CAS/WE)完成一次 SDRAM 突发写

②③④⑤ 全是硬件自动的,软件只提供⑤之前的那个地址。

LTDC、DMA、CPU —— 它们都是"总线主设备"

主设备怎么发起访问表现形式
CPU执行 LDR/STR/LDM/STM代码里的指针读写、memset
LTDC自己按像素时钟递增地址只要在 LTDC_LxCFBAR 寄存器写好起始地址
DMA2_Stream7自己按传输请求递增地址只要在 DMA_Memory0BaseAddr 写好起始地址

LVGL双缓冲

lv_color_t *framebuffer0 = (lv_color_t *)(uintptr_t)0x60000000UL;
lv_color_t *framebuffer1 = (lv_color_t *)(uintptr_t)(0x60000000+320*240*4)UL;

0x60000000 的普通指针,没有任何特殊之处。

LTDC切换缓冲区首地址:

LTDC_Layer1->CFBAR = 0x60000000UL;
LTDC_ReloadConfig(LTDC_VBReload); //

注意点

SDRAM 里不能直接定义 C 全局变量。

static uint32_t buffer[240*240];   // ❌ 链接器会放进内部 RAM,不是 SDRAM

链接脚本 (mh2457.ld) 的 MEMORY 里只有 FLASH 和 RAM——根本没有 SDRAM 这一段,链接器不知道它存在。想用 SDRAM 只有一条路:

uint32_t *buffer = (uint32_t *)(uintptr_t)0x600F6000;   // ✅ 手工地址

004. DMA

DMA Direct Memory Access(直接存储器访问)

DMA能干的事,核心就是三种传输方向:

传输方向说明典型场景
外设 → 存储器从外设读取数据存到内存ADC采集数据存入数组
存储器 → 外设从内存取出数据发给外设内存数据通过串口发送
存储器 → 存储器内存之间相互搬运RAM数据拷贝到另一块RAM

存储器:

  • ✅片内 RAM(内存)
  • ✅外部 SRAM / SDRAM(外接内存)
  • ✅片内 Flash(只能做源,不能做目标)
  • ✅外接映射的外部存储(FMC 挂载的器件)

DMA一次最多可搬运 1~65535个 数据单元,每个单元可以是字节(8位)、半字(16位)或全字(32位)。

此外,DMA还支持两种传输模式:

  • 普通模式:传完一批数据就停止
  • 循环模式:传完后自动重新开始,循环往复

凡是需要频繁搬运数据、但又不想让CPU被占满的场景,都适合用DMA:

  1. ADC连续采样:ADC不断采集数据,DMA自动把结果搬进内存数组
  2. 串口(UART)通信:收发大量数据时用DMA搬运,CPU可以去处理其他任务
  3. SPI/I2C总线通信:与外部传感器、存储器批量交换数据
  4. 音频/波形处理:DAC播放音频时,DMA从内存持续搬运数据给DAC
  5. 内存数据拷贝:在SRAM不同区域之间快速搬移数据
  6. 显示屏刷新:将图像数据从内存搬运到显示控制器

总线主设备

总线事务的发起者。它拥有主动使用权,可以发出读/写指令,不需要别人同意就能启动传输(只要总线上没有冲突)。

总线从设备

总线事务的响应者。它不能主动发起任何操作,只能挂在那里“听候差遣”。当主设备发出的地址正好落在它的地址范围内时,它必须做出响应(交出数据或写入数据)。

设备名称挂在什么总线上?总线角色(主/从)为什么这么分?
CPU高速总线(AHB)主设备它是大脑,必须高速主动取指令。
DMA高速总线(AHB)主设备它要搬运大量数据(如ADC波形),必须抢占总线高速搬运,不能等慢速总线。
内存(RAM/Flash)高速总线(AHB)从设备CPU和DMA都要高速读写它,它只能被动响应,但速度必须快,否则CPU会“饥饿”(等待)。
I2C / UART 控制器低速总线(APB)从设备CPU(主)通过低速总线写它们的发送寄存器,配置波特率。因为配置是一次性的,不追求高速。
外部I2C传感器(如MPU6050)外部物理线外部从设备这里要注意!芯片内部的I2C控制器(此时作为外部主设备),通过SCL/SDA线去读这个外部传感器(外部从设备)。此时内部低速总线(APB)上的“从设备(I2C控制器)”,在外部物理线上反转成了“主设备”——这就是双层主从的切换。

ADC概念

  • 将特定的一些引脚配置成模拟模式。它和UART、SPI那种数字复用不完全一样。
    • 关闭数字输入施密特触发器;
    • 关闭输出驱动器;
    • 关闭上下拉;
    • 让引脚电压直接进入ADC的模拟多路开关。
  • 单通道配置流程
    1. 使能GPIO和ADC时钟;
    2. 把对应GPIO配置为模拟模式;
    3. 在ADC规则序列或注入序列里写入要转换的通道号;
    4. 设置采样时间;
    5. 启动ADC转换;
    6. ADC内部采样该通道对应的引脚电压,进行逐次逼近转换;
    7. 结果存到 ADC_DR 或 ADC_JDRx。
  • 多通道时要注意

    一个ADC只有一个规则数据寄存器 ADC_DR。如果你扫描多个通道:

    • 通道0转换完,结果进 ADC_DR;
    • 通道1转换完,新结果会覆盖 ADC_DR;
    • 所以多通道扫描通常要配合 DMA,把每次结果自动搬到内存数组。
  • 多ADC情况

    一些MCU有多个ADC,ADC1、ADC2、ADC3。他们有各自的数据寄存器。另有少量共用的寄存器比如,ADC 时钟预分频,多重模式配置,双重/三重模式的数据寄存器等。

  • 搬运到内存

    三种方式:

    • 轮询:CPU 查 EOC,读 DR;
    • 中断:转换完成中断里读 DR;
    • DMA:最常用。DMA 把 &ADC3->DR 自动搬到内存数组。

    多通道扫描时,后一个通道会覆盖 DR,所以必须用 DMA 或及时读走。

流程

CPU配置PA0为模拟输入
        ↓
CPU配置ADC3为12位、单通道、连续转换
        ↓
CPU配置DMA2 Stream0
        ↓
DMA源地址 = ADC3->DR
DMA目标地址 = uhADCxConvertedValue
        ↓
CPU配置并开启DMA中断
        ↓
CPU开启DMA,DMA开始等待
        ↓
CPU开启ADC3的DMA请求功能
        ↓
CPU软件启动ADC3
        ↓
ADC3采样PA0
        ↓
ADC3完成12位转换
        ↓
结果写入ADC3->DR
        ↓
ADC3向DMA2 Stream0发送请求
        ↓
DMA读取ADC3->DR
        ↓
DMA写入uhADCxConvertedValue
        ↓
DMA剩余数量从1变成0
        ↓
DMA产生TC传输完成中断
        ↓
NVIC通知CPU
        ↓
CPU进入DMA2_Stream0_IRQHandler()
        ↓
打印ADC值和电压
        ↓
清除DMA中断标志
        ↓
ADC连续转换、DMA循环搬运,重新开始

开启时钟配置

PeripheralEnable(PeripheralGPIOA, true); //GPIOA的时钟
PeripheralEnable(PeripheralADC3, true); //ADC3的时钟

IO脚配置

GPIO_InitTypeDef      GPIO_InitStructure = {0};

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AN;//模拟输入
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;//无上下拉
GPIO_Init(GPIOA, &GPIO_InitStructure);

ADC公共配置

所谓“公共配置”,是ADC1、ADC2、ADC3可能共用的配置。

ADC_CommonInitTypeDef ADC_CommonInitStructure = {0};
ADC_CommonInitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_CommonInitStructure.ADC_Prescaler = ADC_Prescaler_Div4;//ADC时钟四分频
ADC_CommonInitStructure.ADC_DMAAccessMode = ADC_DMAAccessMode_Disabled;//
ADC_CommonInitStructure.ADC_TwoSamplingDelay = ADC_TwoSamplingDelay_5Cycles;//多ADC采样间隔
ADC_CommonInit(&ADC_CommonInitStructure);
  • ADC_CommonInitStructure.ADC_Mode =ADC_Mode_Independent;

    表示ADC3独立工作。

    一些芯片支持多ADC协同工作,例如:

    • ADC1和ADC2同时采样
    • ADC1和ADC2交替采样
    • 三个ADC同时采样
  • ADC_CommonInitStructure.ADC_DMAAccessMode = ADC_DMAAccessMode_Disabled;

    禁止多ADC公共DMA模式

    这一项主要用于双ADC或三ADC模式。

    例如多个ADC联合工作时,多个ADC结果可能被打包到公共数据寄存器,再由DMA一起搬运。

    ADC_Mode_Independent 所以不需要多ADC公共DMA模式,设置为Disabled

ADC自身配置

ADC_InitTypeDef       ADC_InitStructure = {0};
ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b;//设置12位分辨率0~4095

//关闭扫描模式,扫描模式用于连续转换多个通道。例如需要测量三个通道:Channel 0 → Channel 3 → Channel 7
ADC_InitStructure.ADC_ScanConvMode = DISABLE;

//开启连续转换模式,连续转换意味着ADC只需要启动一次,之后会自动不断转换:
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;

//禁止外部触发边沿ADC可以由很多事件启动,例如:- 软件命令 - 定时器更新事件 - 定时器比较事件- 外部引脚
//设置成None,表示不使用外部触发边沿,后面由软件启动 ADC_SoftwareStartConv(ADC3);
ADC_InitStructure.ADC_ExternalTrigConvEdge = ADC_ExternalTrigConvEdge_None;

//ADC结果右对齐,ADC数据寄存器通常是16位或32位,而有效结果只有12位。
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;

//规则序列包含一次转换
ADC_InitStructure.ADC_NbrOfConversion = 1;
ADC_Init(ADCx, &ADC_InitStructure);

// 配置具体ADC通道
// 哪个ADC:ADC3
// 哪个通道:Channel 0
// 序列中的位置:第1位
// 采样时间:3个ADC周期
ADC_RegularChannelConfig(ADC3, ADC_Channel_0, 1, ADC_SampleTime_3Cycles);

//允许ADC产生DMA请求
ADC_DMARequestAfterLastTransferCmd(ADCx, ENABLE);

//开启ADC的DMA接口
ADC_DMACmd(ADC3, ENABLE);
//开启ADC3
ADC_Cmd(ADC3, ENABLE);

开始ADC转换

必须配置好对应的DMA之后才能开启这个ADC

ADC_SoftwareStartConv(ADC3);

DMA相关配置

DMA_InitTypeDef       DMA_InitStructure = {0};
PeripheralEnable(PeripheralDMA2, true); //时钟
DMA_InitStructure.DMA_Channel = DMA_Channel_2;//DMA请求源配置
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC3->DR;//配置DMA源地址
DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)&uhADCxConvertedValue;//配置DMA目标地址
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;//设置DMA传输方向,外设 → 内存
DMA_InitStructure.DMA_BufferSize = 1;//设置每轮传输数量,表示DMA每轮只搬一个数据。
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;//禁止外设地址递增
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Disable;//禁止内存地址递增
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//设置外设数据宽度
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//设置内存数据宽度
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;//设置DMA循环模式,通模式下,传输数量变成0以后DMA会停止,循环模式下,传输完成后自动重新装载数量
DMA_InitStructure.DMA_Priority = DMA_Priority_High;//设置DMA优先级
DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;//关闭DMA FIFO

DMA_Init(DMA2_Stream0, &DMA_InitStructure);

DMA_ITConfig(DMA2_Stream0, DMA_IT_TC, ENABLE);//开启DMA传输完成中断
DMA_Cmd(DMA2_Stream0, ENABLE);//启动DMA Stream

NVIC中断配置

void NVIC_Configuration(void)
{
    NVIC_InitTypeDef NVIC_InitStructure;
    
    NVIC_InitStructure.NVIC_IRQChannel = DMA2_Stream0_IRQn;//选择DMA2 Stream0中断
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;//抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;//子优先级,两个中断具有相同抢占优先级,并且同时等待CPU处理,子优先级决定先处理谁。
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;//允许NVIC接收该中断
    NVIC_Init(&NVIC_InitStructure);
}
中断处理函数
void DMA2_Stream0_IRQHandler(void)
{
    printf(
        "ADC data = %d, voltage = %f mV\n",
        uhADCxConvertedValue, //读取DMA搬运后的数据
        (float)uhADCxConvertedValue * 3300 / 4096 //把ADC值换算成电压
    );

    DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TCIF);//清除DMA传输完成标志
    NVIC_ClearPendingIRQ(DMA2_Stream0_IRQn);//清除NVIC挂起状态
}

DMA2D(Chrom‑ART Accelerator)

DMA2D = 2D 图形专用 DMA,单片机简易 2D 硬件加速器

普通 DMA(DMA1/DMA2)只会单纯搬运字节,不做任何计算; DMA2D 只做存储器↔存储器操作,不对接 UART/ADC/SPI 这类外设,专门干像素图像处理CSDN博...。

  1. 寄存器 → 存储器(Register‑to‑Memory) 把寄存器里设置的单一颜色,批量填充到一块内存(帧缓存)。 ✅典型:清屏、画纯色矩形块。源不是内存,是颜色寄存器值。
  2. 存储器 → 存储器(Memory‑to‑Memory) 普通内存拷贝,和 DMA2 的 mem‑to‑mem 类似,原样复制像素块。 ✅典型:把图片缓冲区整块拷贝到 LCD 显存。
  3. 存储器 → 存储器 + 颜色格式转换 搬运同时硬件自动转像素格式,CPU 不用算。 例:源是 RGB888 图片,目标显存是 RGB565,DMA2D 硬件直接完成格式转换搬运。
  4. 存储器 → 存储器 + 格式转换 + Alpha 透明混合 双输入:前景图层 + 背景图层,硬件做 Alpha 透明度混合叠加,输出结果到目标内存。 ✅典型:UI 半透明弹窗、叠加图标,LVGL/EMWIN 图形库会大量使用此硬件加速,解放 CPU。

DMA2D 全部操作都在内存之间(片内 RAM、外部 SDRAM 显存、Flash 只读做源),不能直接发送给 UART、SPI 外设,这点和普通 DMA 完全不一样CSDN博...。

典型使用场景

  1. 快速清屏,填充矩形色块;
  2. 图片拷贝到 LCD 帧缓存(显存一般放在外部 SDRAM);
  3. 图片像素格式硬件转换;
  4. UI 图层透明叠加,LVGL 打开 DMA2D 加速可以大幅降低 CPU 占用;
  5. 局部屏幕刷新,只更新屏幕一小块区域。

容易混淆点

  • LTDC:LCD 屏幕控制器,负责持续把显存数据输出到 TFT 屏幕硬件;
  • DMA2D:图形加速器,负责修改显存里面的像素内容;

LTDC 负责 “往外刷屏幕”,DMA2D 负责 “往显存里面画图”,两者经常搭配使用。

005. SD卡和FatFS文件系统

最底层:SPI只负责收发字节

/* 发送一个字节,同时接收一个字节 */
uint8_t SPI4_RW(uint8_t dat);

/* 连续接收len个字节,内部不断发送0xFF产生时钟 */
void SPI4_RxMulti(uint8_t *buff, uint32_t len);

/* 连续发送len个字节 */
void SPI4_TxMulti(const uint8_t *buff, uint32_t len);

实际上,后两个都是调用第一个实现的。

SPI只负责传输,不理解“文件”“扇区”“SD命令”。

中间层:SD驱动负责把字节组织成SD协议

在SPI收发基础上,sd_spi.c实现:

/* 初始化GPIO、供电和SPI外设 */
void SD_SPI_Init(void);

/* 发送CMD0、CMD8、ACMD41等,完成SD卡协议初始化 */
DSTATUS SD_SPI_Initialize(void);

/* 查询SD卡状态 */
DSTATUS SD_SPI_Status(void);

/* 从sector开始,读取count个扇区到buff */
DRESULT SD_SPI_ReadSectors(BYTE *buff, DWORD sector, UINT count);

/* 将buff写入从sector开始的count个扇区 */
DRESULT SD_SPI_WriteSectors(const BYTE *buff, DWORD sector, UINT count);

/* 获取卡的总扇区数 */
DWORD SD_SPI_GetSectorCount(void);

例如:

SD_SPI_ReadSectors(buffer, 100, 2);

意思是:

从第100号扇区开始
读取100、101两个扇区
共1024字节
保存到buffer

驱动内部负责发送CMD18、等待数据令牌、接收数据等细节。

给FatFs的接口:diskio.c中的五个函数

真正交给FatFs调用的是这五个接口:

FatFs接口作用当前对接的SD驱动
disk_initialize()初始化磁盘SD_SPI_Initialize()
disk_status()查询磁盘状态SD_SPI_Status()
disk_read()读取若干扇区SD_SPI_ReadSectors()
disk_write()写入若干扇区SD_SPI_WriteSectors()
disk_ioctl()同步写入、查询容量等SD状态和容量等接口

其中disk_ioctl()常用命令:

CTRL_SYNC         /* 等待底层待完成的写操作结束 */
GET_SECTOR_COUNT  /* 总扇区数 */
GET_SECTOR_SIZE   /* 每扇区字节数,当前为512 */
GET_BLOCK_SIZE    /* 擦除块包含的扇区数,主要供格式化使用 */

当前固定512字节配置下,FatFs已经知道扇区大小;这里仍实现了对应查询。

另外,当前启用文件时间戳,还需要:

DWORD get_fattime(void);

接好之后,应用就直接操作文件

先初始化并挂载:

SD_SPI_Init();
f_mount(&fs, "0:", 1);

随后应用使用:

f_open();
f_read();
f_write();
f_close();

例如读取文件,实际调用关系是:

应用:f_read()
          ↓
FatFs:查找文件对应的簇和扇区
          ↓
diskio:disk_read()
          ↓
SD驱动:SD_SPI_ReadSectors()
          ↓
SD协议:发送CMD17/CMD18,接收数据块
          ↓
SPI:SPI4_RW()

移植时,重点就是:让底层能够正确读写SD卡扇区,再把这套能力接到diskio.c的五个接口上。之后文件名、目录、空间分配等工作就交给FatFs。

SD卡引脚

功能			MCU引脚		   说明
SD电源	     PB2		低电平上电,高电平断电
插卡检测	    PB4	       低电平表示已插卡
SPI4 SCK	  PE2	     SD时钟
SPI4 MISO	  PE13	     SD DATA0
SPI4 MOSI	  PE14	     SD CMD
SD CS	      PB12	     软件控制片选

SPI函数

// 设置SPI速度:SPI_BaudRatePrescaler_256,SPI_BaudRatePrescaler_4,2-256多种
static void SPI4_SetPrescaler(uint16_t prescaler) 
{
    SPI_Cmd(SPI4, DISABLE); //SPI_Cmd是SDK已经有的一个函数,直接读写
    SD_SPI->CR1 = (SD_SPI->CR1 & (uint16_t)~0x0038) | (prescaler & 0x0038);
    SPI_Cmd(SPI4, ENABLE);
}
//SPI读写
static uint8_t SPI4_RW(uint8_t dat)
{
    while (SPI_GetFlagStatus(SPI4, SPI_I2S_FLAG_TXE) == RESET);
    SPI_SendData(SPI4, dat);
    while (SPI_GetFlagStatus(SPI4, SPI_I2S_FLAG_RXNE) == RESET);
    return (uint8_t)SPI_ReceiveData(SD_SPI);
}
//MCU读SPI外设,内部调用SPI4_RW
static void SPI4_RxMulti(uint8_t *buff, uint32_t len)
{
    while (len--) *buff++ = SPI4_RW(0xFF);
}
//MCU写SPI外设,内部调用SPI4_RW
static void SPI4_TxMulti(const uint8_t *buff, uint32_t len)
{
    while (len--) SPI4_RW(*buff++);
}

控制引脚初始化

//电源引脚配置
GPIO_InitTypeDef  gpio;
PeripheralEnable(PeripheralGPIOB, true);
gpio.GPIO_Pin  = GPIO_Pin_2; //SD卡电源使能, 低有效(拉低上电/供电), 拉高关断
gpio.GPIO_Mode = GPIO_Mode_OUT;
gpio.GPIO_OType = GPIO_OType_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
gpio.GPIO_PuPd  = GPIO_PuPd_NOPULL;
GPIO_Init(GPIOB, &gpio);

//插入判断DET引脚
gpio.GPIO_Pin  = SD_DET_PIN;
gpio.GPIO_Mode = GPIO_Mode_IN;
gpio.GPIO_PuPd  = GPIO_PuPd_UP;
GPIO_Init(SD_DET_PORT, &gpio);

void SD_SPI_PowerOn(void)
{
    GPIO_ResetBits(GPIOB, GPIO_Pin_2);   /* 低有效使能: 拉低 PB2 给 SD 卡上电 */
}

void SD_SPI_PowerOff(void)
{
    GPIO_SetBits(GPIOB, GPIO_Pin_2);     /* 拉高 PB2 关断 SD 卡电源 */
}
//读取是否插入
uint8_t SD_SPI_IsInserted(void)
{
    /* DET 外部上拉, 插入为低电平 */
    return (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_4) == Bit_RESET) ? 1 : 0;
}

SPI引脚配置

void SD_SPI_Init(void)
{
    SPI_InitTypeDef   spi;
    /* 3. SPI4 复用脚: PE2(SCK) PE13(MISO) PE14(MOSI), AF5 */
    PeripheralEnable(PeripheralGPIOE, true);
    /* SCK + MOSI: 无上下拉 (主机驱动) */
    gpio.GPIO_Pin   = SD_SPI_SCK_PIN | SD_SPI_MOSI_PIN;
    gpio.GPIO_Mode  = GPIO_Mode_AF;
    gpio.GPIO_OType = GPIO_OType_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    gpio.GPIO_PuPd  = GPIO_PuPd_NOPULL;
    GPIO_Init(GPIOE, &gpio);
    /* MISO: 上拉! SD卡CS=高时MISO为高阻态, 无上拉则电平浮动,
     *       SD_WaitReady() 轮询 0xFF 会读到浮动值超时失败。 */
    gpio.GPIO_Pin   = SD_SPI_MISO_PIN;
    gpio.GPIO_PuPd  = GPIO_PuPd_UP;
    GPIO_Init(GPIOE, &gpio);
    GPIO_PinAFConfig(GPIOE,  GPIO_PinSource2,  (uint8_t)0x05);
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource13, (uint8_t)0x05);
    GPIO_PinAFConfig(GPIOE, GPIO_PinSource14, (uint8_t)0x05);

    /* 4. CS 脚 PB12: 软件控制, 推挽输出, 默认高 */
    gpio.GPIO_Pin  = SD_SPI_CS_PIN;
    gpio.GPIO_Mode = GPIO_Mode_OUT;
    gpio.GPIO_OType = GPIO_OType_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    gpio.GPIO_PuPd  = GPIO_PuPd_NOPULL;
    GPIO_Init(GPIOB, &gpio);
    SD_CS_HIGH(); //对应下面CS操作

    /* 5. SPI4 外设 (APB2) */
    PeripheralEnable(PeripheralSPI4, true);
    SPI_StructInit(&spi); //初始化结构体
    spi.SPI_Direction     = SPI_Direction_2Lines_FullDuplex;
    spi.SPI_Mode          = SPI_Mode_Master;
    spi.SPI_DataSize      = SPI_DataSize_8b;
    spi.SPI_CPOL          = SPI_CPOL_Low;
    spi.SPI_CPHA          = SPI_CPHA_1Edge;
    spi.SPI_NSS           = SPI_NSS_Soft;
    spi.SPI_BaudRatePrescaler = SD_SPI_SLOW_PRESCALER;
    spi.SPI_FirstBit      = SPI_FirstBit_MSB;
    spi.SPI_CRCPolynomial = 7;
    SPI_Init(SPI4, &spi);
    SPI_Cmd(SPI4, ENABLE);
}

CS操作

#define SD_CS_HIGH()   GPIO_SetBits(GPIOB,  GPIO_Pin_12)
#define SD_CS_LOW()    GPIO_ResetBits(GPIOB, GPIO_Pin_12)

作为中间层,利用SPI提供的函数,加上协议完成SD操作

static void SPI4_SetPrescaler(uint16_t prescaler);//设置SPI速度

static uint8_t SPI4_RW(uint8_t dat);//SPI读写

static void SPI4_RxMulti(uint8_t *buff, uint32_t len);//MCU读SPI外设,内部调用SPI4_RW

static void SPI4_TxMulti(const uint8_t *buff, uint32_t len);//MCU写SPI外设,内部调用SPI4_RW

SD是否就绪

不断发送0xFF并读取MISO:

​ 读到0xFF:卡已经就绪 ​ 其他值:卡仍然忙

/* 等待卡就绪: 返回 0=就绪 1=超时 */
static int SD_WaitReady(uint32_t wt)
{
    uint8_t d;
    do {
        d = SPI4_RW(0xFF);
    } while (d != 0xFF && --wt);
    return (d == 0xFF) ? 0 : 1;
}

选中SD卡

/* 选卡并等待就绪, 返回 1=成功 0=失败
 * 关键: 先拉高 CS 再拉低, 保证每个命令事务都有 CS 下降沿。
 * SD SPI 规范要求每个命令以 CS 下降沿开始; 若 CS 持续为低
 * (背靠背命令), 部分 v2 卡的命令状态机不重置, CMD8/ACMD41 无响应。 */
static int SD_Select(void)
{
    SD_CS_HIGH();
    SPI4_RW(0xFF);      /* CS 高期间 8 时钟, 让卡释放 MISO */
    SD_CS_LOW();
    SPI4_RW(0xFF);      /* CS 低后 8 时钟, 卡开始驱动 MISO */
    if (SD_WaitReady(SD_READ_TIMEOUT)) {
        SD_Deselect();
        return 0;
    }
    return 1;
}

取消选中SD卡

static void SD_Deselect(void)
{
    SD_CS_HIGH(); // 拉高CS
    SPI4_RW(0xFF);   /* 额外 8 时钟释放 DO */
}

发送SD命令

SD卡SPI命令固定为6字节:

字节0:01 + 命令号
字节1:参数[31:24]
字节2:参数[23:16]
字节3:参数[15:8]
字节4:参数[7:0]
字节5:CRC7 + 结束位1
/* 发送命令, 返回 R1 */
static uint8_t SD_SendCmd(uint8_t cmd, uint32_t arg)
{
    uint8_t buf[6];
    uint8_t r1;
    uint32_t n;

    if (SD_Select() == 0) return 0xFF;

    buf[0] = (uint8_t)(0x40 | cmd);
    buf[1] = (uint8_t)(arg >> 24);
    buf[2] = (uint8_t)(arg >> 16);
    buf[3] = (uint8_t)(arg >> 8);
    buf[4] = (uint8_t)(arg);
    buf[5] = 0x01;
    if (cmd == CMD0)      buf[5] = 0x95;   /* CMD0 CRC */
    else if (cmd == CMD8) buf[5] = 0x87;   /* CMD8 CRC */

    SPI4_TxMulti(buf, 6);

    if (cmd == CMD12) SPI4_RW(0xFF);       /* CMD12 后补 1 字节 */

    n = 1000;//超时时间1000ms
    do {
        r1 = SPI4_RW(0xFF);
    } while ((r1 & 0x80) && --n);

    return r1;
}
定义:15个
有调用:14个
未调用:CMD10

常见SDHC/SDXC卡:
初始化涉及6个
读取涉及3个
写入涉及3个
共12种命令

额外兼容:
CMD1、CMD16
/**
 * @file sd_spi.c
 * @brief SD 卡 SPI 模式驱动 (SPI4), 适配 JW-MCU-TM30-2
 *
 * 管脚 (按用户确认):
 *   39脚 PB2  = SD_PWR   电源使能(低有效: 拉低上电/供电, 拉高关断)
 *   3脚  PE2  = SPI4_SCK  CLK
 *   48脚 PB12 = SPI4_NSS  CS (CD/DATA3)
 *   66脚 PE13 = SPI4_MISO DATA0
 *   67脚 PE14 = SPI4_MOSI CMD
 *   93脚 PB4  = SD_DET   插入检测
 *
 * SPI4 挂在 APB2, 系统 APB2 = 48MHz (SYSCLK 288M / APB2 div 6)。
 * 初始化用低速分频 256 -> 187.5kHz (< 400kHz), 协议完成提速到 12MHz。
 */

#include "mh245x_gpio.h"
#include "mh245x_spi.h"
#include "peripheral.h"
#include "system_mh2457.h"
#include "sd_spi.h"
#include <stdio.h>

/* Keep the proven reference driver intact while routing its diagnostics to
 * this project's console service. */
#define BSP_LOGI(...) do { printf("[SD] "); printf(__VA_ARGS__); printf("\n"); } while (0)
#define BSP_LOGW(...) do { printf("[SD][WARN] "); printf(__VA_ARGS__); printf("\n"); } while (0)
#define BSP_LOGE(...) do { printf("[SD][ERR] "); printf(__VA_ARGS__); printf("\n"); } while (0)

/* ===================== SD 命令集 ===================== */
#define CMD0    0   /* GO_IDLE_STATE */
#define CMD1    1   /* SEND_OP_COND (MMC) */
#define CMD8    8   /* SEND_IF_COND */
#define CMD9    9   /* SEND_CSD */
#define CMD10   10  /* SEND_CID */
#define CMD12   12  /* STOP_TRANSMISSION */
#define CMD16   16  /* SET_BLOCKLEN */
#define CMD17   17  /* READ_SINGLE_BLOCK */
#define CMD18   18  /* READ_MULTIPLE_BLOCK */
#define CMD23   23  /* SET_BLOCK_COUNT (ACMD) */
#define CMD24   24  /* WRITE_BLOCK */
#define CMD25   25  /* WRITE_MULTIPLE_BLOCK */
#define CMD55   55  /* APP_CMD */
#define CMD58   58  /* READ_OCR */
#define ACMD41  41  /* SEND_OP_COND (SD) */

/* ===================== 私有宏 ===================== */
#define SD_CS_HIGH()   GPIO_SetBits(SD_SPI_CS_PORT,  SD_SPI_CS_PIN)
#define SD_CS_LOW()    GPIO_ResetBits(SD_SPI_CS_PORT, SD_SPI_CS_PIN)

#define SD_SPI_SLOW_PRESCALER  SPI_BaudRatePrescaler_256  /* 187.5kHz */
#define SD_SPI_FAST_PRESCALER  SPI_BaudRatePrescaler_4    /* 12MHz */

/* 下面三个计数只在 DWT 周期计数器不可用时作为兜底(见 sd_timeout_cycles),
 * 正常路径的超时按真实时间算。
 *
 * 原来它们是唯一的超时机制,而同一个数字代表的真实时间随 SPI 时钟变化:
 *   初始化 187.5 kHz → 20000 次 ≈ 854 ms
 *   运行中 12 MHz    → 20000 次 ≈ 14~20 ms
 * 偏偏"等卡写完一个块的 busy"只发生在 12 MHz 那个阶段,也就是预算最小的
 * 阶段。而 SD 规范允许卡写完块后把 DO 拉低到 250 ms,所以这个预算偏小,
 * 表现为"数据能写、紧接其后的元数据单扇区写失败"。 */
#define SD_INIT_TIMEOUT   1000
#define SD_READ_TIMEOUT   20000
#define SD_WRITE_TIMEOUT  20000

/* 等卡 busy / 等响应 token 的时间上限。规范上限 250 ms,这里留一倍余量。 */
#define SD_BUSY_TIMEOUT_MS  500U

/* SD_SendCmdEx 回传的命令阶段子状态。0xFF 作为 R1 有两种来源,混成一个值
 * 会让日志完全无法解释:一个是卡 busy 选不中,一个是命令被吞。 */
#define SD_CMD_ST_OK      0
#define SD_CMD_ST_SELECT  1   /* SD_Select 失败:卡一直 busy,选不中 */
#define SD_CMD_ST_NO_R1   2   /* 命令已上总线,但卡一个字节都没回 */

/* SD_XmitData 的失败阶段。原来只返回 0/1,看不出是卡 busy 超时还是卡明确
 * 拒绝了数据块,而这两种原因的修法完全不同。 */
#define SD_XMIT_OK        0
#define SD_XMIT_BUSY      1   /* 进入时卡一直 busy,等到超时 */
#define SD_XMIT_NO_TOKEN  2   /* 数据块已发完,但等不到数据响应 token */
#define SD_XMIT_REJECTED  3   /* 收到响应但不是"接受"(0x05),如 CRC 错/写错 */
#define SD_CMD_REJECTED   4   /* 命令阶段失败(R1 非 0,或根本没收到 R1) */

/* ===================== 私有变量 ===================== */
static uint8_t  s_sdType = 0;      /* 卡类型 SD_CT_* */
static uint8_t  s_inited = 0;      /* 协议初始化完成 */
static DSTATUS  s_status = STA_NOINIT;
static DWORD    s_sectorCount = 0; /* 卡总扇区数 (CSD 解析) */

/* 连续写失败计数:FatFs 上层会把同一个扇区反复重发,逐次打印会淹没串口。 */
static uint32_t s_write_fail_streak;

/* 本次事务的超时上限,单位 CPU 周期。返回 0 表示 DWT 周期计数器没在跑,
 * 调用方必须退回固定的迭代计数——否则 CYCCNT 恒为 0、比较永远不成立,
 * 等待循环会退化成死循环。DWT 由 BootTask 在任何 SD 事务之前使能
 * (app/tasks/task_boot.c)。 */
static uint32_t sd_timeout_cycles(void)
{
    uint32_t cycles_per_ms = SystemCoreClock / 1000U;
    if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) == 0U || cycles_per_ms == 0U) {
        return 0U;
    }
    return cycles_per_ms * SD_BUSY_TIMEOUT_MS;
}

/* ===================== SD 协议层 ===================== */

/* 等待卡就绪: 返回 0=就绪 1=超时
 * 超时优先按真实时间算,DWT 不可用时才退回 fallback 次迭代。 */
static int SD_WaitReady(uint32_t fallback)
{
    uint8_t d;
    uint32_t start = DWT->CYCCNT;
    uint32_t limit = sd_timeout_cycles();

    for (;;) {
        d = SPI4_RW(0xFF);
        if (d == 0xFF) return 0;
        if (limit != 0U) {
            if ((uint32_t)(DWT->CYCCNT - start) >= limit) return 1;
        } else if (--fallback == 0U) {
            return 1;
        }
    }
}

static void SD_Deselect(void)
{
    SD_CS_HIGH();
    SPI4_RW(0xFF);   /* 额外 8 时钟释放 DO */
}

/* 选卡并等待就绪, 返回 1=成功 0=失败
 * 关键: 先拉高 CS 再拉低, 保证每个命令事务都有 CS 下降沿。
 * SD SPI 规范要求每个命令以 CS 下降沿开始; 若 CS 持续为低
 * (背靠背命令), 部分 v2 卡的命令状态机不重置, CMD8/ACMD41 无响应。 */
static int SD_Select(void)
{
    SD_CS_HIGH();
    SPI4_RW(0xFF);      /* CS 高期间 8 时钟, 让卡释放 MISO */
    SD_CS_LOW();
    SPI4_RW(0xFF);      /* CS 低后 8 时钟, 卡开始驱动 MISO */
    if (SD_WaitReady(SD_READ_TIMEOUT)) {
        SD_Deselect();
        return 0;
    }
    return 1;
}

/* 发送命令, 返回 R1。
 *
 * 返回值 0xFF 有两种完全不同的来源,混在一起就无法解释日志:
 *   - SD_Select() 失败:卡一直把 DO 拉低(busy),根本选不中
 *   - R1 轮询超时:命令已经发上总线,卡一个字节都没回
 * 两者都指向"卡不应答",但一个是忙死、一个是命令被吞。cmd_status 回传
 * 具体是哪种;cmd_status 非 0 时 R1 无意义。 */
static uint8_t SD_SendCmdEx(uint8_t cmd, uint32_t arg, int *cmd_status)
{
    uint8_t buf[6];
    uint8_t r1;
    uint32_t n;

    if (cmd_status != NULL) *cmd_status = SD_CMD_ST_OK;

    if (SD_Select() == 0) {
        if (cmd_status != NULL) *cmd_status = SD_CMD_ST_SELECT;
        return 0xFF;
    }

    buf[0] = (uint8_t)(0x40 | cmd);
    buf[1] = (uint8_t)(arg >> 24);
    buf[2] = (uint8_t)(arg >> 16);
    buf[3] = (uint8_t)(arg >> 8);
    buf[4] = (uint8_t)(arg);
    buf[5] = 0x01;
    if (cmd == CMD0)      buf[5] = 0x95;   /* CMD0 CRC */
    else if (cmd == CMD8) buf[5] = 0x87;   /* CMD8 CRC */

    SPI4_TxMulti(buf, 6);

    if (cmd == CMD12) SPI4_RW(0xFF);       /* CMD12 后补 1 字节 */

    n = SD_INIT_TIMEOUT;
    do {
        r1 = SPI4_RW(0xFF);
    } while ((r1 & 0x80) && --n);

    if ((r1 & 0x80) != 0U && cmd_status != NULL) {
        *cmd_status = SD_CMD_ST_NO_R1;
    }
    return r1;
}

static uint8_t SD_SendCmd(uint8_t cmd, uint32_t arg)
{
    return SD_SendCmdEx(cmd, arg, NULL);
}

/* 发送应用命令。CMD55 在卡仍处于 idle 时合法返回 0x01;旧代码把它当成
 * 失败并被 || 短路,结果 ACMD41 从未真正上总线,卡永远停在 idle。
 * 两个命令各自结束 CS 事务,兼容要求命令间存在 CS 高电平的 SD 卡。 */
static uint8_t SD_SendAppCmd(uint8_t acmd, uint32_t arg)
{
    uint8_t r1 = SD_SendCmd(CMD55, 0);
    SD_Deselect();
    if (r1 > SD_R1_IDLE) {
        return r1;
    }

    r1 = SD_SendCmd(acmd, arg);
    SD_Deselect();
    return r1;
}

/* 接收数据块 (含 0xFE 起始令牌 + 2 CRC), 返回 0=OK 1=ERR
 * 等 token 同样按真实时间计时:读也可能紧跟在一次写的 busy 之后。 */
static int SD_RecvData(uint8_t *buff, uint16_t btr)
{
    uint8_t token;
    uint32_t start = DWT->CYCCNT;
    uint32_t limit = sd_timeout_cycles();
    uint32_t n = SD_READ_TIMEOUT;

    for (;;) {
        token = SPI4_RW(0xFF);
        if (token == 0xFE) break;
        if (token != 0xFF) return 1;   /* 非起始、非空闲 → 错误 token */
        if (limit != 0U) {
            if ((uint32_t)(DWT->CYCCNT - start) >= limit) return 1;
        } else if (--n == 0U) {
            return 1;
        }
    }

    SPI4_RxMulti(buff, btr);
    SPI4_RW(0xFF);     /* CRC16 高 */
    SPI4_RW(0xFF);     /* CRC16 低 */
    return 0;
}

/* 发送数据块, token=0xFE(单块)/0xFC(多块)。
 * 返回 SD_XMIT_* 阶段码,并把卡的数据响应写进 resp_out(0xFF = 没收到)。 */
static int SD_XmitData(const uint8_t *buff, uint8_t token, uint8_t *resp_out)
{
    uint8_t resp;
    uint32_t n = SD_INIT_TIMEOUT;

    if (resp_out != NULL) *resp_out = 0xFFU;

    if (SD_WaitReady(SD_WRITE_TIMEOUT) != 0) return SD_XMIT_BUSY;

    SPI4_RW(token);
    SPI4_TxMulti(buff, 512);
    SPI4_RW(0xFF);     /* 伪 CRC */
    SPI4_RW(0xFF);

    /* 数据响应 token 在 CRC 之后才出现,卡可以再拖几个字节时钟。原来只读
     * 一次就下结论:读到 0xFF(空闲线)直接算失败,卡稍慢一点就被误判。 */
    do {
        resp = SPI4_RW(0xFF);
    } while (resp == 0xFFU && --n != 0U);

    if (resp_out != NULL) *resp_out = resp;
    if (resp == 0xFFU) return SD_XMIT_NO_TOKEN;

    /* 0x05 = 接受;0x0B = CRC 错;0x0D = 写错 */
    return ((resp & 0x1FU) == 0x05U) ? SD_XMIT_OK : SD_XMIT_REJECTED;
}

/* 读取 CSD 并解析总扇区数 (512B 单位)。返回 0=OK */
static int SD_ReadCapacity(DWORD *pSectors)
{
    uint8_t csd[16];
    uint32_t c_size, c_size_mult, read_bl_len;
    uint64_t blocks;

    if (SD_SendCmd(CMD9, 0) != 0 || SD_RecvData(csd, 16)) {
        SD_Deselect();
        return 1;
    }
    SD_Deselect();

    if ((csd[0] & 0xC0) == 0x40) {            /* CSD v2.0 (SDHC/SDXC) */
        c_size = ((uint32_t)csd[7] << 16) | ((uint32_t)csd[8] << 8) | csd[9];
        blocks = ((uint64_t)c_size + 1) << 10;   /* (c_size+1)*1024 KB / 512B */
    } else {                                /* CSD v1.0 (SDSC) */
        read_bl_len  = csd[5] & 0x0F;
        c_size       = ((uint32_t)(csd[6] & 0x03) << 10) |
                       ((uint32_t)csd[7] << 2) | ((csd[8] & 0xC0) >> 6);
        c_size_mult  = ((uint32_t)(csd[9] & 0x03) << 1) | ((csd[10] & 0x80) >> 7);
        blocks = ((uint64_t)(c_size + 1) << (c_size_mult + 2 + read_bl_len))
                 >> 9;   /* 转 512B 扇区 */
    }

    *pSectors = (blocks > 0xFFFFFFFFUL) ? 0xFFFFFFFFUL : (DWORD)blocks;
    return 0;
}

/* ===================== 公有接口 ===================== */

DSTATUS SD_SPI_Initialize(void)
{
    uint8_t n, r1, ocr[4];
    uint8_t ty = 0;
    uint32_t ntry;

    /* 幂等: 协议已成功且卡仍在位, 直接返回就绪。
     * f_mount 会再次调用 disk_initialize, 避免重复跑 CMD0~CMD9 握手。 */
    if (s_inited && SD_SPI_IsInserted()) {
        s_status = 0;
        return s_status;
    }

    BSP_LOGI("[SD] init start: inserted=%d", (int)SD_SPI_IsInserted());
    if (!SD_SPI_IsInserted()) {
        s_status = STA_NOINIT | STA_NODISK;
        s_inited = 0;
        s_sdType = 0;
        s_sectorCount = 0;
        BSP_LOGW("[SD] init FAIL: card NOT inserted (DET pin high)");
        return s_status;
    }

    SD_SPI_PowerOn();
    SystemDelay(50);   /* 上电稳定:10ms 对部分卡(尤其热重启)不足,曾致 CMD0 失败 */

    SPI4_SetPrescaler(SD_SPI_SLOW_PRESCALER);
    SD_CS_HIGH();
    for (n = 0; n < 10; n++) SPI4_RW(0xFF);   /* >=74 时钟 */
    SD_Deselect();

    /* 进入 Idle: CMD0 */
    ntry = SD_INIT_TIMEOUT;
    while ((r1 = SD_SendCmd(CMD0, 0)) != SD_R1_IDLE) {
        SD_Deselect();
        if (!--ntry) {
            BSP_LOGE("[SD] init FAIL: CMD0 timeout, last R1=0x%02X", r1);
            s_status = STA_NOINIT; return s_status;
        }
    }
    BSP_LOGI("[SD] CMD0 OK (idle), R1=0x%02X", r1);

    /* CMD8 检查电压 2.7-3.6V */
    r1 = SD_SendCmd(CMD8, 0x1AA);
    if (r1 == SD_R1_IDLE) {
        /* v2.x */
        for (n = 0; n < 4; n++) ocr[n] = SPI4_RW(0xFF);
        SD_Deselect();
        BSP_LOGI("[SD] CMD8 OK (v2), OCR=%02X %02X %02X %02X",
                 ocr[0], ocr[1], ocr[2], ocr[3]);
        if (ocr[3] == 0xAA) {
            ntry = SD_INIT_TIMEOUT;
            while ((r1 = SD_SendAppCmd(ACMD41, 0x40000000U)) != 0) {
                if (!--ntry) {
                    BSP_LOGE("[SD] init FAIL: ACMD41 timeout, last R1=0x%02X", r1);
                    s_status = STA_NOINIT; return s_status;
                }
            }
            BSP_LOGI("[SD] ACMD41 OK, R1=0x%02X (tries left=%lu)", r1, (unsigned long)ntry);
            r1 = SD_SendCmd(CMD58, 0);
            if (r1 == 0) {
                for (n = 0; n < 4; n++) ocr[n] = SPI4_RW(0xFF);
                SD_Deselect();
                /* OCR bit30(CCS) 单独表示块寻址;bit31 只是上电完成状态。 */
                ty = (ocr[0] & 0x40U) ? (SD_CT_SD2 | SD_CT_BLOCK) : SD_CT_SD2;
                BSP_LOGI("[SD] CMD58 OCR=%02X %02X %02X %02X, type=%s",
                         ocr[0], ocr[1], ocr[2], ocr[3],
                         (ty & SD_CT_BLOCK) ? "SDHC/SDXC(block)" : "SD2(byte)");
            } else {
                SD_Deselect();
                BSP_LOGW("[SD] CMD58 failed, R1=0x%02X", r1);
            }
        } else {
            BSP_LOGE("[SD] init FAIL: CMD8 echo mismatch (got 0x%02X, expect 0xAA)", ocr[3]);
            s_status = STA_NOINIT; return s_status;
        }
    } else {
        /* v1.x 或 MMC */
        SD_Deselect();
        BSP_LOGI("[SD] CMD8 R1=0x%02X -> try v1/MMC path", r1);
        r1 = SD_SendAppCmd(ACMD41, 0);
        ty = (r1 <= SD_R1_IDLE) ? SD_CT_SD1 : SD_CT_MMC;
        ntry = SD_INIT_TIMEOUT;
        if (ty == SD_CT_SD1) {
            while ((r1 = SD_SendAppCmd(ACMD41, 0)) != 0) {
                if (!--ntry) { ty = 0; break; }
            }
        } else if (ty == SD_CT_MMC) {
            while (SD_SendCmd(CMD1, 0) != 0) {
                SD_Deselect();
                if (!--ntry) { ty = 0; break; }
            }
        }
    }

    SD_Deselect();
    if (ty == 0) {
        BSP_LOGE("[SD] init FAIL: card type detection failed (v1/MMC path)");
        s_status = STA_NOINIT; return s_status;
    }

    /* SDHC/SDXC 固定为 512 字节块且不接受 CMD16;仅字节寻址卡设置块长。 */
    if ((ty & SD_CT_BLOCK) == 0U) {
        if (SD_SendCmd(CMD16, 512) != 0) {
            BSP_LOGE("[SD] init FAIL: CMD16 (SET_BLOCKLEN) failed");
            SD_Deselect(); s_status = STA_NOINIT; return s_status;
        }
        SD_Deselect();
        BSP_LOGI("[SD] CMD16 OK (blocklen=512)");
    } else {
        BSP_LOGI("[SD] CMD16 skipped (SDHC/SDXC fixed 512-byte blocks)");
    }

    /* 提速 */
    SPI4_SetPrescaler(SD_SPI_FAST_PRESCALER);

    /* 解析 CSD 取得总扇区数 */
    if (SD_ReadCapacity(&s_sectorCount) != 0) {
        BSP_LOGE("[SD] init FAIL: CMD9 (READ_CSD) failed - no data token 0xFE");
        s_status = STA_NOINIT; return s_status;
    }
    BSP_LOGI("[SD] CMD9 CSD OK, sectors=%lu", (unsigned long)s_sectorCount);

    s_sdType = ty;
    s_inited = 1;
    s_status = 0;
    BSP_LOGI("[SD] init SUCCESS: type=0x%02X, sectors=%lu", (unsigned)ty, (unsigned long)s_sectorCount);
    return s_status;
}

DWORD SD_SPI_GetSectorCount(void)
{
    return s_sectorCount;
}

DSTATUS SD_SPI_Status(void)
{
    if (!SD_SPI_IsInserted()) {
        /* 拔卡会丢失整个协议会话;再次插入必须重新 CMD0..CMD9,不能只
         * 清 STA_NODISK 后把尚未初始化的新卡误报为 ready。 */
        s_status = STA_NOINIT | STA_NODISK;
        s_inited = 0;
        s_sdType = 0;
        s_sectorCount = 0;
        return s_status;
    }
    if (!s_inited) {
        s_status = STA_NOINIT;
    } else {
        s_status = 0;
    }
    return s_status;
}

DRESULT SD_SPI_ReadSectors(BYTE *buff, DWORD sector, UINT count)
{
    DRESULT res = RES_OK;
    DWORD   sec = (s_sdType & SD_CT_BLOCK) ? sector : (sector << 9);

    if (s_status & STA_NOINIT) return RES_NOTRDY;

    if (count == 1) {
        if (SD_SendCmd(CMD17, sec) != 0 || SD_RecvData(buff, 512)) res = RES_ERROR;
        SD_Deselect();
    } else {
        if (SD_SendCmd(CMD18, sec) == 0) {
            do {
                if (SD_RecvData(buff, 512)) { res = RES_ERROR; break; }
                buff += 512;
            } while (--count);
        } else {
            res = RES_ERROR;
        }
        SD_SendCmd(CMD12, 0);
        SD_Deselect();
    }
    return res;
}

/* 单次写入尝试。sec 已经按卡类型换算过(块寻址 / 字节寻址)。
 * 返回失败阶段(SD_XMIT_OK = 成功),并回传全部诊断信息。 */
static int sd_write_once(const BYTE *buff, DWORD sec, UINT count,
                         uint8_t *resp, int *cmd_status, uint8_t *r1,
                         UINT *done)
{
    int stage;

    *resp = 0xFFU;
    *r1 = 0xFFU;
    *done = 0U;
    *cmd_status = SD_CMD_ST_OK;

    if (count == 1) {
        *r1 = SD_SendCmdEx(CMD24, sec, cmd_status);
        stage = (*r1 != 0U) ? SD_CMD_REJECTED : SD_XmitData(buff, 0xFE, resp);
        if (stage == SD_XMIT_OK) *done = 1U;
        SD_Deselect();
    } else {
        /* ACMD23 只是可选的预擦除提示;不支持时仍应继续尝试 CMD25。 */
        (void)SD_SendAppCmd(CMD23, count);
        *r1 = SD_SendCmdEx(CMD25, sec, cmd_status);
        if (*r1 == 0U) {
            for (;;) {
                stage = SD_XmitData(buff, 0xFC, resp);
                if (stage != SD_XMIT_OK) break;
                buff += 512;
                ++*done;
                if (--count == 0U) break;
            }
            if (stage == SD_XMIT_OK) {
                /* 停止令牌必须等卡结束编程(DO 拉高)之后再发。
                 *
                 * SD_XmitData 是一读到数据响应 token 就返回的,而卡在发出响应
                 * 之后立刻拉低 DO 开始编程;卡在整个 busy 期间会忽略 MOSI 上的
                 * 一切 —— 紧跟着发出去的 0xFD 会被直接丢掉,多块写收不了尾。
                 * 中间那些块之所以没出事,是因为下一次 SD_XmitData 开头的
                 * SD_WaitReady 把 busy 兜住了;只有最后这个令牌后面没有防护,
                 * 所以必然被吞,卡停在多块写状态。
                 *
                 * 规范时序:数据块 → 数据响应 → busy(DO 低) → DO 高 → 停止令牌。 */
                if (SD_WaitReady(SD_WRITE_TIMEOUT) != 0) {
                    stage = SD_XMIT_BUSY;
                } else {
                    SPI4_RW(0xFD);
                }
            }
            /* 中途失败时不做额外动作:后面紧跟的 SD_Deselect()(CS 拉高)
             * 就是 SPI 模式下的中止手段,不需要再加 CMD12。 */
        } else {
            stage = SD_CMD_REJECTED;
        }
        SD_Deselect();
    }
    return stage;
}

DRESULT SD_SPI_WriteSectors(const BYTE *buff, DWORD sector, UINT count)
{
    DWORD   sec = (s_sdType & SD_CT_BLOCK) ? sector : (sector << 9);
    UINT    done = 0U;
    uint8_t resp = 0xFFU;
    uint8_t r1 = 0xFFU;
    int     cmd_status = SD_CMD_ST_OK;
    int     stage;

    if (s_status & STA_NOINIT) return RES_NOTRDY;

    stage = sd_write_once(buff, sec, count, &resp, &cmd_status, &r1, &done);
    if (stage == SD_XMIT_OK) {
        s_write_fail_streak = 0U;
        return RES_OK;
    }

    ++s_write_fail_streak;
    if (s_write_fail_streak <= 1U || (s_write_fail_streak % 16U) == 0U) {
        /* 每个字段都用于区分原因:
         *   stage = 1 卡 busy 超时 / 2 数据块发完没等到响应 token /
         *           3 卡回了响应但不是"接受"(resp 0x0B=CRC错 0x0D=写错)/
         *           4 命令阶段失败,看 cmd:1=选卡时卡 busy、2=没收到 R1
         *   r1 = 卡对 CMD24/CMD25 的真实应答(0x20 写保护、0x40 参数错、
         *        0x04 非法命令)
         * 没有这几个数,上层只能看到 FatFs 的 result=1,无从定位。 */
        BSP_LOGE("write failed: stage=%d cmd=%d r1=0x%02X resp=0x%02X "
                 "sector=%lu count=%u done=%u streak=%lu",
                 stage, cmd_status, (unsigned)r1, (unsigned)resp,
                 (unsigned long)sec, (unsigned)count, (unsigned)done,
                 (unsigned long)s_write_fail_streak);
    }
    return RES_ERROR;
}

DRESULT SD_SPI_ReadSectors(BYTE *buff, DWORD sector, UINT count)
{
    DRESULT res = RES_OK;
    DWORD   sec = (s_sdType & SD_CT_BLOCK) ? sector : (sector << 9);

    if (s_status & STA_NOINIT) return RES_NOTRDY;

    if (count == 1) {
        if (SD_SendCmd(CMD17, sec) != 0 || SD_RecvData(buff, 512)) res = RES_ERROR;
        SD_Deselect();
    } else {
        if (SD_SendCmd(CMD18, sec) == 0) {
            do {
                if (SD_RecvData(buff, 512)) { res = RES_ERROR; break; }
                buff += 512;
            } while (--count);
        } else {
            res = RES_ERROR;
        }
        SD_SendCmd(CMD12, 0);
        SD_Deselect();
    }
    return res;
}

DRESULT SD_SPI_WriteSectors(const BYTE *buff, DWORD sector, UINT count)
{
    DRESULT res = RES_OK;
    DWORD   sec = (s_sdType & SD_CT_BLOCK) ? sector : (sector << 9);

    if (s_status & STA_NOINIT) return RES_NOTRDY;

    if (count == 1) {
        if (SD_SendCmd(CMD24, sec) != 0 || SD_XmitData(buff, 0xFE)) res = RES_ERROR;
        SD_Deselect();
    } else {
        /* ACMD23 只是可选的预擦除提示;不支持时仍应继续尝试 CMD25。 */
        (void)SD_SendAppCmd(CMD23, count);
        if (SD_SendCmd(CMD25, sec) == 0) {
            do {
                if (SD_XmitData(buff, 0xFC)) { res = RES_ERROR; break; }
                buff += 512;
            } while (--count);
            SPI4_RW(0xFD);   /* 停止令牌 */
        } else {
            res = RES_ERROR;
        }
        SD_Deselect();
    }
    return res;
}

Fatfs文件系统

一般用在读写SD卡,u盘等

主要文件:

ff.c
ff.h
ffconf.h
diskio.h
ffsystem.c
ffunicode.c
必须实现diskio.c对应diskio.h文件

FatFs会调用:

disk_initialize()
disk_status()
disk_read()
disk_write()
disk_ioctl()

使用FatFs的基本流程

移植
复制FatFs核心
+ 配置ffconf.h
+ 实现diskio.c
+ 实现SD卡底层驱动
+ 实现get_fattime
+ 加入构建系统
+ 初始化并f_mount

完成这些以后,才可以直接调用:

f_open()
f_read()
f_write()
f_close()
f_mkfs()

Diskio.c

/*-----------------------------------------------------------------------*/
/* Low level disk I/O module for FatFs  -  SD Card (SPI4) drive           */
/* 盘0 = SD 卡 (SPI4 模式), 作为唯一 FatFs 卷 (存储相片/USB 导出)         */
/*-----------------------------------------------------------------------*/
#include "ff.h"
#include "diskio.h"			/* FatFs lower layer API */
#include "string.h"
#include "mh2457.h"
#include "sd_spi.h"         /* SD_SPI_GetSectorCount */
#include "rtc_calendar.h"   /* BSP RTC calendar / FAT timestamp */
#include <stdbool.h>

/* 物理盘编号:0 = SD 卡 */
#define SD_PDRV       0

/* U盘模式互斥:USB 挂载 PC 时 SD 卡 FAT 卷独占给 USB MSD,
 * 拒绝一切设备侧读/写,防止本机与 PC 双写同一卷损坏 FAT、或任务上下文与
 * USB ISR 重入 SPI 总线。USB 自身在 ISR 内持锁(s_usbSdLocked)时放行。
 * 注意:此处跨层引用 usb_msd.c 的符号,用 extern 避免头路径耦合。
 * (与参考工程 JW-MCU-TM30/BSP/Source/diskio.c 的实现一致) */
extern bool usb_msd_is_active(void);
extern bool usb_msd_sd_locked(void);

/* SPI4/SD transaction lock shared by task-side FatFs and USB IRQ callbacks.
 * s_usbSdLocked only identifies the caller; it is not itself a lock.  Without
 * this guard USB1_IRQHandler could preempt an in-progress CMD17/CMD24 sequence
 * and inject a second SD command into the same SPI transaction. */
static volatile bool s_sdIoBusy = false;

static bool sd_io_try_lock(void)
{
	bool acquired = false;
	uint32_t primask = __get_PRIMASK();
	__disable_irq();
	if (!s_sdIoBusy && (!usb_msd_is_active() || usb_msd_sd_locked())) {
		s_sdIoBusy = true;
		acquired = true;
	}
	__set_PRIMASK(primask);
	return acquired;
}

static void sd_io_unlock(void)
{
	uint32_t primask = __get_PRIMASK();
	__disable_irq();
	s_sdIoBusy = false;
	__set_PRIMASK(primask);
}

/*-----------------------------------------------------------------------*/
/* 获取设备状态                                                          */
/*-----------------------------------------------------------------------*/
DSTATUS disk_status(
	BYTE pdrv		/* 物理编号 */
)
{
	if (pdrv != SD_PDRV) {
		return STA_NOINIT;
	}
	return SD_SPI_Status();
}

/*-----------------------------------------------------------------------*/
/* 设备初始化                                                            */
/*-----------------------------------------------------------------------*/
DSTATUS disk_initialize(
	BYTE pdrv		/* 物理编号 */
)
{
	DSTATUS result;

	if (pdrv != SD_PDRV) {
		return STA_NOINIT;
	}

	/* SPI4 + GPIO + 电源已在 SD_SPI_Init() 中完成 (main 启动阶段调用)。
	 * 此处执行 SD 卡协议初始化。
	 *
	 * 与 disk_read/disk_write 共用同一把锁:SD 协议初始化同样是一段完整的
	 * 总线事务(CMD0..CMD9),U盘模式期间本机一律不得发起。不加锁的话,
	 * 安全性就只能依赖"调用它的时候 USB 恰好没在读"这种隐含前提。 */
	if (!sd_io_try_lock()) {
		return STA_NOINIT;
	}
	result = SD_SPI_Initialize();
	sd_io_unlock();
	return result;
}

/*-----------------------------------------------------------------------*/
/* 读扇区                                                                */
/*-----------------------------------------------------------------------*/
DRESULT disk_read(
	BYTE pdrv,		/* 物理编号 */
	BYTE* buff,		/* 数据缓冲区 */
	DWORD sector,	/* 起始扇区 */
	UINT count		/* 扇区个数 */
)
{
	if (pdrv != SD_PDRV || count == 0) {
		return RES_PARERR;
	}

	/* 原子地完成所有权检查和 SPI 事务加锁;既阻止本机在 UMS 期间进入,
	 * 也阻止 USB IRQ 嵌套进已经开始的本机 SD 命令。 */
	if (!sd_io_try_lock()) {
		return RES_NOTRDY;
	}

	DRESULT result = SD_SPI_ReadSectors(buff, sector, count);
	sd_io_unlock();
	return result;
}

/*-----------------------------------------------------------------------*/
/* 写扇区                                                                */
/*-----------------------------------------------------------------------*/
DRESULT disk_write(
	BYTE pdrv,				/* 物理编号 */
	const BYTE* buff,		/* 待写入数据 */
	DWORD sector,			/* 起始扇区 */
	UINT count				/* 扇区个数 */
)
{
	if (pdrv != SD_PDRV || count == 0) {
		return RES_PARERR;
	}

	if (!sd_io_try_lock()) {
		return RES_NOTRDY;
	}

	DRESULT result = SD_SPI_WriteSectors(buff, sector, count);
	sd_io_unlock();
	return result;
}

/*-----------------------------------------------------------------------*/
/* 其他控制                                                              */
/*-----------------------------------------------------------------------*/
DRESULT disk_ioctl(
	BYTE pdrv,		/* 物理编号 */
	BYTE cmd,		/* 控制命令 */
	void* buff		/* 结果缓冲区 */
)
{
	if (pdrv != SD_PDRV) {
		return RES_PARERR;
	}

	switch (cmd) {
		case GET_SECTOR_SIZE:	/* 返回扇区大小 (固定 512) */
			*(WORD*)buff = 512;
			return RES_OK;
		case GET_SECTOR_COUNT:	/* 返回卡总扇区数 (CSD 解析) */
			*(DWORD*)buff = SD_SPI_GetSectorCount();
			return RES_OK;
		case GET_BLOCK_SIZE:	/* 返回擦除块大小(以扇区为单位) */
			*(DWORD*)buff = 8;   /* SD 卡典型 4KB/512B */
			return RES_OK;
		case CTRL_SYNC:
			return RES_OK;
		case CTRL_TRIM:
			return RES_OK;
		default:
			return RES_PARERR;
	}
}

/*-----------------------------------------------------------------------*/
/* 获取当前 RTC 日历时间                                                  */
/*-----------------------------------------------------------------------*/
__WEAK DWORD get_fattime(void)
{
	return (DWORD)RtcCalendar_GetFatTime();
}

应用调用f_read()          // app.c
    ↓
FatFs内部调用disk_read()  // ff.c
    ↓
链接到我们实现的disk_read() // diskio.c
    ↓
调用SD_SPI_ReadSectors()  // sp_protol.c  
    ↓
通过SPI读取SD卡  		  // sd_spi.c

006. USB

usb模式主要是看谁负责控制
名称全称 / 常见形态具体作用(开发板充当的角色)
MSD / MSC大容量存储类(Mass Storage Class)这就是U盘模式。开发板模拟成一块移动硬盘/U盘,电脑可以读写板载的SD卡或Flash芯片。
VCP虚拟串口(Virtual COM Port)开发板模拟成一个USB转串口设备。插上电脑后,会生成一个“COM口”,用于MCU与电脑间传输调试信息或数据(常基于CDC类实现)。
CDC通信设备类(Communication Device Class)一个大的通信类框架,VCP(虚拟串口) 就是CDC中最常用的子类(CDC-ACM)。另外CDC也支持模拟网卡(RNDIS/ECM)等。
HID人机交互设备(Human Interface Device)开发板模拟成键盘、鼠标、游戏手柄或自定义HID设备。特点是免驱(即插即用),适合传输小数据量、低延迟的控制指令。
DFU设备固件升级(Device Firmware Upgrade)开发板进入烧录/升级模式。插上电脑后,电脑会识别到一个DFU设备,可以通过专用软件(如STM32CubeProgrammer)给开发板烧写新的固件程序。
UVC-DisplayUSB视频类 / 显示(Video Class / Display)这里需要分两种情况: 1. UVC(摄像头):开发板模拟成USB摄像头,把图像数据传回电脑(如OpenMV)。 2. Display(显示):通常指开发板模拟成USB外接显示器(类似显卡扩展坞),电脑把屏幕画面压缩传输给开发板显示。
  • 选 Device 时,你才会看到 HID、MSC、CDC、DFU 这些选项(代表你“模拟成什么”)。
  • 选 Host 时,你看到的选项通常是 MSC(Host)、HID(Host)、CDC(Host)(代表你“能识别并插入什么外设”,比如读取U盘或键盘)。
usb1

我们只需要移植MSD(UMS)功能。参看官方案例。进行迁移放入到middleware文件夹里面。

移植USB功能我们不需要考虑那么多。只需要给到单片机USB相关配置给这些文件就好了。

步骤 1:确认 DWC2 兼容 & 收集硬件参数

从新 MCU 数据手册拿到:USB 控制器寄存器基地址、USB 中断号、端点数、DM/DP 引脚、48MHz 时钟来源。

步骤 2:新建 Target/USBTarget.h

#ifndef __USB_TARGET_CONFIG_MH2457_H__
#define __USB_TARGET_CONFIG_MH2457_H__

#define USE_USBESL_CORE_ZOFFY 1

// Zoffy Features
#define FEATURE_USBCORE_ZOFFY_DYNAMIC_DMA_MODE 1

#define FEATURE_USBCORE_ZOFFY_MAX_EP_COUNT (6)

#include "mh2457.h"

// USB Core Base Address
#define USB_ZOFFY1_BASE 0x50000000
#define USB_ZOFFY1_IRQ  USB1_IRQn

#define USB_ZOFFY_BASE USB_ZOFFY1_BASE
#define USB_ZOFFY_IRQ  USB_ZOFFY1_IRQ

#ifndef USB_IO_CONFIG_DATA
#define USB_IO_CONFIG_DATA MakeIOConfig(IOModeAlternate, GPIO_AF_USB, IOPullNone, IOSpeedHigh, IODriveHigh)
#endif

#ifndef USB_IO_CONFIG_ID
#define USB_IO_CONFIG_ID MakeIOConfig(IOModeAlternate, GPIO_AF_USB, IOPullUp, IOSpeedMedium, IODriveMedium)
#endif

#ifndef USB_IO_CONFIG_VBUS
#define USB_IO_CONFIG_VBUS MakeIOConfig(IOModeInput, 0, IOPullNone, IOSpeedMedium, IODriveMedium)
#endif

#endif

新建 Target/USBTarget.c

extern void USBTargetConstractor(USBHALStruct* hal);   // 设能力位(DMA/Device/Host)
extern void USBTargetDelayUs(uint32_t us);             // → SystemDelayUs
extern void USBTargetEnablePhy(...);                   // PHY 上电 / GCFG
extern void USBTargetEnableInterrupt(...);             // NVIC Enable/Disable
extern void USBTargetEnableModule(...);                // ★引脚+48M时钟+外设使能
extern void USBTargetResetModule(...);                 // PeripheralReset
extern uint8_t USBTargetGetSN(...);                    // 序列号(可空)
#include "USBCoreZoffyDefine.h"
#include "USBESL.h"

inline void USBTargetConstractor(USBHALStruct* hal) {
    if (hal->USBBase == 0x50000000) { //USB硬件地址
        hal->IsDMAEnabled    = false; //关闭DMA
        hal->IsDeviceEnabled = true; //设备模式
        hal->IsHostEnabled   = false; //host关闭
        hal->IsSRPEnabled    = false; //SRP关闭
    }
    else {
        hal->IsDMAEnabled    = true;
        hal->IsDeviceEnabled = true;
        hal->IsHostEnabled   = false;
        hal->IsSRPEnabled    = false;
        hal->PhyType         = USBPhyTypeUTMI;
        hal->SpeedConfig     = USBSpeedConfigHigh;
    }
}

inline void USBTargetDelayUs(uint32_t us) {
    SystemDelayUs(us);
}

inline void USBTargetEnablePhy(USBHALStruct* hal, bool isEnable) {
    if (!isEnable || hal->PhyType == USBPhyTypeUTMI) {
        hal->Zoffy.USB->GREGS.GCFG = 0;
    }
    else {
        hal->Zoffy.USB->GREGS.GCFG = BIT(16); // Power ON

        if (!(hal->IsHNPEnabled || hal->IsSRPEnabled))
            hal->Zoffy.USB->GREGS.GCFG |= BIT(21); // NOVUBS
        else
            hal->Zoffy.USB->GREGS.GCFG |= BIT(19) | BIT(18); // VBUS A|B EN

        if (hal->PhyType == USBPhyTypeFSI2C) {
            hal->Zoffy.USB->GREGS.GI2CCTL = //
                BIT(26) |                   // I2C Addr 0x2D
                BIT(23);                    // I2C Enable
        }
        if (hal->USBBase == 0x50000000) {
            hal->Zoffy.MaxFifoSize = 0x140; // Fix FIFO size of USB 1 to 1280 bytes
        }
    }
}

inline void USBTargetEnableInterrupt(USBHALStruct* hal, bool isEnable) {
    IRQn_Type irq = USB1_IRQn;
    if (isEnable) {
        NVIC_EnableIRQ(irq);
    }
    else {
        NVIC_DisableIRQ(irq);
    }
}

inline void USBTargetEnableModule(USBHALStruct* hal, bool isEnable) {
    if (hal->USBBase == 0x50000000) {
        // Configure DM DP Pins
        IOSetup(PA11, USB_IO_CONFIG_DATA);
        IOSetup(PA12, USB_IO_CONFIG_DATA);

        // Configure ID Pin
        if (hal->IsDeviceEnabled && hal->IsHostEnabled) {
            IOSetup(PA10, USB_IO_CONFIG_ID);
        }

        // Configure VBUS Pin
        if (hal->IsSRPEnabled || hal->IsHNPEnabled) {
            IOSetup(PA9, USB_IO_CONFIG_VBUS);
        }
    }

    if (isEnable && (ClockGet(ClockNodeREF) / 1000000 != 48)) {
        ClockMultiply(ClockNodePLL3, ClockRatio(768 / 12.0));
        ClockDivide(ClockNodePLL3R, ClockRatio(768 / 48.0));
        ClockSelect(ClockNodeREF, ClockNodePLL3R);
        ClockEnable(ClockNodePLL3G, true);
    }

    PeripheralEnable(PeripheralUSB1, isEnable);
}

inline void USBTargetResetModule(USBHALStruct* hal) {
    PeripheralReset(PeripheralUSB1);
}
inline uint8_t USBTargetGetSN(uint8_t* sn, uint8_t length) {
    // TODO
    return 0;
}

步骤 4:改 USBESL.h 的 include(USBESL.h:17)

#include "Target/USBTargetMH2457.h"   // → 改为 #include "Target/USBTarget<新MCU>.h"

步骤 5:对齐中断向量名

匹配MCU 启动文件里的向量名:

// usb_msd.c:418
void USB1_IRQHandler(void) {           // ← 必须与新 MCU startup_xxx.s 的向量弱符号同名
    s_usbHAL.Interrupt(&s_usbHAL);
}

步骤 6:配置 NVIC 优先级

NVIC_SetPriority(USB_ZOFFY1_IRQ, BSP_NVIC_PRIO_USB); 
USB的MSD模式和FatFS在操作SD卡,让他们互斥就好了

usb初始化

static USBHALStruct        s_usbHAL;
static USBDeviceStruct     s_usbDevice;
static InterfaceMSDStruct  s_usbMSD;
void usb_msd_init(void)
{
	USBHALStruct*    hal    = &s_usbHAL;
	USBDeviceStruct* device = &s_usbDevice;
	uint32_t         t0 = xTaskGetTickCount();

	/* 设置 USB 中断优先级(见 USB_MSD_IRQ_PRIORITY 注释) */
	NVIC_SetPriority(USB_ZOFFY1_IRQ, USB_MSD_IRQ_PRIORITY);
	NVIC_DisableIRQ(USB_ZOFFY1_IRQ);
	NVIC_ClearPendingIRQ(USB_ZOFFY1_IRQ);

	/* USB HAL 初始化 */
	USBCoreZoffyConstractor(hal, USB_ZOFFY1_BASE);
	hal->Init(hal);

	/* USB 设备描述 */
	USBDeviceConstractor(device);
	device->Manufacturer      = L"JW02";
	device->Product           = L"JW02 Photo U-Disk";
	device->ConfigurationName = L"Default";
	device->MaxPower          = 100;                    /* 100mA */
	device->Attributes.SelfPowered = true;

	/* 固定序列号 */
	s_usbSerialNumber[0] = 'J'; s_usbSerialNumber[1] = 'W';
	s_usbSerialNumber[2] = '0'; s_usbSerialNumber[3] = '2';
	s_usbSerialNumber[4] = 'U'; s_usbSerialNumber[5] = 'D';
	s_usbSerialNumber[6] = '0'; s_usbSerialNumber[7] = 0;
	device->SerialNumber = s_usbSerialNumber;

	device->Init(device, hal, 0x0D28, 0xCCDD, 0x0001);

	usbMSDSetup(device);

	device->Start(device);
	printf("[USB] MSD init OK, total=%lums\n",
	         (unsigned long)(xTaskGetTickCount() - t0));

	/* 保存 device 指针供 UsbMsd_Poll() 查询枚举态(U盘模式互斥信任源) */
	s_usbDev = device;
}

USB的MSD模式和FatFS在操作SD卡,让他们互斥就好了

一、总原则

MSD 模式下,MCU 的角色是"块级透明桥",不是文件系统代理。

  • 它不解析 FAT、不认文件名、不知道什么是"文件"
  • 它只做一件事:把 PC 的"读/写第 N 个扇区"翻译成 SD 卡的 CMD17/CMD18/CMD24
  • 文件系统的事全在 PC 那边(Windows 的 FAT 驱动)

所以"U 盘"= 把 SD 卡的扇区原样透传给电脑。


二、USB 插入 → 是否进入 MSD 模式

                USB 插入
                   │
        ┌──────────┴──────────┐
        │                     │
   VBUS 存在               PC 发起枚举
   (PB5 变高)          GET_DESCRIPTOR → SET_ADDRESS
        │                     → GET_DESCRIPTOR(配置)
        │                     │
   只说明"有电"          SET_CONFIGURATION  ← 握手成功的唯一标志
   充电头也满足                │
   → 不能作判据          ┌─────┴─────┐
        │             是         不是
        │              │           │
        └──► ✗     进入 MSD      ✗ 不进 MSD
                     模式          本机照常拍照

关键:判据是"协议握手",不是"有没有电"。

充电头只会给 VBUS,永远不会发 SET_CONFIGURATION,所以插充电器时本机照常工作,不进 MSD。


三、进入 MSD 模式(握手成功的那一刻)

这一步必须在中断里、几毫秒内完成,因为主机的第一条 SCSI 命令随后就到:

① 置模式标志  →  "本机禁访"立即生效
② 让 MSC 能报"有介质" → 用开机时就已缓存的容量
③ 不动本机 FatFs  →  卸载是 I/O,太慢,会错过主机第一条命令

四、PC 读 SD 卡的完整流程

PC(Windows FAT 驱动)
   │  ① READ CAPACITY(10)
   ▼
USB1 控制器 ──中断──► USB-ESL 协议栈 ──► InterfaceMSD (BOT/SCSI)
   │                                          │
   │                                          ├─ ② 用缓存的 BlockCount 回答
   │  ③ READ(10)  LBA=N, count=M              │
   ▼                                          ▼
                                        usbMSDDiskRead()
                                              │ ④ 打上"USB 正在用总线"标记
                                              ▼
                                        disk_read(0, buf, N, M)
                                              ▼
                                        SD_SPI_ReadSectors()
                                              │ ⑤ CMD17/CMD18 → SPI4
                                              ▼
                                           SD 卡

要点:

点说明
全程同步发生在 USB 中断里MCU 就是个搬运工,没有任务调度参与
中断可能很长一次 READ10 能读几十个块 → 所以 USB 中断优先级要低于图像采集
数据不经过文件系统buf 与扇区直接对拷,MCU 完全不知道这是 FAT 还是目录还是照片

五、PC 写 SD 卡的流程

同一条链,只是命令换成写:

WRITE(10) → usbMSDDiskWrite() → disk_write() → CMD24/CMD25 → SD 卡

六、MSD 模式下本机能做什么

操作允许原因
拍照(写文件)✗两边 FAT 缓存会互相覆盖
格式化✗同上
读文件 / 刷新图库✗只读也会拿到过期目录,会看到不存在的文件
显示卡的总容量✅来自 CSD 缓存,不读 FAT
显示剩余容量⚠️ 只能显示进入前的旧值算剩余要挂载 FAT

禁的是"本机挂载文件系统",不是"本机不能碰卡"。 物理上 MCU 随时能读扇区;不让它读,是因为它的 FAT 缓存已经和 PC 改过的实际内容不一致了。


七、退出 MSD 模式

判据:PB5 物理拔出(去抖后)。 为什么不看协议事件:

  • 本机是自供电设备,拔线时 PC 侧不一定发任何事件(reset / deconfig 都可能没有)
  • PC 上点"弹出" ≠ 拔线,不应该退出

退出动作(顺序不能反):

① 清模式标志        ← 先退出模式,USB 侧立刻拒绝新访问
② f_mount(NULL)     ← 丢弃被 PC 改过的陈旧 FAT/目录缓存
③ disk_initialize() ← 卡可能被 PC 动过,重跑 SD 协议初始化
④ 用本机唯一的 FATFS 对象重挂 + 刷新图库

②③④ 顺序的原因:必须先退出模式再挂载。反过来(还在模式内就挂载)挂载过程中的磁盘读会被自己的互斥层拒绝,永远挂不上。


八、互斥的两层(概念)

层粒度解决什么
模式层整卷决定"这一段时间归谁"——本机所有磁盘调用一律拒绝
事务层单次 SPI 事务防止一次 CMD17/CMD24 被另一端插进来打断

模式层保证不会两个主人同时挂同一个 FAT;事务层保证单条 SD 命令不被切碎。


九、完整状态图

        ┌──────────────────────────────────────────────┐
        │            本机模式(LOCAL)                  │
        │  FatFs 挂载中 · 可拍照 · 可格式化 · 可看图库   │
        └──────────────────────────────────────────────┘
             │                              ▲
   USB 插入并用真主机                     PB5 物理拔出
   完成 SET_CONFIGURATION                 (去抖 ~300ms)
             │                              │
             ▼                              │
        ┌──────────────────────────────────────────────┐
        │             MSD 模式(USB)                   │
        │  PC 全权限读写扇区 · 本机禁止读写 SD          │
        │  容量显示停在进入前的旧值                      │
        └──────────────────────────────────────────────┘

   插充电器:只有 VBUS,永远不握手 → 留在"本机模式"
   PC 上点"弹出":不退出 → 要物理拔线

007. 其他驱动和使用

450K

5个按钮:ADC值对应4个 + 普通IO

ADC按钮初始化

void BoardButtons_Init(void)
{
    GPIO_InitTypeDef gpio;
    ADC_CommonInitTypeDef common;
    ADC_InitTypeDef adc;

    if (s_initialized) return;
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA |
                           RCC_AHB1Periph_GPIOF, ENABLE);
    gpio.GPIO_Pin = GPIO_Pin_0;
    gpio.GPIO_Mode = GPIO_Mode_AN; //配置为模拟引脚
    gpio.GPIO_OType = GPIO_OType_PP;
    gpio.GPIO_PuPd = GPIO_PuPd_NOPULL;
    gpio.GPIO_Speed = GPIO_Low_Speed;
    GPIO_Init(GPIOA, &gpio);

    gpio.GPIO_Pin = GPIO_Pin_9;
    gpio.GPIO_Mode = GPIO_Mode_IN;
    gpio.GPIO_PuPd = GPIO_PuPd_UP;
    GPIO_Init(GPIOF, &gpio);

    PeripheralEnable(PeripheralADC1, true);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
    ADC_CommonStructInit(&common);
    common.ADC_Mode = ADC_Mode_Independent;
    common.ADC_Prescaler = ADC_Prescaler_Div8;
    common.ADC_DMAAccessMode = 0U;
    common.ADC_TwoSamplingDelay = 0U;
    ADC_CommonInit(&common);

    ADC_StructInit(&adc);
    adc.ADC_Resolution = ADC_Resolution_12b;
    adc.ADC_ScanConvMode = DISABLE;
    adc.ADC_ContinuousConvMode = DISABLE;
    adc.ADC_ExternalTrigConvEdge = ADC_ExternalTrigConvEdge_None;
    adc.ADC_ExternalTrigConv = 0U;
    adc.ADC_DataAlign = ADC_DataAlign_Right;
    adc.ADC_NbrOfConversion = 1U;
    ADC_Init(KEY_ADC, &adc);
    ADC_RegularChannelConfig(KEY_ADC, ADC_Channel_0, 1U,
                             ADC_SampleTime_56Cycles);
    ADC_Cmd(KEY_ADC, ENABLE);
}

读取ADC的值(无DMA)

static uint16_t adc_read_average(void)
{
    uint32_t total = 0U;
    uint32_t sample_index;
    for (sample_index = 0U; sample_index < 4U; ++sample_index) {
        uint32_t timeout = 100000U; //读取过程超时时间
        ADC_ClearFlag(ADC1, ADC_FLAG_EOC | ADC_FLAG_STRT | ADC_FLAG_OVR);
        ADC_SoftwareStartConv(ADC1);
        while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET && timeout-- != 0U) {}
        if (timeout == 0U) return 4095U;
        total += ADC_GetConversionValue(ADC1);
    }
    return (uint16_t)(total / 4U);
}

不同电压对应不同的值

typedef enum {
    BOARD_BUTTON_NONE = 0,
    BOARD_BUTTON_UP,
    BOARD_BUTTON_DOWN,
    BOARD_BUTTON_OK,
    BOARD_BUTTON_PHOTO,
    BOARD_BUTTON_BACK,
    BOARD_BUTTON_POWER
} BoardButtonEvent;
static BoardButtonEvent decode_adc(uint16_t raw)
{
    if (raw < 493U) return BOARD_BUTTON_UP;
    if (raw < 1485U) return BOARD_BUTTON_DOWN;
    if (raw < 2414U) return BOARD_BUTTON_OK;
    if (raw < 3449U) return BOARD_BUTTON_PHOTO;
    return BOARD_BUTTON_NONE;
}

Freertos任务去10ms一次轮询查看

bool BoardButtons_Scan(uint32_t now_ms, BoardButtonEvent *event)
{
    BoardButtonEvent decoded;
    bool power_low;
    if (!s_initialized || event == NULL) return false; //是否初始化
    *event = BOARD_BUTTON_NONE;

    /* PF9 short release is BACK; a continuous 3-second press is POWER. */
    power_low = GPIO_ReadInputDataBit(GPIOF, GPIO_Pin_9) == Bit_RESET;
    if (power_low) {
        if (s_power_debounce < 2U) ++s_power_debounce;
        if (!s_power_pressed && s_power_debounce >= 2U) { //防抖
            s_power_pressed = true;
            s_power_pressed_at = now_ms;
            s_power_long_sent = false;
        }
        if (s_power_pressed && !s_power_long_sent &&
            (uint32_t)(now_ms - s_power_pressed_at) >= 3000U) { //电源触发长按
            s_power_long_sent = true;
            *event = BOARD_BUTTON_POWER;
            return true;
        }
    } else {
        s_power_debounce = 0U;
        if (s_power_pressed) {
            s_power_pressed = false;
            if (!s_power_long_sent) {
                *event = BOARD_BUTTON_BACK;
                return true;
            }
        }
    }

    decoded = decode_adc(adc_read_average());
    if (decoded != s_candidate) {
        s_candidate = decoded;
        s_candidate_count = 1U;
        return false;
    }
    if (s_candidate_count < 2U) { //防抖
        ++s_candidate_count;
        return false;
    }
    if (decoded != s_stable) {
        s_stable = decoded;
        if (decoded != BOARD_BUTTON_NONE) {
            *event = decoded;
            return true;
        }
    }
    return false;
}

启动一个Freertos任务

InfraredLvgl_PostKey()

static void KeyTask_Handler(void *argument)
{
    TickType_t last_wake;
    (void)argument;
    last_wake = xTaskGetTickCount();
    printf("[RTOS] KeyTask started, priority=%u.\n",
           (unsigned)uxTaskPriorityGet(NULL));

    for (;;) {
        BoardButtonEvent event;
        uint32_t now_ms = InfraredLvgl_GetMillis();
        if (BoardButtons_Scan(10, &event)) {
            BoardBuzzer_Beep(10, 50U);
            InfraredLvgl_PostKey(event);
            printf("[KEY] event=%u.\n", (unsigned)event);
        }
        vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10U));
    }
}

投递ui_keys_post()到Freertos队列中

static QueueHandle_t s_key_queue;
void InfraredLvgl_PostKey(BoardButtonEvent event)
{
    (void)ui_keys_post(event);
}
bool ui_keys_post(BoardButtonEvent event)
{
    if (s_key_queue == NULL || event == BOARD_BUTTON_NONE) return false;
    return xQueueSend(s_key_queue, &event, 0U) == pdPASS;
}

lvgl轮询去读(内部)

bool indev_init(void)
{
    static lv_indev_drv_t drv;   /* LVGL 持有该指针,不能是栈变量 */

    lv_indev_drv_init(&drv);
    drv.type = LV_INDEV_TYPE_KEYPAD;
    drv.read_cb = indev_read; //轮询触发indev_read回调
    s_indev = lv_indev_drv_register(&drv);
    return s_indev != NULL;
}

static void indev_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    BoardButtonEvent event;
    if (!ui_keys_poll(&event)) return;
}

处理Freertos队列消息

static bool ui_keys_poll(BoardButtonEvent *event)
{
    if (s_key_queue == NULL || event == NULL) return false;
    return xQueueReceive(s_key_queue, event, 0U) == pdPASS;
}

界面处理接收事件

// 创建一个widget
lv_obj_t *widget;
widget = lv_obj_create(NULL);

//创建一个group
lv_group_t *group;
group = lv_group_create();

//让界面和group绑定然后就可以接收group的事件了
lv_group_add_obj(group, widget);

//接收事件
lv_obj_add_event_cb(widget, key_cb, LV_EVENT_KEY, NULL);
static void key_cb(lv_event_t *event)
{
	//处理输入事件
}
//让group和输入事件lv_index进行绑定
lv_indev_set_group(indev, group);

触摸输入也是同理,但有出入

参考:参考

008. Jpeg编解码

概念参考:参考

mcu官方提供了jpeg库以及基本使用方式。

主要是我们要写EOI自己的东西。

JPEG软件编码,直接用就行

#ifndef JPEG_ENCODE_H
#define JPEG_ENCODE_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

/* 软件基线 JPEG 编码器:ARGB8888 输入 → JFIF baseline、4:2:0、无 APP1。
 *
 * 纯软件实现,不碰任何外设,因此没有硬件相关条件编译。
 *
 * 色度采样 4:2:0:MCU = 16×16 像素 = 4 个亮度 8×8 块 + Cb/Cr 各 1 块,
 * 色度按 2×2 取平均后再编码。
 *
 *   argb        ARGB8888 像素首地址(A 被忽略)
 *   width/height 图像尺寸,不必是 16 的整数倍(边缘用最后一行/列复制补齐)
 *   stride      行距,单位像素,必须 >= width
 *   quality     1..100,超出会被夹到范围内
 *   output      输出缓冲
 *   capacity    输出缓冲容量,最小值 1024
 *   outputSize  收到实际写入字节数;返回 false 时保持 0
 *
 * 返回 false 表示参数非法或输出放不下(output 中已写入的部分无意义,
 * 调用方应整体丢弃)。 */
bool JpegEncode_Argb8888(const uint32_t *argb, uint16_t width, uint16_t height,
                         uint16_t stride, uint8_t quality, uint8_t *output,
                         size_t capacity, size_t *outputSize);

#ifdef __cplusplus
}
#endif

#endif /* JPEG_ENCODE_H */

#include "jpeg_encode.h"

#include <string.h>

typedef struct {
    uint8_t* data;
    size_t capacity;
    size_t size;
    uint32_t bits;
    uint8_t bitCount;
    bool ok;
} JpegWriter_t;

typedef struct { uint16_t code; uint8_t bits; } HuffCode_t;

/* Keep generated lookup tables out of PhotoTask's 4KB stack. */
static HuffCode_t s_dcYCode[256];
static HuffCode_t s_dcCCode[256];
static HuffCode_t s_acYCode[256];
static HuffCode_t s_acCCode[256];

static const uint8_t s_zigzag[64] = {
     0, 1, 8,16, 9, 2, 3,10,17,24,32,25,18,11, 4, 5,
    12,19,26,33,40,48,41,34,27,20,13, 6, 7,14,21,28,
    35,42,49,56,57,50,43,36,29,22,15,23,30,37,44,51,
    58,59,52,45,38,31,39,46,53,60,61,54,47,55,62,63
};

static const uint8_t s_qYBase[64] = {
    16,11,10,16,24,40,51,61, 12,12,14,19,26,58,60,55,
    14,13,16,24,40,57,69,56, 14,17,22,29,51,87,80,62,
    18,22,37,56,68,109,103,77, 24,35,55,64,81,104,113,92,
    49,64,78,87,103,121,120,101, 72,92,95,98,112,100,103,99
};
static const uint8_t s_qCBase[64] = {
    17,18,24,47,99,99,99,99, 18,21,26,66,99,99,99,99,
    24,26,56,99,99,99,99,99, 47,66,99,99,99,99,99,99,
    99,99,99,99,99,99,99,99, 99,99,99,99,99,99,99,99,
    99,99,99,99,99,99,99,99, 99,99,99,99,99,99,99,99
};

static const uint8_t s_bitsDcY[16] =
    {0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0};
static const uint8_t s_valDcY[12] =
    {0,1,2,3,4,5,6,7,8,9,10,11};
static const uint8_t s_bitsDcC[16] =
    {0,3,1,1,1,1,1,1,1,1,1,0,0,0,0,0};
static const uint8_t s_valDcC[12] =
    {0,1,2,3,4,5,6,7,8,9,10,11};
static const uint8_t s_bitsAcY[16] =
    {0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,0x7d};
static const uint8_t s_valAcY[162] = {
    0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,
    0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xa1,0x08,
    0x23,0x42,0xb1,0xc1,0x15,0x52,0xd1,0xf0,0x24,0x33,0x62,0x72,
    0x82,0x09,0x0a,0x16,0x17,0x18,0x19,0x1a,0x25,0x26,0x27,0x28,
    0x29,0x2a,0x34,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,
    0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59,
    0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75,
    0x76,0x77,0x78,0x79,0x7a,0x83,0x84,0x85,0x86,0x87,0x88,0x89,
    0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,
    0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6,
    0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,
    0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,0xe1,0xe2,
    0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf1,0xf2,0xf3,0xf4,
    0xf5,0xf6,0xf7,0xf8,0xf9,0xfa
};
static const uint8_t s_bitsAcC[16] =
    {0,2,1,2,4,4,3,4,7,5,4,4,0,1,2,0x77};
static const uint8_t s_valAcC[162] = {
    0x00,0x01,0x02,0x03,0x11,0x04,0x05,0x21,0x31,0x06,0x12,0x41,
    0x51,0x07,0x61,0x71,0x13,0x22,0x32,0x81,0x08,0x14,0x42,0x91,
    0xa1,0xb1,0xc1,0x09,0x23,0x33,0x52,0xf0,0x15,0x62,0x72,0xd1,
    0x0a,0x16,0x24,0x34,0xe1,0x25,0xf1,0x17,0x18,0x19,0x1a,0x26,
    0x27,0x28,0x29,0x2a,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,
    0x45,0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,
    0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,
    0x75,0x76,0x77,0x78,0x79,0x7a,0x82,0x83,0x84,0x85,0x86,0x87,
    0x88,0x89,0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,
    0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,
    0xb5,0xb6,0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,
    0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,
    0xe2,0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf2,0xf3,0xf4,
    0xf5,0xf6,0xf7,0xf8,0xf9,0xfa
};

/* alpha(u)*cos((2*x+1)*u*pi/16), scaled by 16384. */
static const int16_t s_dct[8][8] = {
    {11585,11585,11585,11585,11585,11585,11585,11585},
    {16069,13623, 9102, 3196,-3196,-9102,-13623,-16069},
    {15137, 6270,-6270,-15137,-15137,-6270, 6270,15137},
    {13623,-3196,-16069,-9102, 9102,16069, 3196,-13623},
    {11585,-11585,-11585,11585,11585,-11585,-11585,11585},
    { 9102,-16069,3196,13623,-13623,-3196,16069,-9102},
    { 6270,-15137,15137,-6270,-6270,15137,-15137,6270},
    { 3196,-9102,13623,-16069,16069,-13623,9102,-3196}
};

static void putByte(JpegWriter_t* w, uint8_t value)
{
    if (!w->ok || w->size >= w->capacity) { w->ok = false; return; }
    w->data[w->size++] = value;
}
static void putU16(JpegWriter_t* w, uint16_t value)
{
    putByte(w, (uint8_t)(value >> 8)); putByte(w, (uint8_t)value);
}
static void putMarker(JpegWriter_t* w, uint8_t marker)
{
    putByte(w, 0xffU); putByte(w, marker);
}
static void putBits(JpegWriter_t* w, uint16_t value, uint8_t count)
{
    if (count == 0U || !w->ok) return;
    w->bits = (w->bits << count) | (value & ((1UL << count) - 1UL));
    w->bitCount = (uint8_t)(w->bitCount + count);
    while (w->bitCount >= 8U) {
        uint8_t b = (uint8_t)(w->bits >> (w->bitCount - 8U));
        w->bitCount = (uint8_t)(w->bitCount - 8U);
        putByte(w, b);
        if (b == 0xffU) putByte(w, 0U);
    }
}
static void flushBits(JpegWriter_t* w)
{
    if (w->bitCount != 0U) {
        uint8_t pad = (uint8_t)(8U - w->bitCount);
        putBits(w, (uint16_t)((1U << pad) - 1U), pad);
    }
}
static void buildHuff(const uint8_t bits[16], const uint8_t* values,
                      HuffCode_t table[256])
{
    uint16_t code = 0U;
    size_t k = 0U;
    memset(table, 0, 256U * sizeof(table[0]));
    for (uint8_t len = 1U; len <= 16U; ++len) {
        for (uint8_t n = 0U; n < bits[len - 1U]; ++n) {
            table[values[k]].code = code++;
            table[values[k]].bits = len;
            ++k;
        }
        code <<= 1;
    }
}
static uint8_t magnitudeBits(int value)
{
    unsigned int v = (unsigned int)(value < 0 ? -value : value);
    uint8_t n = 0U;
    while (v != 0U) { ++n; v >>= 1; }
    return n;
}
static uint16_t magnitudeValue(int value, uint8_t bits)
{
    return value < 0 ? (uint16_t)(value + ((1 << bits) - 1)) : (uint16_t)value;
}
static void fdctQuant(const int16_t input[64], const uint8_t q[64], int16_t out[64])
{
    int32_t temp[64];
    for (uint8_t y = 0U; y < 8U; ++y) {
        for (uint8_t u = 0U; u < 8U; ++u) {
            int32_t sum = 0;
            for (uint8_t x = 0U; x < 8U; ++x)
                sum += (int32_t)input[y * 8U + x] * s_dct[u][x];
            temp[y * 8U + u] = sum;
        }
    }
    for (uint8_t v = 0U; v < 8U; ++v) {
        for (uint8_t u = 0U; u < 8U; ++u) {
            int64_t sum = 0;
            int32_t coeff;
            for (uint8_t y = 0U; y < 8U; ++y)
                sum += (int64_t)temp[y * 8U + u] * s_dct[v][y];
            if (sum >= 0) coeff = (int32_t)((sum + (1LL << 29)) >> 30);
            else coeff = -(int32_t)(((-sum) + (1LL << 29)) >> 30);
            if (coeff >= 0) coeff = (coeff + q[v * 8U + u] / 2) / q[v * 8U + u];
            else coeff = -((-coeff + q[v * 8U + u] / 2) / q[v * 8U + u]);
            out[v * 8U + u] = (int16_t)coeff;
        }
    }
}
static void encodeBlock(JpegWriter_t* w, const int16_t input[64],
                        const uint8_t q[64], const HuffCode_t dc[256],
                        const HuffCode_t ac[256], int16_t* previousDc)
{
    int16_t c[64];
    int diff;
    uint8_t n;
    uint8_t zeroRun = 0U;
    fdctQuant(input, q, c);
    if (c[0] > 2047) c[0] = 2047;
    if (c[0] < -2047) c[0] = -2047;
    diff = c[0] - *previousDc;
    *previousDc = c[0];
    n = magnitudeBits(diff);
    putBits(w, dc[n].code, dc[n].bits);
    if (n != 0U) putBits(w, magnitudeValue(diff, n), n);

    for (uint8_t k = 1U; k < 64U; ++k) {
        int value = c[s_zigzag[k]];
        if (value > 1023) value = 1023;
        if (value < -1023) value = -1023;
        if (value == 0) { ++zeroRun; continue; }
        while (zeroRun >= 16U) {
            putBits(w, ac[0xf0].code, ac[0xf0].bits);
            zeroRun = (uint8_t)(zeroRun - 16U);
        }
        n = magnitudeBits(value);
        {
            uint8_t symbol = (uint8_t)((zeroRun << 4) | n);
            putBits(w, ac[symbol].code, ac[symbol].bits);
        }
        putBits(w, magnitudeValue(value, n), n);
        zeroRun = 0U;
    }
    if (zeroRun != 0U) putBits(w, ac[0].code, ac[0].bits);
}
static void makeQuant(uint8_t quality, const uint8_t base[64], uint8_t out[64])
{
    int scale;
    if (quality < 1U) quality = 1U;
    if (quality > 100U) quality = 100U;
    scale = quality < 50U ? 5000 / quality : 200 - quality * 2;
    for (uint8_t i = 0U; i < 64U; ++i) {
        int v = (base[i] * scale + 50) / 100;
        if (v < 1) v = 1;
        if (v > 255) v = 255;
        out[i] = (uint8_t)v;
    }
}
static void writeDhtTable(JpegWriter_t* w, uint8_t id,
                          const uint8_t bits[16], const uint8_t* values)
{
    size_t count = 0U;
    putByte(w, id);
    for (uint8_t i = 0U; i < 16U; ++i) { putByte(w, bits[i]); count += bits[i]; }
    for (size_t i = 0U; i < count; ++i) putByte(w, values[i]);
}

/* 4:2:0 的 MCU 是 16×16 像素:4 个亮度块 + 每路色度 1 块,共 6 块。
 * 源图宽高不是 16 的整数倍时,越界的行/列用最后一行/列复制补齐——这是 JPEG
 * 对非整 MCU 边界的标准处理。参数用 int 收,避免 by+oy+iy 在 uint16_t 里回绕。 */
#define JPEG_MCU_SIZE 16U

static uint16_t clamp_index(int value, int limit)
{
    return (uint16_t)((value < limit) ? value : (limit - 1));
}

bool JpegEncode_Argb8888(const uint32_t* argb, uint16_t width,
                         uint16_t height, uint16_t stride, uint8_t quality,
                         uint8_t* output, size_t capacity, size_t* outputSize)
{
    JpegWriter_t w = { output, capacity, 0U, 0U, 0U, true };
    uint8_t qY[64], qC[64];
    int16_t previousDc[3] = {0,0,0};
    int16_t yBlock[64];
    int16_t cbBlock[64];
    int16_t crBlock[64];

    if (outputSize != NULL) *outputSize = 0U;
    if (argb == NULL || output == NULL || outputSize == NULL ||
        width == 0U || height == 0U || stride < width || capacity < 1024U) return false;

    makeQuant(quality, s_qYBase, qY); makeQuant(quality, s_qCBase, qC);
    buildHuff(s_bitsDcY, s_valDcY, s_dcYCode); buildHuff(s_bitsDcC, s_valDcC, s_dcCCode);
    buildHuff(s_bitsAcY, s_valAcY, s_acYCode); buildHuff(s_bitsAcC, s_valAcC, s_acCCode);

    putMarker(&w, 0xd8);                         /* SOI */
    putMarker(&w, 0xe0); putU16(&w, 16U);        /* APP0 JFIF */
    putByte(&w,'J'); putByte(&w,'F'); putByte(&w,'I'); putByte(&w,'F'); putByte(&w,0);
    putByte(&w,1); putByte(&w,1); putByte(&w,0); putU16(&w,1); putU16(&w,1); putByte(&w,0); putByte(&w,0);
    putMarker(&w, 0xdb); putU16(&w, 132U);       /* DQT before SOF0 */
    putByte(&w, 0U); for (uint8_t i=0U;i<64U;++i) putByte(&w,qY[s_zigzag[i]]);
    putByte(&w, 1U); for (uint8_t i=0U;i<64U;++i) putByte(&w,qC[s_zigzag[i]]);
    /* baseline SOF0, 4:2:0:亮度 Y 采样因子 2×2,两路色度 1×1,
     * 于是 MCU = 16×16 像素 = 4 个 Y 块 + 1 个 Cb + 1 个 Cr。 */
    putMarker(&w, 0xc0); putU16(&w, 17U);
    putByte(&w,8); putU16(&w,height); putU16(&w,width); putByte(&w,3);
    putByte(&w,1); putByte(&w,0x22); putByte(&w,0);
    putByte(&w,2); putByte(&w,0x11); putByte(&w,1);
    putByte(&w,3); putByte(&w,0x11); putByte(&w,1);
    putMarker(&w, 0xc4); putU16(&w, 0x01a2U);    /* standard Huffman tables */
    writeDhtTable(&w,0x00,s_bitsDcY,s_valDcY); writeDhtTable(&w,0x10,s_bitsAcY,s_valAcY);
    writeDhtTable(&w,0x01,s_bitsDcC,s_valDcC); writeDhtTable(&w,0x11,s_bitsAcC,s_valAcC);
    putMarker(&w, 0xda); putU16(&w, 12U);        /* SOS */
    putByte(&w,3); putByte(&w,1); putByte(&w,0x00); putByte(&w,2); putByte(&w,0x11);
    putByte(&w,3); putByte(&w,0x11); putByte(&w,0); putByte(&w,63); putByte(&w,0);

    for (uint16_t by = 0U; by < height; by = (uint16_t)(by + JPEG_MCU_SIZE)) {
        for (uint16_t bx = 0U; bx < width; bx = (uint16_t)(bx + JPEG_MCU_SIZE)) {
            /* ① 亮度:一个 MCU 里 4 个 8×8 块,按 raster 顺序
             *    (0,0) (8,0) (0,8) (8,8) —— 这就是熵编码的顺序,
             *    顺序写反图像的块会错位,而且不容易一眼看出来。 */
            for (uint8_t sub = 0U; sub < 4U; ++sub) {
                int oy = (sub & 2U) ? 8 : 0;
                int ox = (sub & 1U) ? 8 : 0;

                for (uint8_t iy = 0U; iy < 8U; ++iy) {
                    uint16_t sy = clamp_index((int)by + oy + (int)iy, (int)height);
                    for (uint8_t ix = 0U; ix < 8U; ++ix) {
                        uint16_t sx = clamp_index((int)bx + ox + (int)ix, (int)width);
                        uint32_t p = argb[(uint32_t)sy * stride + sx];
                        int r = (int)((p >> 16) & 0xffU);
                        int g = (int)((p >> 8) & 0xffU);
                        int b = (int)(p & 0xffU);
                        yBlock[iy * 8U + ix] =
                            (int16_t)(((77*r + 150*g + 29*b + 128) >> 8) - 128);
                    }
                }
                encodeBlock(&w, yBlock, qY, s_dcYCode, s_acYCode, &previousDc[0]);
            }

            /* ② 色度:把 16×16 里每个 2×2 取平均,得到 8×8 的 Cb / Cr 各一块。
             * 必须在 Cb/Cr 域上平均(YCbCr 是仿射变换,先平均 RGB 会多引入一次
             * 舍入),所以逐像素算完再累加。 */
            for (uint8_t cy = 0U; cy < 8U; ++cy) {
                for (uint8_t cx = 0U; cx < 8U; ++cx) {
                    int cbSum = 0;
                    int crSum = 0;

                    for (uint8_t qy = 0U; qy < 2U; ++qy) {
                        uint16_t sy = clamp_index((int)by + (int)(cy * 2U) + (int)qy,
                                                  (int)height);
                        for (uint8_t qx = 0U; qx < 2U; ++qx) {
                            uint16_t sx = clamp_index((int)bx + (int)(cx * 2U) + (int)qx,
                                                      (int)width);
                            uint32_t p = argb[(uint32_t)sy * stride + sx];
                            int r = (int)((p >> 16) & 0xffU);
                            int g = (int)((p >> 8) & 0xffU);
                            int b = (int)(p & 0xffU);
                            cbSum += (-43*r - 85*g + 128*b + 32768 + 128) >> 8;
                            crSum += (128*r - 107*g - 21*b + 32768 + 128) >> 8;
                        }
                    }
                    {
                        uint8_t n = (uint8_t)(cy * 8U + cx);
                        cbBlock[n] = (int16_t)(((cbSum + 2) >> 2) - 128);
                        crBlock[n] = (int16_t)(((crSum + 2) >> 2) - 128);
                    }
                }
            }
            encodeBlock(&w, cbBlock, qC, s_dcCCode, s_acCCode, &previousDc[1]);
            encodeBlock(&w, crBlock, qC, s_dcCCode, s_acCCode, &previousDc[2]);
            if (!w.ok) return false;
        }
    }
    flushBits(&w); putMarker(&w, 0xd9);           /* EOI */
    if (!w.ok) return false;
    *outputSize = w.size;
    return true;
}

JPEG硬件解码

使用了DMA2D做调配

#ifndef JPEG_DECODE_H
#define JPEG_DECODE_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

/* 色度采样。解码方向不预设格式:Probe 从 SOF0 里读出实际值再决定,
 * 4:4:4 / 4:2:2 / 4:2:0 都接受。本机编码器(jpeg_encode.c)输出的是 4:2:0。 */
typedef enum {
    JPEG_DECODE_SUBSAMPLING_444 = 0,
    JPEG_DECODE_SUBSAMPLING_420 = 1,
    JPEG_DECODE_SUBSAMPLING_422 = 2
} JpegDecodeSubsampling_t;

typedef struct {
    uint16_t width;
    uint16_t height;
    JpegDecodeSubsampling_t subsampling;
} JpegDecodeInfo_t;

/* 解析 JPEG 头,校验 MH2457 JPEGD 的限制,并定位第一个 EOI。
 *
 *   jpeg        JPEG 数据
 *   bufferSize  可读字节数
 *   info        收到 SOF0 里的尺寸与色度采样
 *   jpegSize    收到"首个 EOI 结尾"的字节数(供 DMA 定长传输用)
 *
 * 返回 false 表示不是合法 JPEG,或含 JPEGD 不支持的标记(APP1 / SOF2 等)。 */
bool JpegDecode_Probe(const uint8_t *jpeg, size_t bufferSize,
                      JpegDecodeInfo_t *info, size_t *jpegSize);

/* JPEGD + DMA2 解码成 YCbCr,再用 DMA2D PFC 转 ARGB8888。
 *
 *   jpeg/jpegSize/jpegCapacity  输入缓冲、有效长度、可写容量
 *   yuv/yuvCapacity             中间 YCbCr 缓冲(JPEGD 输出,16 对齐行距)
 *   argb/argbPixels             输出 ARGB8888 缓冲,容量以像素计
 *   info                        收到实际解码尺寸(复用 Probe 的结果)
 *
 * 三个缓冲都必须 4 字节对齐;jpegCapacity 需比 jpegSize 多出最多 3 字节
 * (函数内部会补齐到 4 字节边界再做 DMA)。 */
bool JpegDecode_ToArgb8888(uint8_t *jpeg, size_t jpegSize, size_t jpegCapacity,
                           uint8_t *yuv, size_t yuvCapacity, uint32_t *argb,
                           size_t argbPixels, JpegDecodeInfo_t *info);

#ifdef __cplusplus
}
#endif

#endif /* JPEG_DECODE_H */

#include "jpeg_decode.h"

#include <string.h>

#ifndef JPEG_DECODE_HOST_TEST
#include "mh2457.h"
#include "mh245x_dma.h"
#include "mh245x_dma2d.h"
#include "mh245x_jpeg.h"
#include "peripheral.h"
#endif

static uint16_t be16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); }

bool JpegDecode_Probe(const uint8_t *jpeg, size_t bufferSize,
                      JpegDecodeInfo_t *info, size_t *jpegSize)
{
    size_t pos = 2U;
    bool dqtSeen = false, sofSeen = false;
    JpegDecodeInfo_t found = {0U, 0U, JPEG_DECODE_SUBSAMPLING_444};
    if (jpegSize != NULL)
        *jpegSize = 0U;
    if (jpeg == NULL || info == NULL || jpegSize == NULL || bufferSize < 4U ||
        jpeg[0] != 0xffU || jpeg[1] != 0xd8U)
        return false;

    while (pos + 3U < bufferSize)
    {
        uint8_t marker;
        uint16_t length;
        while (pos < bufferSize && jpeg[pos] != 0xffU)
            ++pos;
        while (pos < bufferSize && jpeg[pos] == 0xffU)
            ++pos;
        if (pos >= bufferSize)
            return false;
        marker = jpeg[pos++];
        if (marker == 0xd9U)
        {
            *jpegSize = pos;
            *info = found;
            return sofSeen;
        }
        if (marker == 0x00U || (marker >= 0xd0U && marker <= 0xd7U))
            continue;
        if (pos + 2U > bufferSize)
            return false;
        length = be16(jpeg + pos);
        if (length < 2U || pos + length > bufferSize)
            return false;
        if (marker == 0xe1U || marker == 0xc2U)
            return false; /* JPEGD restrictions */
        if (marker == 0xdbU)
        {
            if (sofSeen)
                return false;
            dqtSeen = true;
        }
        else if (marker == 0xc0U)
        {
            const uint8_t *s = jpeg + pos + 2U;
            if (!dqtSeen || length < 17U || s[0] != 8U || s[5] != 3U)
                return false;
            found.height = be16(s + 1U);
            found.width = be16(s + 3U);
            if (s[7] == 0x22U)
                found.subsampling = JPEG_DECODE_SUBSAMPLING_420;
            else if (s[7] == 0x21U || s[7] == 0x12U)
                found.subsampling = JPEG_DECODE_SUBSAMPLING_422;
            else if (s[7] == 0x11U)
                found.subsampling = JPEG_DECODE_SUBSAMPLING_444;
            else
                return false;
            sofSeen = true;
        }
        else if (marker == 0xdaU)
        {
            size_t i = pos + length;
            if (!sofSeen)
                return false;
            while (i + 1U < bufferSize)
            {
                if (jpeg[i] != 0xffU)
                {
                    ++i;
                    continue;
                }
                if (jpeg[i + 1U] == 0x00U ||
                    (jpeg[i + 1U] >= 0xd0U && jpeg[i + 1U] <= 0xd7U))
                {
                    i += 2U;
                    continue;
                }
                if (jpeg[i + 1U] == 0xd9U)
                {
                    *jpegSize = i + 2U;
                    *info = found;
                    return true;
                }
                return false;
            }
            return false;
        }
        pos += length;
    }
    return false;
}

#define JPEG_DMA_WAIT_GUARD 80000000UL
#define JPEG_DMA2D_WAIT_GUARD 80000000UL

#ifndef JPEG_DECODE_HOST_TEST
bool JpegDecode_ToArgb8888(uint8_t *jpeg, size_t jpegSize,
                           size_t jpegCapacity, uint8_t *yuv,
                           size_t yuvCapacity, uint32_t *argb,
                           size_t argbPixels, JpegDecodeInfo_t *info)
{
    DMA_InitTypeDef d;
    JPEG_InfoTypeDef hw;
    size_t actualSize, paddedSize, worstYuv;
    uint32_t guard;
    if (jpeg == NULL || yuv == NULL || argb == NULL || info == NULL ||
        (((uintptr_t)jpeg | (uintptr_t)yuv | (uintptr_t)argb) & 3U) != 0U)
        return false;
    if (!JpegDecode_Probe(jpeg, jpegSize, info, &actualSize))
        return false;
    if (info->width == 0U || info->height == 0U ||
        (size_t)info->width * info->height > argbPixels)
        return false;
    paddedSize = (actualSize + 3U) & ~(size_t)3U;
    worstYuv = (size_t)((info->width + 15U) & ~15U) * info->height * 3U;
    if (paddedSize > jpegCapacity || worstYuv > yuvCapacity)
        return false;
    memset(jpeg + actualSize, 0, paddedSize - actualSize);

    PeripheralEnable(PeripheralJPEGD, 1);
    PeripheralEnable(PeripheralDMA2, 1);
    JPEG_Cmd(DISABLE);
    PeripheralReset(PeripheralJPEGD);
    DMA_Cmd(DMA2_Stream0, DISABLE);
    DMA_Cmd(DMA2_Stream5, DISABLE);
    guard = JPEG_DMA_WAIT_GUARD;
    while ((DMA_GetCmdStatus(DMA2_Stream0) || DMA_GetCmdStatus(DMA2_Stream5)) && --guard)
    {
    }
    if (guard == 0U)
        return false;
    DMA_ClearFlag(DMA2_Stream0, DMA_FLAG_ALL);
    DMA_ClearFlag(DMA2_Stream5, DMA_FLAG_ALL);
    DMA_StructInit(&d);
    d.DMA_Channel = DMA_Channel_0;
    d.DMA_BufferSize = 1U;
    d.DMA_DIR = DMA_DIR_MemoryToPeripheral;
    d.DMA_FIFOMode = DMA_FIFOMode_Enable;
    d.DMA_FIFOThreshold = DMA_FIFOThreshold_Full;
    d.DMA_Memory0BaseAddr = (uint32_t)jpeg;
    d.DMA_MemoryBurst = DMA_MemoryBurst_INC4;
    d.DMA_MemoryDataSize = DMA_MemoryDataSize_Word;
    d.DMA_MemoryInc = DMA_MemoryInc_Enable;
    d.DMA_Mode = DMA_Mode_Normal;
    d.DMA_PeripheralBaseAddr = (uint32_t)&JPEGD->DIR;
    d.DMA_PeripheralBurst = DMA_PeripheralBurst_INC16;
    d.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    d.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
    d.DMA_Priority = DMA_Priority_Medium;
    DMA_Init(DMA2_Stream5, &d);
    d.DMA_Channel = DMA_Channel_7;
    d.DMA_DIR = DMA_DIR_PeripheralToMemory;
    d.DMA_Memory0BaseAddr = (uint32_t)yuv;
    d.DMA_PeripheralBaseAddr = (uint32_t)&JPEGD->DOR;
    DMA_Init(DMA2_Stream0, &d);
    DMA_FlowControllerConfig(DMA2_Stream0, DMA_FlowCtrl_Peripheral);
    DMA_ITConfig(DMA2_Stream0, DMA_IT_TC, DISABLE);
    DMA_ITConfig(DMA2_Stream5, DMA_IT_TC, DISABLE);
    JPEG_SetInFifoThreshold(16U);
    JPEG_SetOutFifoThreshold(16U);
    DMA2_Stream5->M0AR = (uint32_t)jpeg;
    DMA2_Stream5->NDTR = (uint32_t)paddedSize;
    DMA2_Stream0->M0AR = (uint32_t)yuv;
    DMA2_Stream0->NDTR = 0U;
    JPEG_Cmd(ENABLE);
    DMA_Cmd(DMA2_Stream0, ENABLE);
    DMA_Cmd(DMA2_Stream5, ENABLE);
    guard = JPEG_DMA_WAIT_GUARD;
    while (DMA_GetFlagStatus(DMA2_Stream0, DMA_FLAG_TCIF) == RESET && --guard)
    {
        if (JPEG_GetFlagStatus(JPEG_FLAG_CFGERR) != RESET)
            break;
    }
    if (guard == 0U || JPEG_GetFlagStatus(JPEG_FLAG_CFGERR) != RESET)
    {
        JPEG_Cmd(DISABLE);
        DMA_Cmd(DMA2_Stream0, DISABLE);
        DMA_Cmd(DMA2_Stream5, DISABLE);
        return false;
    }
    JPEG_GetInfo(&hw);
    JPEG_Cmd(DISABLE);
    DMA_Cmd(DMA2_Stream0, DISABLE);
    DMA_Cmd(DMA2_Stream5, DISABLE);
    if (hw.ImageWidth != info->width || hw.ImageHeight != info->height)
        return false;

    PeripheralEnable(PeripheralDMA2D, 1);
    DMA2D_DisableIT_TC(DMA2D);
    guard = JPEG_DMA2D_WAIT_GUARD;
    while (DMA2D_IsTransferOngoing(DMA2D) && --guard)
    {
    }
    if (guard == 0U)
        return false;
    DMA2D_SetMode(DMA2D, DMA2D_MODE_M2M_PFC);
    DMA2D_SetLineOffsetMode(DMA2D, DMA2D_LINE_OFFSET_PIXELS);
    DMA2D_SetOutputRotationMode(DMA2D, DMA2D_ROTATION_0);
    DMA2D_FGND_SetColorMode(DMA2D, DMA2D_INPUT_MODE_YCBCR);
    DMA2D_FGND_SetAlphaMode(DMA2D, DMA2D_ALPHA_MODE_REPLACE);
    DMA2D_FGND_SetAlpha(DMA2D, 0xffU);
    DMA2D_FGND_SetChrSubSampling(DMA2D,
                                 hw.ChromaSubsampling == JPEG_420_SUBSAMPLING ? DMA2D_CSS_420 : (hw.ChromaSubsampling == JPEG_422_SUBSAMPLING ? DMA2D_CSS_422 : DMA2D_CSS_444));
    DMA2D_FGND_SetLineOffset(DMA2D, (16U - (hw.ImageWidth & 15U)) & 15U);
    DMA2D_FGND_SetMemAddr(DMA2D, (uint32_t)yuv);
    DMA2D_SetOutputColorMode(DMA2D, DMA2D_OUTPUT_MODE_ARGB8888);
    DMA2D_SetOutputColor(DMA2D, 0xff000000UL);
    DMA2D_SetOutputMemAddr(DMA2D, (uint32_t)argb);
    DMA2D_SetLineOffset(DMA2D, 0U);
    DMA2D_SetNbrOfPixelsPerLines(DMA2D, hw.ImageWidth);
    DMA2D_SetNbrOfLines(DMA2D, hw.ImageHeight);
    DMA2D_ClearFlag_TC(DMA2D);
    DMA2D_ClearFlag_CE(DMA2D);
    DMA2D_ClearFlag_TE(DMA2D);
    DMA2D_Start(DMA2D);
    guard = JPEG_DMA2D_WAIT_GUARD;
    while (DMA2D_IsActiveFlag_TC(DMA2D) == 0U && --guard)
    {
        if (DMA2D_IsActiveFlag_CE(DMA2D) || DMA2D_IsActiveFlag_TE(DMA2D))
            break;
    }
    if (guard == 0U || DMA2D_IsActiveFlag_CE(DMA2D) || DMA2D_IsActiveFlag_TE(DMA2D))
        return false;
    DMA2D_ClearFlag_TC(DMA2D);
    return true;
}
#else
bool JpegDecode_ToArgb8888(uint8_t *jpeg, size_t jpegSize,
                           size_t jpegCapacity, uint8_t *yuv,
                           size_t yuvCapacity, uint32_t *argb,
                           size_t argbPixels, JpegDecodeInfo_t *info)
{
    (void)jpeg;
    (void)jpegSize;
    (void)jpegCapacity;
    (void)yuv;
    (void)yuvCapacity;
    (void)argb;
    (void)argbPixels;
    (void)info;
    return false;
}
#endif

009. Flash RW littlefs

MH2457 片内没有能放代码的 flash,必须要外挂flash

通过 XFC 控制器 XIP 就地执行

① 地址映射。 XFC 把外挂 NOR 的整个空间映射到 CPU 总线的 0x08000000 起。CPU 发起一次取指(比如取 0x08012345 处的指令),它只看到一次普通的总线读,不知道后面是 SPI 芯片。

② 按需翻译成 SPI 事务。 XFC 收到这个地址后,现场生成一串 QSPI 时序:发“四线快速读”命令 → 发 24 位地址 → 在 4 根数据线上把字节收回来 → 交给 CPU。每条指令的取指都是这么来的。四线(QSPI)比单线快 4 倍,就是为了喂饱 CPU 取指带宽。

③ Cache 挡住 SPI 延迟。 SPI 再快也比并行总线慢一个量级,如果每条指令都现跑一遍 QSPI 事务,CPU 早就饿死了。所以 XFC 前面挂了 ICACHE/DCACHE(mh245x_cache.c 那一套):读过的缓存行命中就直接给,未命中才走 SPI。这就是 XIP 能跑得动的关键。

④ 启动时对齐时序。 SystemInit 里那段 QSPIClockConfig / CONFIG_QSPI_TIMMING,是在系统时钟切到 PLL 之后按新频率重新配置 QSPI 读时序——芯片型号、PCB 走线长度不同,采样时刻就不同。

复位
 └─ CPU 从掩膜 ROM (0x00000000 区) 取指执行
     ├─ 读 flash 头 4 KB 的启动头(我们链接脚本保留 0x08000000~0x08000FFF 的原因)
     ├─ 配 QSPI 引脚(专用引脚,不需要我们做 GPIO 复用)
     ├─ 配 XFC 寄存器:DEVICE_PARA(协议/伪周期/分频)
     │                 CACHE_INTF_CMD ← XIP 的本体,见下
     ├─ 使能 ICACHE/DCACHE
     └─ 跳到 flash 执行 → 我们的 Reset_Handler → SystemInit → main

复位 → 掩膜 ROM(芯片内部,初始化 XIP)→ ??? → 我们的程序 (0x08001000)
                                              ↑
              这里是两种可能:A) ROM 读头 4KB 里的【数据头】(magic/入口地址),直接跳
                             B) ROM 跳到头 4KB 里的【一小段 loader 代码】执行后再跳

链接地址

说明:0x08000000 ~0x0800FFFF启动头在官方提供的烧录工具,会帮我们添上去。

__ROM_BASE   = 0x08001000; //链接地址
__ROM_SIZE   = 0x01FFF000; // 大小32MB

我们直接拿最后4KB作为存储。读写这个4KB即可完成配置记录

__ROM_BASE   = 0x08001000; //链接地址
__ROM_SIZE   = 0x01FFE000; // 大小32MB-4KB
0x08000000 ┬─ 启动头 4 KB            烧录工具写,ROM 读它启动
0x08001000 ┼─ 固件区开始              ← __ROM_BASE(现用 394,944 B,余 ~31.4 MB)
0x080616C0 │   ← __flash_image_end__(驱动挡擦/写)
   ...     │      (代码连续向上长,跨过 16 MB 边界也没问题——取指走 XIP)
0x09FFF000 ┼─ 数据扇区 4 KB          ← FLASH_XFC_SETTINGS_ADDR(芯片末尾)
0x0A000000 ┴─ 芯片结束(32 MB)

地址信息

#ifndef FLASH_XFC_H__
#define FLASH_XFC_H__

#include <stdint.h>
#include <stdbool.h>

#define FLASH_XFC_SECTOR_SIZE   4096UL // 4KB大小的数据区,小擦除单位
#define FLASH_XFC_PAGE_SIZE     256UL // 页大小,写的最小颗粒
#define FLASH_XFC_SETTINGS_ADDR 0x09FFF000UL // 读写的开始地址

/* 读一次 JEDEC ID 并打日志,用于核对容量/型号假设。无其他初始化——
 * QSPI 控制器与引脚由 SystemInit 配置(代码本身就从这片 flash 运行)。 */
void FlashXFC_Init(void);

/* 读设置扇区内 [addr, addr+len)。扇区之外返回 false。 */
bool FlashXFC_Read(uint32_t addr, void *out, uint32_t len);

/* 擦除设置扇区(addr 必须恰好是 FLASH_XFC_SETTINGS_ADDR)。 */
bool FlashXFC_EraseSector(uint32_t addr);

/* 在设置扇区内编程:addr/len 4 字节对齐,目标区必须已擦除(NOR 只能
 * 1→0)。data 必须指向 RAM(SRAM/栈/静态区),不要传 flash 里的 const
 * 数据。扇区之外返回 false。 */
bool FlashXFC_Program(uint32_t addr, const void *data, uint32_t len);

#endif /* FLASH_XFC_H__ */

#include "flash_xfc.h"

/* 全部操作只放行设置扇区内的 [addr, addr+len) */
static bool in_settings_sector(uint32_t addr, uint32_t len)
{
    return addr >= FLASH_XFC_SETTINGS_ADDR &&
           len != 0UL &&
           (addr - FLASH_XFC_SETTINGS_ADDR) + len <= FLASH_XFC_SECTOR_SIZE;
}

void FlashXFC_Init(void)
{
    printf("[XFC] flash JEDEC ID 0x%06lX.\n",
           (unsigned long)QSPI_ReadID(NULL));
}

bool FlashXFC_Read(uint32_t addr, void *out, uint32_t len)
{
    if (out == NULL || !in_settings_sector(addr, len)) return false;
    memcpy(out, (const void *)(uintptr_t)addr, len);
    return true;
}

bool FlashXFC_EraseSector(uint32_t addr)
{
    if (!in_settings_sector(addr, FLASH_XFC_SECTOR_SIZE) ||
        addr != FLASH_XFC_SETTINGS_ADDR) {
        return false;
    }
    if (FLASH_EraseSector(addr) != QSPI_STATUS_OK) {
        printf("[XFC] erase 0x%08lX failed.\n", (unsigned long)addr);
        CACHE_CleanAll(DCACHE);
        return false;
    }
    /* 擦写后读回之前必须清 DCACHE(SDK 注意事项),否则可能读到旧缓存行 */
    CACHE_CleanAll(DCACHE);
    return true;
}

bool FlashXFC_Program(uint32_t addr, const void *data, uint32_t len)
{
    const uint8_t *src = (const uint8_t *)data;
    QSPI_CommandTypeDef cmd;

    if (data == NULL || !in_settings_sector(addr, len)) return false;
    /* ROM 例程的三重对齐要求,缺一不可,这里前置校验而不是让它半途失败 */
    if ((addr & 3UL) != 0UL || (len & 3UL) != 0UL ||
        ((uintptr_t)data & 3UL) != 0UL) {
        printf("[XFC] program 0x%08lX len=%lu: unaligned.\n",
               (unsigned long)addr, (unsigned long)len);
        return false;
    }

    /* 与参考例程一致:四线页编程(0x32),1-1-4 总线,8bit 命令 + 24bit 地址。
     * 不用 DMA(传 NULL):DMA2 的可用流(S4/6/7)留给以后,编程量只有
     * 几十字节的配置数据,CPU 搬运绰绰有余。 */
    cmd.Instruction = QUAD_INPUT_PAGE_PROG_CMD;
    cmd.BusMode = QSPI_BUSMODE_114;
    cmd.CmdFormat = QSPI_CMDFORMAT_CMD8_ADDR24_PDAT;

    while (len != 0UL) {
        /* 一页编程不能跨 NOR 页边界(256 B) */
        uint32_t page_room = FLASH_XFC_PAGE_SIZE -
                             (addr & (FLASH_XFC_PAGE_SIZE - 1UL));
        uint32_t chunk = len < page_room ? len : page_room;

        if (FLASH_ProgramPage(&cmd, NULL, addr, chunk,
                              (uint8_t *)(uintptr_t)src) != QSPI_STATUS_OK) { //擦除并写入
            printf("[XFC] program 0x%08lX failed.\n", (unsigned long)addr);
            CACHE_CleanAll(DCACHE);
            return false;
        }
        addr += chunk;
        src += chunk;
        len -= chunk;
    }
    CACHE_CleanAll(DCACHE);
    return true;
}

具体:

  • 调用擦除某一个地址

  • 然后写入

if (!FlashXFC_EraseSector(FLASH_XFC_SETTINGS_ADDR)) return false;
if (!FlashXFC_Program(FLASH_XFC_SETTINGS_ADDR, &record, sizeof(record))) {
	return false;
}

官方git:littlefs-project/littlefs: A little fail-safe filesystem designed for microcontrollers

文件移植:

最好README.md也移植过来

1

010. Boot 充电图片logo图片

仅为开机 Logo 和关机充电界面,不值得现在增加 Bootloader 分区。

上电阶段
├─ 电源锁存
├─ 启动模式判断
├─ Logo/黑屏准备
└─ LCD 启动

应用阶段
├─ 正常应用
└─ 充电待机

MH2457QAUZ0D0

  • 封装:QFN100

器件

RAM

下载cmake

官网:CMake

项目构建参考

cmake项目

Sdcc

常用方式(确定版本)

  • 去官网下载

  • 上传到服务器

  • 设置环境变量

  • export PATH=$PATH:/root/tool/gcc/bin
    export PATH=$PATH:/root/tool/sdcc/bin
    source /etc/profile  
    //说明
    PATH 								环境变量
    = 									赋值
    $PATH 								当前PATH的值
    :/root/tool/arm-linux-gnueabihf 	追加一行
    

简单方式(apt安装)

apt install gcc
apt install sdcc

确定版本安装

  • 去到apt包管理官网https://packages.ubuntu.com/

  • 搜索,安装

  • apt install gcc=11.4.0
    

Windows安装

直接下载.exe文件进行安装

下载

  • 参考当前目录下sdcc/gcc-sdcc安装.md文件

  • 由于windows执行makefile比较困难,需要一步一步编译每个.c文件,再做链接,要么就要写shell脚本,比较麻烦。现在只举一些例子。

基本使用

main.c

// 51 单片机 P1 口点灯(P1.0 接 LED)
#define FOSC 11059200L
#include <8052.h>

// 简单延时函数
void delay(void)
{
    unsigned int i;
    for(i=0; i<60000; i++);
}

void main(void)
{
    while(1)
    {
        P1 = 0xFE;   // P1.0 输出低电平 → 点灯
        delay();
        
        P1 = 0xFF;   // 灭灯
        delay();
    }
}

编译

# 1. 编译 + 链接(SDCC 一步完成)
sdcc -mmcs51 --model-small -I. main.c -o main.ihx
# 2. 转成标准 hex
packihx main.ihx > main.hex
# 或者直接复制main.ihx改成main.hex
# 3. 转成纯二进制 bin , sdcc不能直接转,直接烧录hex就可以了

Stm32 notes

ADC内部是如何读取引脚电平的

采样与逐次逼近

STM32的ADC并非“瞬间”读取电压,而是通过采样-保持电路和逐次逼近逻辑来完成。

  1. 采样阶段(电容充电):当ADC开始转换时,内部的一个采样电容会通过开关连接到你的目标引脚。在一段被称为“采样时间”的窗口内,电容被充电,直到其两端电压与引脚电压一致。
  2. 保持阶段(断开输入):采样结束后,开关断开,电容与引脚隔离。此时电容上“记住”了采样瞬间的电压值,并在整个转换期间保持稳定,这是精确转换的前提。
  3. 逐次逼近(二分法比较):这是核心的量化过程。ADC内部的逐次逼近寄存器(SAR) 会从最高有效位(MSB)开始,逐位确定数字输出。它像一个“天平”,将电容上的电压与一系列基准电压(如VREF/2, VREF/4, 3VREF/8...)进行比较。每一位的比较结果决定该位是1还是0,直到最低有效位(LSB)被确定,一个完整的数字量就诞生了。

多长时间读取一次:转换时间的计算

ADC的读取速度由总转换时间(Tconv) 决定,即一次完整转换(采样+逐次逼近)所需的时间。其计算公式为:

Tconv = 采样时间 + 12.5个ADC时钟周期

其中,12.5个周期是SAR架构进行逐次逼近所需的固定时间(对于12位分辨率)。而采样时间是可配置的,通过寄存器(如 ADC_SMPR1/2)可以设置不同的采样周期数(如1.5、7.5、239.5个周期等),以适应不同内阻的信号源。

转换频率 = 1 / Tconv。以常见的STM32F4系列为例,假设ADC时钟(ADCCLK)配置为21MHz,采样时间设为3个周期,那么: Tconv = (3 + 12) / 21MHz ≈ 0.714 µs,即最快约1.4 MSPS(每秒百万次采样)。

不同系列性能差异较大,例如STM32F1系列的最高转换速率约为1 MSPS,而STM32H7等高性能系列可达3.6 MSPS甚至更高。

转换后的值存放何处:数据寄存器与DMA

转换完成的数字结果会存入特定的数据寄存器中,而非直接进入CPU。

  • 规则通道数据寄存器(ADC_DR):对于常规的“规则组”转换,结果存放在 ADC_DR 中。它是一个32位寄存器,但只有低16位有效。你可以通过配置对齐方式(左对齐或右对齐)来方便地提取数据。
  • 注入通道数据寄存器(ADC_JDRx):对于优先级更高的“注入组”转换,结果存放在独立的 ADC_JDR1~JDR4 寄存器中,与规则组数据完全隔离。

关键点:ADC_DR 只有一个,如果进行多通道扫描转换,后一个通道的结果会覆盖前一个通道的数据。因此,在连续扫描多个通道时,几乎必须使用DMA。DMA可以在每次转换完成后,自动将 ADC_DR 中的值搬运到内存数组里,避免数据丢失。

  • STM32F407ZG通过DCMI(数字摄像头接口),DCMI 就是为 DVP 接口摄像头量身定制的

  • ov5640,一般配置为RGB格式输出。与NV12,YUV422,MPEG,H264等相似。走的分辨率是VGA(640x480),还有一些分辨率5Mpixel(2592x1944),1080p,720p,QVGA(320x240)等。

关于SCCB

很类似IIC接口,可以直接用IIC接口模拟。

IIC协议解析

一、IIC 模式设置

配置项含义与说明
IIC标准 I²C 模式。使用两根线(SDA 数据线、SCL 时钟线),支持多主机、多从机,通过地址寻址。这是最常用的模式。
SMBus-Alert-modeSMBus 的 警报信号模式。SMBus 是 I²C 的一个子集,增加了低功耗管理、超时检测等特性。Alert 模式允许从机通过一个专用信号线(SMBA#)主动通知主机发生事件,主机随后发起 Alert Response Address (ARA) 流程来识别哪个从机触发了警报。
SMBus-two-wire-interface也是 SMBus,但 不使用专用警报线,所有通信(包括警报响应)都在标准的 SDA/SCL 两根线上完成。更接近 I²C 的物理层,但遵循 SMBus 的协议层(如超时、包错误校验 PEC 等)。

简单区分:

  • IIC:标准模式,无超时限制,支持任意速率(常见 100k/400k)。
  • SMBus Alert:需要额外 SMBA 线,用于从机主动报警。
  • SMBus 2-wire:仅用两根线,兼容 I²C 电气特性,但协议更严格(如最小时钟低电平时间、超时等)。

二、参数设置

1. Speed Mode 速度模式

  • Standard mode (100 kHz):经典速度,兼容性最好,适合大多数传感器、EEPROM。
  • Fast Mode (400 kHz):高速模式,传输更快,要求总线电容更低,部分旧设备不支持。

2. Clock Speed

  • 直接设定 SCL 时钟频率(单位通常是 Hz 或 kHz)。 如果已选 Speed Mode,此选项可能用于微调(例如设为 350 kHz 而非 400 kHz)。若没选模式,则需要手动输入频率值。

3. Clock No Stretch Mode 开启/关闭

  • 时钟拉伸:当从机来不及处理数据时,可以主动拉低 SCL,迫使主机等待。
  • Clock No Stretch Mode(无时钟拉伸模式):
    • 开启:禁止从机使用时钟拉伸。主机不会等待,如果从机拉低 SCL 可能会造成通信错误。
    • 关闭:允许从机拉伸时钟(标准 I²C 支持此特性)。
    • 使用场景:与某些不支持拉伸的快速设备通信,或为了简化主机时序。

4. Primary Address Length selection 地址长度

  • 7bit:标准 I²C 地址长度,最常用。实际发送时左移一位加 R/W 位。
  • 10bit:扩展地址长度,用于需要超过 112 个设备的总线(7 位地址除去保留地址后只有 112 个可用)。10 位地址需要两个字节传输,兼容性略差。

5. Dual Address Acknowledged 开启/关闭

  • 是否让从机 响应两个不同的从地址。
    • 开启:从机有两个有效地址(例如主地址 + 第二地址),常用于需要区分不同功能的复合设备。
    • 关闭:只响应 Primary slave address。
    • 注意:此选项通常只在 从机模式 下有意义。

6. Primary slave address

  • 当本机作为 从机 时,自己的主要地址(7 位或 10 位,与地址长度设置匹配)。
  • 主机通过这个地址来访问本设备。

7. General Call Address detection

  • 广播地址检测。通用广播地址是 0x00(8 位形式,即 7 位地址 0x00 + R/W=0)。
    • 开启:从机响应广播地址,接收到主机发送的全局命令(如复位所有设备、改变从机地址等)。
    • 关闭:从机忽略广播地址,只响应自己的专用地址。
    • 一般传感器或简单从机不启用此功能,避免误响应。

IIC时序

SCK时钟稳定一高一低,高电平时开始采样。

采样时SDA数据线四个状态:

  • 从高到低:为开始状态,告诉设备要开始发送数据了。

  • 从低到高:为结束状态,告诉设备我已经发送完成了。

  • 高电平:数据为 1

  • 低电平:数据为 0

iic时序

应答过程:设备主动拉低电平。

IIC数据协议规定

一次完整的 IIC 读写操作(以写操作为例)包含以下序列:

  1. 起始条件(S):主机发出起始信号,开始一次通信。

  2. 设备地址与读写位(第 1 个字节): 前 7 位(A6~A0)为从机地址,用于寻址总线上最多 127 个设备。 最低位(R/W)表示本次操作方向:

    • 0 表示主机向从机写入数据(写操作)
    • 1 表示主机从从机读取数据(读操作) 该字节传输完成后,从机会在第 9 个时钟周期回复一个应答位(A)。
  3. 寄存器地址(第 2 个字节): 该字节(B7~B0)指向从机内部的寄存器地址。后续的读写操作将从此地址开始。 该字节传输完成后,从机再次回复一个应答位(A)。

    注意:若本次操作为连续读写,每次访问该寄存器后,地址指针会自动加 1,指向下一个寄存器。

  4. 数据字节(第 3 个及后续字节):

    • 写操作:主机发送数据字节(D7~D0),写入第 2 步指定的寄存器。从机每收到一个字节回复一个应答位(A)。之后可继续发送多个数据字节,寄存器地址自动递增。
    • 读操作:主机转为接收模式,从机发送数据字节(D7~D0),内容为当前寄存器地址中的值。主机每收到一个字节回复一个应答位(最后一个字节后可回复非应答 NAK 表示结束)。之后可继续读取多个字节,寄存器地址自动递增。
  5. 停止条件(S):主机发出停止信号,释放总线,结束本次通信。

iic数据规定

硬件IIC和软件IIC

  • 硬件IIC不需要操作SDA,SCL引脚,硬件会自己处理,只需要发送数据或命令就好。
//配置好硬件配置

void OLED_WriteCmd(uint8_t cmd) {
    uint8_t buf[2] = {0x00, cmd};  // 0x00 //cmd
    HAL_I2C_Master_Transmit(&hi2c1,OLED_ADDR, buf, 2, 100);
}
void OLED_WriteData(uint8_t data) {
    uint8_t buf[2] = {0x40, data};  // 0x40 //data
    HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100);
}
  • 需要自己模拟整个时序过程。
static void I2C_Start(void) {
    // SDA 和 SCL 初始高电平
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET); //时钟开始
    HAL_Delay(5);
    // SDA 拉低产生起始条件
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_RESET);
    HAL_Delay(5);
    // SCL 拉低,准备传输数据
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);//时钟结束
    HAL_Delay(5);
}

static void I2C_Stop(void) {
    //1.先拉低SCL,再拉低SDA 
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_RESET);
    HAL_Delay(5);
    // SCL 时钟开始
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
    HAL_Delay(5);
    // SDA 拉高产生停止条件
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
    HAL_Delay(5);
    // SCL 时钟结束
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
    
}

// 发送一个字节,返回从机应答位(0=应答,1=非应答)
static uint8_t I2C_SendByte(uint8_t data) {
    // 发送 8 位数据,高位在前
    for (uint8_t i = 0; i < 8; i++) {
        if (data & 0x80) {
            HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
        } else {
            HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_RESET);
        }
        data <<= 1;
        HAL_Delay(2);
        // 产生时钟高电平,从机采样数据
        HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
        HAL_Delay(5);
        HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);
        HAL_Delay(2);
    }
    // 释放 SDA 总线,准备接收应答
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
    HAL_Delay(2);
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
    HAL_Delay(5);
    // 读取应答位(低电平有效)
    uint8_t ack = HAL_GPIO_ReadPin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin);
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);
    HAL_Delay(2);
    return ack;   // 0 表示应答,1 表示非应答
}


// 向 OLED 发送命令或数据(连续多个字节)
static void OLED_WriteBytes(uint8_t ctrl_byte, uint8_t *data, uint8_t len) {
    I2C_Start();
    I2C_SendByte(OLED_ADDR);          // 发送设备地址 + 写位
    I2C_SendByte(ctrl_byte);          // 控制字节:命令或数据
    for (uint8_t i = 0; i < len; i++) {
        I2C_SendByte(data[i]);
    }
    I2C_Stop();
}

// 便捷函数:写单个命令
static void OLED_WriteCmd(uint8_t cmd) {
    OLED_WriteBytes(0, &cmd, 1); // 0代表写命令,&cmd要发送的命令,1发送长度
}

// 便捷函数:写单个数据
static void OLED_WriteData(uint8_t data) {
    OLED_WriteBytes(1, &data, 1);
}

读数据&写数据

向设备写数据,前面已经有相关示例了。

读设备数据:

  • 硬件操作
// 从设备读取多个字节(标准流程:写寄存器地址 -> 重复起始 -> 读数据)
// ctrl_byte: 控制字节(例如 0x00 命令,0x40 数据,或者寄存器地址)
// data: 接收缓冲区
// len: 要读取的字节数
// 返回值:HAL 状态(HAL_OK 表示成功)
HAL_StatusTypeDef OLED_ReadBytes(uint8_t ctrl_byte, uint8_t *data, uint8_t len) {
    HAL_StatusTypeDef ret;
    // 1. 发送设备地址 + 写位,并发送控制字节
    ret = HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, &ctrl_byte, 1, 100);
    if (ret != HAL_OK) return ret;

    // 2. 重复起始,发送设备地址 + 读位,然后接收数据
    ret = HAL_I2C_Master_Receive(&hi2c1, OLED_ADDR, data, len, 100);
    return ret;
}

  • 软件模拟
// 读取一个字节,ack: 0=主机发送应答(继续读取),1=主机发送非应答(结束读取)
static uint8_t I2C_ReadByte(uint8_t ack) {
    uint8_t data = 0;
    // 释放 SDA 总线,让从机控制数据线
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
    HAL_Delay(2);
    for (uint8_t i = 0; i < 8; i++) {
        data <<= 1;
        // SCL 高电平,从机输出数据
        HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
        HAL_Delay(5);
        if (HAL_GPIO_ReadPin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin) == GPIO_PIN_SET) {
            data |= 0x01;
        }
        // SCL 低电平,准备下一位
        HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);
        HAL_Delay(2);
    }
    // 主机发送应答位
    if (ack == 0) {
        HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_RESET); // 应答(低电平)
    } else {
        HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);   // 非应答(高电平)
    }
    HAL_Delay(2);
    // 产生应答时钟脉冲
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_SET);
    HAL_Delay(5);
    HAL_GPIO_WritePin(Soft_IIC_SCL_GPIO_Port, Soft_IIC_SCL_Pin, GPIO_PIN_RESET);
    HAL_Delay(2);
    // 释放 SDA 总线
    HAL_GPIO_WritePin(Soft_IIC_SDA_GPIO_Port, Soft_IIC_SDA_Pin, GPIO_PIN_SET);
    return data;
}

// 从 OLED 读取多个字节(先写入控制字节,再连续读取)
static void OLED_ReadBytes(uint8_t ctrl_byte, uint8_t *data, uint8_t len) {
    I2C_Start();
    I2C_SendByte(OLED_ADDR);          // 发送设备写地址
    I2C_SendByte(ctrl_byte);          // 发送控制字节(例如命令或寄存器地址)
    I2C_Start();                      // 重复起始条件
    I2C_SendByte(OLED_ADDR | 0x01);   // 发送设备读地址
    for (uint8_t i = 0; i < len; i++) {
        // 最后一个字节发送非应答,其余发送应答
        uint8_t ack = (i == len - 1) ? 1 : 0;
        data[i] = I2C_ReadByte(ack);
    }
    I2C_Stop();
}

AXI高速

AHB高速

APB低速

APB桥

SPI 协议详解

SPI 模式

SPI(Serial Peripheral Interface,串行外设接口)是一种高速、全双工的同步串行通信总线,根据通信角色(主 / 从)和数据传输方向,可分为以下 8 种核心工作模式:

1. 全双工模式(Full-Duplex)

  • Full-Duplex Master(全双工主模式):

    作为 SPI 通信的主设备,可同时向从设备发送数据(MOSI 线)和接收从设备返回的数据(MISO 线),是 SPI 最常用的模式之一。例如在与 SPI 显示屏、Flash 芯片通信时,主设备发送指令 / 地址的同时,可接收设备的状态反馈,通信效率最高。

  • Full-Duplex Slave(全双工从模式):

    作为从设备,被动响应主设备的时钟(SCK),在主设备发送数据的同时,向主设备传输自身数据。典型应用如多传感器组网,传感器作为从设备,主控制器发起通信时,传感器同步回传采集数据。

2. 半双工模式(Half-Duplex)

  • Half-Duplex Master(半双工主模式):

    主设备同一时间段仅能单向传输数据(要么发、要么收),需通过硬件或软件控制数据方向。适用于无需同时收发的场景,如部分简单传感器(仅需主设备读取数据,或仅需主设备下发配置),可节省总线资源。

  • Half-Duplex Slave(半双工从模式):

    从设备仅能在主设备指定时段单向传输数据,常见于低功耗外设,减少不必要的信号交互。

3. 只读模式(Receive Only)

  • Recieve Only Master(只读主模式):

    主设备仅接收从设备数据,MOSI 线无数据输出,仅通过 SCK 提供时钟,从设备通过 MISO 线持续发送数据。适用于数据采集类场景(如高速 ADC 采样),主设备仅需读取采样结果,无需下发指令。

  • Recieve Only Slave(只读从模式):

    从设备仅向主设备发送数据,不接收主设备的任何指令 / 数据,典型如数据广播类外设(如温湿度传感器持续上报数据)。

4. 只发模式(Transmit Only)

  • Transmit Only Master(发送主模式):

    主设备仅向从设备发送数据,MISO 线无数据输入,适用于仅需下发控制指令的场景(如 LED 点阵屏控制,主设备持续发送显示数据,从设备无反馈)。

  • Transmit Only Slave(发送从模式):

    从设备仅接收主设备数据,无任何数据输出,常见于单向控制类外设(如继电器模块,仅接收主设备的通断指令)。

SPI 片选

SPI 通过片选(NSS,Slave Select)信号确定当前通信的从设备,多从设备场景下,仅被选中的从设备响应主设备的时钟和数据信号。

1. Hardware NSS Signal(硬件片选)

硬件片选由 SPI 控制器的专用 NSS 引脚实现,信号电平由硬件电路或控制器自动控制,稳定性高,减少软件开销。

  • Hardware NSS Input Signal(硬件 NSS 输入信号):

    该模式下 NSS 作为输入引脚,主设备通过检测 NSS 电平判断自身是否被选为 “从设备”(极少场景);或从设备通过 NSS 输入电平判断是否被主设备选中(主流场景)。通常高电平为 “未选中”,低电平为 “选中”,部分外设支持高电平片选,需匹配硬件手册。

  • Hardware NSS Output Signal(硬件 NSS 输出信号):

    仅主设备支持,NSS 作为输出引脚,主设备通信时自动拉低对应从设备的 NSS 电平,通信结束后拉高,实现精准的从设备选中控制。例如主设备挂载多个 SPI 从设备时,通过硬件 NSS 输出自动切换选中的从设备,无需软件干预。

2. Software NSS Signal(软件片选)

软件片选无需专用 NSS 引脚,可任意指定通用 IO 口作为片选引脚,通过软件控制引脚电平(高 / 低)实现从设备选择,灵活性高,适用于无专用 NSS 引脚或多从设备(超过硬件 NSS 数量)的场景。

  • 实现方式:初始化指定 IO 口为输出模式,通信前拉低目标从设备的片选 IO,通信完成后拉高,避免多个从设备同时响应。
  • 注意事项:软件片选需严格同步时钟(SCK)和片选电平,避免因软件执行延迟导致从设备误触发。

SPI 数据传输

SPI 数据传输的核心参数决定了数据的封装格式和解析规则,需主从设备严格一致,否则会出现数据解析错误。

1. Frame Format(协议格式)

SPI 主流协议格式为Motorola(摩托罗拉)格式,也是几乎所有 SPI 外设默认支持的格式;少数场景会兼容 TI 格式(同步串行通信的另一种格式),但非 SPI 标准,需特殊配置。

  • Motorola 格式特征:数据以帧为单位,时钟(SCK)同步,片选(NSS)拉低后开始传输,每帧数据的位宽由 Data Size 定义,高位 / 低位先行由 First Bit 定义。

2. Data Size(数据位宽)

定义单次传输的数据位数,需主从设备匹配,否则会出现数据截断或错位。

  • 8bits(八位传输):

    最常用的位宽,适用于绝大多数外设(如 Flash、传感器、显示屏),单帧传输 1 字节数据,解析简单,兼容性强。

  • 16bits(16 位传输):

    适用于高精度数据传输场景(如 ADC/DAC、电机驱动),单帧传输 2 字节数据,可减少传输次数,提升效率。

    • 从模式注意事项:单片机作为从设备时,需提前确认对接设备的传输位宽(8/16bits),若主设备发送 16bits 数据,从设备配置为 8bits 会导致仅接收低 8 位,高 8 位丢失;反之则会接收无效数据。

3. First Bit(数据位序)

定义数据帧中第一位传输的是高位还是低位,需主从设备严格一致。

  • MSB First(高位先行):

    数据的最高位(Most Significant Bit)先传输,是 SPI 默认且最主流的配置,几乎所有显示屏、Flash、传感器均采用此模式。例如传输字节 0x81(二进制 10000001),先传输最高位 “1”,最后传输最低位 “1”。

  • LSB First(低位先行):

    数据的最低位(Least Significant Bit)先传输,仅少数特殊外设(如部分工业传感器)使用,需手动配置控制器匹配。

SPI 时钟配置

SPI 的时钟(SCK)是同步通信的核心,时钟参数决定了通信速度和数据采样时机,需根据外设的最大支持速度和传输稳定性配置。

1. Prescaler(分频系数)

SPI 控制器的时钟源通常为单片机的系统时钟(如 72MHz、168MHz),通过分频系数降低时钟频率,得到 SPI 的实际波特率。

  • 常见分频系数:2、4、8、16、32、64、128 等,分频系数越小,SCK 频率越高,通信速度越快;反之则速度越慢。
  • 配置原则:需低于外设支持的最大 SCK 频率(如多数 SPI Flash 支持最高 108MHz,显示屏多支持≤50MHz),同时考虑传输距离(远距离传输需降低频率,避免信号衰减)。

2. Baud Rate(波特率)

SPI 的波特率即 SCK 的频率,计算公式为:波特率 = 系统时钟频率 / 分频系数。

  • 示例:系统时钟 72MHz,分频系数 8,则波特率 = 72/8=9MHz,即每秒传输 9M 位数据。
  • 注意:全双工模式下,波特率同时决定 MOSI 和 MISO 的传输速度;单向模式(只读 / 只发)仅决定对应数据线的速度。

3. Clock Polarity (CPOL)(时钟极性)

定义 SPI 时钟线(SCK)在空闲状态(无数据传输时)的电平。

  • CPOL=0(LOW):空闲时 SCK 为低电平,数据传输时 SCK 在高 / 低电平间切换。
  • CPOL=1(HIGH):空闲时 SCK 为高电平,数据传输时 SCK 在低 / 高电平间切换。

4. Clock Phase (CPHA)(时钟相位)

定义数据采样的时机,即在 SCK 的第几个边沿(上升沿 / 下降沿)采集数据。

  • CPHA=0(1 Edge):第一个边沿采样

    • 若 CPOL=0(空闲低):SCK 从低到高的上升沿(第一个边沿)采样数据,下降沿发送数据;
    • 若 CPOL=1(空闲高):SCK 从高到低的下降沿(第一个边沿)采样数据,上升沿发送数据。
  • CPHA=1(2 Edge):第二个边沿采样

    • 若 CPOL=0(空闲低):SCK 从高到低的下降沿(第二个边沿)采样数据,上升沿发送数据;
    • 若 CPOL=1(空闲高):SCK 从低到高的上升沿(第二个边沿)采样数据,下降沿发送数据。

时钟模式组合(CPOL+CPHA)

CPOL 和 CPHA 的组合决定了 SPI 的 4 种核心时钟模式(Mode 0~3),是 SPI 配置的关键:

  • Mode 0:CPOL=0,CPHA=0(空闲低,上升沿采样)→ 最主流模式;
  • Mode 1:CPOL=0,CPHA=1(空闲低,下降沿采样);
  • Mode 2:CPOL=1,CPHA=0(空闲高,下降沿采样);
  • Mode 3:CPOL=1,CPHA=1(空闲高,上升沿采样)。

SPI CRC

CRC(Cyclic Redundancy Check,循环冗余校验)是 SPI 的可选数据校验机制,用于检测传输过程中的数据错误,提升通信可靠性。

1. CRC Polynomial(CRC 多项式)

SPI 控制器通过预设的 CRC 多项式生成校验码,主设备发送数据时附加 CRC 码,从设备接收后重新计算 CRC 并与接收的 CRC 码对比,判断数据是否出错。

  • 常用多项式:SPI 默认采用 CRC-8(多项式为 x⁸+x⁷+x⁶+x⁴+x²+1,对应十六进制 0x107),部分控制器支持 CRC-16(如 0x8005)。
  • 配置方式:需主从设备配置相同的 CRC 多项式,否则校验结果不匹配;若外设不支持 CRC,需关闭 SPI 的 CRC 功能,避免数据附加冗余位导致解析错误。
  • 应用场景:适用于高可靠性要求的场景(如工业控制、医疗设备),普通消费类外设(如显示屏)通常关闭 CRC 以提升传输效率。

2. CRC 校验流程

  1. 主设备初始化 CRC 计算器,写入待传输数据,生成 CRC 码;
  2. 主设备发送数据帧(含有效数据 + CRC 码);
  3. 从设备接收数据后,用相同多项式计算 CRC,对比接收到的 CRC 码;
  4. 若一致,确认数据正确;若不一致,判定传输错误,可触发重传或报错。

SPI 额外注意事项

  1. 总线拓扑:SPI 支持一主多从的星形拓扑,需注意片选信号的隔离和匹配,避免信号串扰;
  2. 信号电平:SPI 通常为 3.3V 电平,部分外设支持 5V,需通过电平转换芯片匹配,避免引脚烧毁;
  3. 传输同步:主设备的 SCK 时钟需与数据传输严格同步,高频传输时需考虑 PCB 走线长度,减少信号延迟;
  4. 中断 / DMA:高速 SPI 传输建议使用 DMA(直接存储器访问),减少 CPU 占用;低速传输可使用中断或轮询方式。

片选和数据/命令控制

涉及到数据传输。后面再补充

1. GRAM 写操作模式

LCD 驱动 IC 具有 GRAM(图形显存)写模式。当主机发送写显存命令(通常为 0x2C)后,控制器进入该模式,后续接收的所有数据均被写入 GRAM,并直接反映在屏幕上。

重要行为:

  • 在 GRAM 写模式下,如果 DC(数据/命令)引脚被意外拉低(即发送了命令),控制器会立即退出 GRAM 模式,转而执行命令解析。
  • 因此,连续写入像素数据期间必须保持 DC 为高电平(数据模式)。

2. 命令分类

根据是否需要附带数据,LCD 命令可分为三类:

类型说明
无数据操作命令仅发送命令字节,无后续数据。例如:软件复位、进入睡眠模式等。
有数据操作命令发送命令后,必须发送固定数量的数据字节。例如:设置对比度、伽马校正。
不定长数据操作发送命令后,可连续发送任意长度的数据,直到被新命令打断。典型例子:写显存命令(0x2C)。

对于写显存操作,通常需要先通过 0x2A 和 0x2B 命令设置窗口(地址范围),包括起始坐标 (x, y) 和结束坐标 (x1, y1)。之后发送的像素数据将按以下规则自动填充:

  • 第一个数据写入 (x, y)。
  • 随后每个数据依次写入 (x+1, y),(x+2, y),… 直到该行末尾(x1)。
  • 到达行末后,列地址自动加 1(y+1),行地址回到起始列 x,继续写入。
  • 重复上述过程,直到最后一个数据写入 (x1, y1)。此后,控制器忽略任何额外发送的数据,直至收到新的命令或重新设置窗口。

注:地址递增方向(先 X 后 Y 或先 Y 后 X)可通过特定寄存器配置,用于实现横屏/竖屏刷新方向。


3. SPI 通信方式:4 线制与 3 线制

3.1 4 线制 SPI(4W)

  • 信号线:SCK、SDI/MOSI、CS、DC(数据/命令区分线)。
  • 主机通过 DC 电平告知当前传输的是命令(DC=0)还是数据(DC=1)。

8 位模式

  • 每个传输周期为 8 个时钟周期。CS 可保持低电平以连续发送多个字节。

16 位模式

  • 若 LCD 控制器仅支持 8 位接收,则 16 位传输会被自动拆分为两个连续的 8 位传输。
  • 关键限制:发送命令时(如 0x2A、0x2B、0x2C),必须使用 8 位传输。若强行使用 16 位发送一个 8 位命令(例如高 8 位为命令,低 8 位填充 0),控制器会将低 8 位误判为数据,导致错误。 因此,16 位模式通常仅用于批量传输像素数据(颜色值),以提高效率。

3.2 3 线制 SPI(3W)

  • 无专用 DC 线。命令/数据的区分信息嵌入在数据流中:每个 9 位传输周期内,第 1 位为 DC 标志(1=数据,0=命令),后续 8 位为有效数据。

8 位模式的软件模拟

主机需用 GPIO 精确产生 9 个时钟周期:

// 假设已定义 DC_flag(0或1),data_byte 为要发送的8位数据

// 1. 发送 DC 标志位
HAL_GPIO_WritePin(SPI_SDIO_GPIO_Port, SPI_SDIO_Pin, DC_flag);
HAL_GPIO_WritePin(SPI_SCK_GPIO_Port, SPI_SCK_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(SPI_SCK_GPIO_Port, SPI_SCK_Pin, GPIO_PIN_RESET);

// 2. 发送 8 位数据(MSB 优先)
for (int i = 7; i >= 0; i--) {
    uint8_t bit = (data_byte >> i) & 0x01;
    HAL_GPIO_WritePin(SPI_SDIO_GPIO_Port, SPI_SDIO_Pin, bit);
    HAL_GPIO_WritePin(SPI_SCK_GPIO_Port, SPI_SCK_Pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(SPI_SCK_GPIO_Port, SPI_SCK_Pin, GPIO_PIN_RESET);
}

16 位模式的处理技巧

在 3 线制下,若想利用硬件 SPI 的 16 位传输能力,需解决“每 9 位为一个周期”与硬件“每 16 位连续时钟”之间的矛盾。常用两种方法:

方法原理实现要点
方法一:屏幕自动截取LCD 控制器的移位寄存器仅保留前 9 位,丢弃后续时钟产生的额外位。发送完整的 16 位数据,其中高 9 位有效(1 位 DC + 8 位数据),低 7 位任意填充(如 0)。发送完毕后拉高 CS,控制器在 CS 上升沿处理已接收的前 9 位,忽略剩余位。
方法二:CS 提前终止在发送完前 9 位后立即拉高 CS,强制终止本次传输,后 7 位不发送。该方法要求硬件能精确控制 CS 的拉高时机,但多数 SPI 外设难以在传输中途改变 CS 状态,故实际较少使用。

推荐方法一:构造 16 位数据 (DC_flag << 8) | data_byte,然后左移 7 位(使有效位对齐到高 9 位),低 7 位补 0。调用 HAL_SPI_Transmit 发送该 16 位数据,并在传输完成后拉高 CS。屏幕控制器将正确解析前 9 位。


4. 关键注意事项

  • 在 GRAM 写模式下,务必保持 DC 为高电平,否则会意外退出数据模式。
  • 对于 4 线制 SPI,发送 8 位命令时禁止使用 16 位传输。
  • 对于 3 线制 SPI 的 16 位传输,必须确保 LCD 控制器能够忽略多余的时钟位(多数主流控制器支持),否则需退回到 8 位模拟方式。
  • 窗口地址设置后不会自动清空,除非重新发送 0x2A / 0x2B 命令覆盖。

配置RCC时钟提供源为外部晶振

RCC配置项

  • High Speed Clock (HSE)高速时钟源

    • Disable 关闭
    • BYPASS CLock Source
    • Crystal/Ceramic Resonator
  • Low Speed Clock (LSE)低速时钟源

    • Disable 关闭
    • BYPASS CLock Source
    • Crystal/Ceramic Resonator
  • PLLCLK 锁相环,选择为外部晶振

  • APB1 Prescaler APB1的分频系数不能过高

TIM2设置

  • slave mode
  • trigger source
  • clock source
  • channel1
  • channel2
  • channel3
  • channel4
  • combined Channels

自动重装周期配置等(设置为1ms)

  • Counter Period设置为1000 即1ms(要先设置这个才能设置Prescaler )

  • Prescaler 设置为72000 即72-1

开启中断

  • NVIC Settings
    • TIM2 global interrupt 开启

启动和使用

  • 在main.c中调用启动函数
#include "tim.h"
HAL_TIM_Base_Start_IT(&htim2);
  • 在main.c中重写中断回调
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
	if(htim->Instance==TIM2)//如果是定时器2
	{
		uint8_t pData = 1;
		HAL_UART_Transmit(&huart1, &pData, 1, 100);
	}
}

MCU主动发送下位机再返回

  • 等待一定时间让下位机发送完成

下位机主动往MCU发送

  • 触发中断,将
单片机:STM32F103C8T6
  • M3内核
  • 20KB的RAM
  • 64KB的ROM
存储设备:W25Q64(SPI通信)
项目大小/数量说明
总容量64 Mbit = 8 MByte = 8,388,608 B地址范围 0x000000 ~ 0x7FFFFF
地址位宽24 位最大 16 MB 地址空间,实际用 8 MB
页 Page256 B共 32768 页,页是最小编程单位
扇区 Sector4 KB = 4096 B共 2048 个扇区,最小擦除单位
块 Block64 KB = 65536 B共 128 个 64KB 块
32KB 块32 KB支持 32KB 块擦除,共 256 个
每扇区16 页4KB / 256B = 16
每 64KB 块16 个扇区 = 256 页64KB / 4KB = 16
每 32KB 块8 个扇区 = 128 页32KB / 4KB = 8

STM32 SPI初始化

static SPI_HandleTypeDef hspi1;

HAL_StatusTypeDef spi_bus_init(void)
{
    GPIO_InitTypeDef gpio = {0};

    __HAL_RCC_GPIOA_CLK_ENABLE(); // 开启时钟
    __HAL_RCC_SPI1_CLK_ENABLE(); // 开启时钟 

    gpio.Pin = GPIO_PIN_5 | GPIO_PIN_7; //SPI1的引脚
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOA, &gpio);

    gpio.Pin = GPIO_PIN_6; //SPI1的引脚
    gpio.Mode = GPIO_MODE_INPUT;
    gpio.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(GPIOA, &gpio);

    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
    hspi1.Init.NSS = SPI_NSS_SOFT;
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4;
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi1.Init.CRCPolynomial = 7U;

    return HAL_SPI_Init(&hspi1);
}

SPI_HandleTypeDef *spi_bus_handle(void)
{
    return &hspi1;
}

读写W25Q64

选中/取消选中设备(片选)
static void select_device(const w25q64_t *dev)
{
    HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET);
}

static void deselect_device(const w25q64_t *dev)
{
    HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET);
}
向w25q64发送和接收数据(封装SPI收发)
static w25q64_status_t transmit(w25q64_t *dev, const uint8_t *data, size_t size)
{
    HAL_StatusTypeDef status;

    status = HAL_SPI_Transmit(dev->spi, (uint8_t *)(uintptr_t)data,
                              (uint16_t)size, 100U); //超时时间
    return (status == HAL_OK) ? 0 : -1;//0成功,-1超时
}

static w25q64_status_t receive(w25q64_t *dev, uint8_t *data, size_t size)
{
    HAL_StatusTypeDef status;

    status = HAL_SPI_Receive(dev->spi, data, (uint16_t)size,100U);//超时时间
    return (status == HAL_OK) ? 0 : -1;//0成功,-1超时
}

读取状态寄存器

static w25q64_status_t read_status_1(w25q64_t *dev, uint8_t *status_reg)
{
    const uint8_t command = 0x05U;
    w25q64_status_t status;

    select_device(dev);//片选选中
    status = transmit(dev, &command, 1U);
    if (status == 0) {
        status = receive(dev, status_reg, 1U);
    }
    deselect_device(dev);//片选取消
    return status;
}

W25Q64写使能

static w25q64_status_t write_enable(w25q64_t *dev)
{
    const uint8_t command = 0x06U;
    w25q64_status_t status;

    select_device(dev); //片选选中
    status = transmit(dev, &command, 1U);
    deselect_device(dev); //片选取消
    return status;
}

初始化

w25q64_status_t w25q64_init(w25q64_t *dev,
                            SPI_HandleTypeDef *spi,
                            GPIO_TypeDef *cs_port,
                            uint16_t cs_pin)
{
    GPIO_InitTypeDef gpio = {0};
    const uint8_t wake_command = 0xABU; //唤醒w25q64
    w25q64_status_t status;
	//保存硬件信息
    dev->spi = spi;
    dev->cs_port = cs_port;
    dev->cs_pin = cs_pin;
    dev->jedec_id = 0U;
	//打开 CS GPIO 时钟
    if (cs_port == GPIOA) {
        __HAL_RCC_GPIOA_CLK_ENABLE();
    } else if (cs_port == GPIOB) {
        __HAL_RCC_GPIOB_CLK_ENABLE();
    } else if (cs_port == GPIOC) {
        __HAL_RCC_GPIOC_CLK_ENABLE();
    } else {
        return -1;
    }
	//配置 CS 引脚
    gpio.Pin = cs_pin;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_WritePin(cs_port, cs_pin, GPIO_PIN_SET);
    HAL_GPIO_Init(cs_port, &gpio);

    select_device(dev);//片选选中
    status = transmit(dev, &wake_command, 1U);//唤醒
    deselect_device(dev);//片选取消
    if (status != 0) {
        return status;
    }
    HAL_Delay(1U);

    status = w25q64_wait_ready(dev, 100U);
    if (status != W25Q64_OK) {
        return status;
    }
    status = w25q64_read_jedec_id(dev, &dev->jedec_id);
    if (status != W25Q64_OK) {
        return status;
    }
    return (dev->jedec_id == W25Q64_EXPECTED_ID) ? W25Q64_OK : W25Q64_BAD_ID;
}

等待 w25q64空闲

w25q64_status_t w25q64_wait_ready(w25q64_t *dev, uint32_t timeout_ms)
{
    const uint32_t start = HAL_GetTick();
    uint8_t status_reg = 0U;
    w25q64_status_t status;

    do {
        status = read_status_1(dev, &status_reg);
        if (status != 0) {
            return status;
        }
        if ((status_reg & 0x01U) == 0U) {
            return 0;
        }
    } while ((HAL_GetTick() - start) < timeout_ms);

    return -1;
}

读取w25q64的JEDEC ID

w25q64_status_t w25q64_read_jedec_id(w25q64_t *dev, uint32_t *jedec_id)
{
    const uint8_t command = 0x9FU;
    uint8_t id[3] = {0};
    w25q64_status_t status;
    
    select_device(dev);//片选选中
    status = transmit(dev, &command, 1U);
    if (status == 0) {
        status = receive(dev, id, sizeof(id));
    }
    deselect_device(dev);//片选取消
    if (status == 0) {
        *jedec_id = ((uint32_t)id[0] << 16U) |
                    ((uint32_t)id[1] << 8U) |
                    (uint32_t)id[2];
    }
    return status;
}

读数据

w25q64_status_t w25q64_read(w25q64_t *dev, uint32_t address,
                            void *data, size_t size)
{
    uint8_t command[4];
    w25q64_status_t status;

    if ((dev == NULL) || ((data == NULL) && (size != 0U)) ||
        (address > 8UL * 1024UL * 1024UL) ||  //总大小
        (size > (size_t)(8UL * 1024UL * 1024UL - address))) {
        return W25Q64_BAD_PARAM; //判断读写的地址是否超出
    }
    if (size == 0U) {
        return 0;
    }

    command[0] = 0x03U; //读命令
    command[1] = (uint8_t)(address >> 16U);
    command[2] = (uint8_t)(address >> 8U);
    command[3] = (uint8_t)address;

    select_device(dev); //片选选中
    status = transmit(dev, command, sizeof(command));
    if (status == W25Q64_OK) {
        status = receive(dev, (uint8_t *)data, size);
    }
    deselect_device(dev); //片选取消
    return status;
}

页编程(写数据) w25q64_page_program

w25q64_status_t w25q64_page_program(w25q64_t *dev, uint32_t address,
                                    const void *data, size_t size)
{
    uint8_t command[4];
    w25q64_status_t status;

    if ((dev == NULL) || (data == NULL) || (size == 0U) ||
        (size > 256UL) ||
        ((address & (256UL - 1U)) + size > 256UL) || //页大小
        (address >= 8UL * 1024UL * 1024UL) ||
        (size > (size_t)(W25Q64_TOTAL_SIZE - address))) {
        return -1;
    }

    status = w25q64_wait_ready(dev, 10U);
    if (status != 0) {
        return status;
    }
    status = write_enable(dev);
    if (status != 0) {
        return status;
    }

    command[0] = 0x02U;
    command[1] = (uint8_t)(address >> 16U);
    command[2] = (uint8_t)(address >> 8U);
    command[3] = (uint8_t)address;

    select_device(dev);
    status = transmit(dev, command, sizeof(command));
    if (status == 0) {
        status = transmit(dev, (const uint8_t *)data, size);
    }
    deselect_device(dev);
    if (status != 0) {
        return status;
    }
    return w25q64_wait_ready(dev, 10U);
}

扇区擦除

w25q64_status_t w25q64_sector_erase(w25q64_t *dev, uint32_t address)
{
    uint8_t command[4];
    w25q64_status_t status;

    if ((dev == NULL) || (address >= (8UL * 1024UL * 1024UL)) ||
        ((address & (4096UL - 1U)) != 0U)) { //扇区数量
        return -1;
    }

    status = w25q64_wait_ready(dev, 10u);
    if (status != 0) {
        return status;
    }
    status = write_enable(dev);
    if (status != 0) {
        return status;
    }

    command[0] = 0x20U; //4K擦除
    command[1] = (uint8_t)(address >> 16U);
    command[2] = (uint8_t)(address >> 8U);
    command[3] = (uint8_t)address;

    select_device(dev);
    status = transmit(dev, command, sizeof(command));
    deselect_device(dev);
    if (status != 0) {
        return status;
    }
    return w25q64_wait_ready(dev, 10U);
}

和 littlefs 的关系

littlefs 操作W25Q64 驱动
readw25q64_read
progw25q64_page_program
erasew25q64_sector_erase
syncw25q64_wait_ready

Littlefs配置

static uint8_t read_buffer[256UL]; //三个缓存
static uint8_t program_buffer[256UL];
static uint8_t lookahead_buffer[32U];
const struct lfs_config *lfs_port_config(w25q64_t *flash)
{
    static struct lfs_config config;

    config.context = flash; //
    config.read = port_read; //读回调
    config.prog = port_program; //写回调(编程)
    config.erase = port_erase; //擦除回调
    config.sync = port_sync; //异步回调
    config.read_size = 1U;
    config.prog_size = 256UL; //页大小
    config.block_size = 4096UL;//扇区大小
    config.block_count = (8UL * 1024UL * 1024UL) / 4096UL;//扇区数量
    config.block_cycles = 500;
    config.cache_size = 256UL; //缓存大小
    config.lookahead_size = 32U;
    config.compact_thresh = 0;
    config.read_buffer = read_buffer; //littlefs 读取缓存
    config.prog_buffer = program_buffer; //littlefs 写入缓存
    config.lookahead_buffer = lookahead_buffer; //空闲块查找位图
    config.name_max = 0U;
    config.file_max = 0U;
    config.attr_max = 0U;
    config.metadata_max = 0U;
    config.inline_max = 0U;

    return &config;
}

地址范围检测

littlefs 访问 Flash 时,不直接提供绝对地址,而是提供:

block  :逻辑块编号
offset :块内偏移
size   :访问长度这个函数检查:
  1. block 不能超过块总数。
  2. offset 不能超过一个块的大小。
  3. offset + size 不能越过当前块。
static bool range_is_valid(const struct lfs_config *cfg,
                           lfs_block_t block,
                           lfs_off_t offset,
                           lfs_size_t size)
{
    return (block < cfg->block_count) &&
           (offset <= cfg->block_size) &&
           (size <= (cfg->block_size - offset));
}

读回调

static int port_read(const struct lfs_config *cfg, lfs_block_t block,
                     lfs_off_t offset, void *buffer, lfs_size_t size)
{
    w25q64_t *flash = (w25q64_t *)cfg->context;
    uint32_t address;

    if (!range_is_valid(cfg, block, offset, size)) {
        return LFS_ERR_INVAL;
    }
    address = ((uint32_t)block * cfg->block_size) + offset;
    return (w25q64_read(flash, address, buffer, size) == 0)
               ? LFS_ERR_OK
               : LFS_ERR_IO;
}

写回调

static int port_program(const struct lfs_config *cfg, lfs_block_t block,
                        lfs_off_t offset, const void *buffer, lfs_size_t size)
{
    w25q64_t *flash = (w25q64_t *)cfg->context;
    uint32_t address;
    const uint8_t *source = (const uint8_t *)buffer;

    if (!range_is_valid(cfg, block, offset, size) ||
        ((offset % cfg->prog_size) != 0U) ||
        ((size % cfg->prog_size) != 0U)) {
        return LFS_ERR_INVAL;
    }

    address = ((uint32_t)block * cfg->block_size) + offset;
    while (size != 0U) {
        if (w25q64_page_program(flash, address, source,
                                256UL) != 0) {
            return LFS_ERR_IO;
        }
        address += 256UL;
        source += 256UL;
        size -= 256UL;
    }
    return LFS_ERR_OK;
}

擦除回调

static int port_erase(const struct lfs_config *cfg, lfs_block_t block)
{
    w25q64_t *flash = (w25q64_t *)cfg->context;
    uint32_t address;

    if (block >= cfg->block_count) {
        return LFS_ERR_INVAL;
    }
    address = (uint32_t)block * cfg->block_size;
    return (w25q64_sector_erase(flash, address) == 0)
               ? LFS_ERR_OK
               : LFS_ERR_IO;
}

异步回调

static int port_sync(const struct lfs_config *cfg)
{
    w25q64_t *flash = (w25q64_t *)cfg->context;
    return (w25q64_wait_ready(flash, 500U) == 0)
               ? LFS_ERR_OK
               : LFS_ERR_IO;
}

声明

讲解stm32c8t6点灯程序从c语言到bin烧录文件的整体流程。

第一部分,比较重要,说清楚被开发工具隐藏的c语言到bin文件流程,包括交叉编译器、c语言预处理,编译,汇编,链接,各个阶段生成的文件。

第二部分,将生成的bin反向推理中断向量,代码编反汇编。

第三部分,通过bootloader+app讲解中断偏移等内容

第一部分

在window下使用交叉编译工具

  • 安装
https://developer.arm.com/downloads/-/gnu-rm
  • 配置环境变量,配置到windows环境
D:\Program Files (x86)\GNU Arm Embedded Toolchain\10 2021.10\bin
  • 由于windows执行makefile比较困难,需要一步一步编译每个.c文件,再做链接,要么就要写shell脚本,比较麻烦。现在只举一些例子。以下是点灯程序,包含main.c:对应的c文件,以及startup.s:对应的汇编启动代码,还有link.ld链接脚本。

main.c

// 寄存器地址(STM32F103 固定)
#define RCC_BASE  0x40021000
#define GPIOA_BASE 0x40010800

#define RCC_APB2ENR *(volatile unsigned int *)(RCC_BASE + 0x18)
#define GPIOA_CRL   *(volatile unsigned int *)(GPIOA_BASE + 0x00)
#define GPIOA_ODR   *(volatile unsigned int *)(GPIOA_BASE + 0x0C)

void main(void)
{
  // 1. 使能 GPIOA 时钟
  RCC_APB2ENR |= 1 << 2;

  // 2. 配置 PA0 推挽输出
  GPIOA_CRL &= ~(0xF << 0);
  GPIOA_CRL |=  (0x3 << 0);

  // 3. 点灯 PA0
  GPIOA_ODR |= 1 << 0;

  // 死循环
  while(1);
}

startup.s

.equ _estack, 0x20005000

.section .isr_vector
.global _vector_table
_vector_table:
  .word _estack
  .word Reset_Handler
  .word 0
  .word 0
  .word 0
  .word 0
  .word 0

.section .text.Reset_Handler
Reset_Handler:
  bl main
  b .

link.ld


MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   (rw)  : ORIGIN = 0x20000000, LENGTH = 20K
}


SECTIONS
{
  .text :
  {
    *(.isr_vector)     
    *(.text)           
  } > FLASH            

  .data :
  {
    *(.data)
  } > RAM AT > FLASH
}

将上面的三个文件放到同一个目录下,依次执行以下shell命令

1.将startup.s汇编文件编译成目标文件

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -c startup.s -o startup.o

2.将main.c编译成目标文件

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -c main.c -o main.o

3.使用链接文件链接生成elf文件

arm-none-eabi-ld startup.o main.o -T link.ld -o main.elf

4.elf文件转成hex文件,elf文件转成bin文件

arm-none-eabi-objcopy -O ihex main.elf main.hex
arm-none-eabi-objcopy -O binary main.elf main.bin

步骤解析:

1.将汇编文件编程成目标文件过程。

2.main.c编译成目标文件,这个步骤依赖的头文件,如果是stdio.h这种已经有的会放在arm-none-eabi\include目录下,编译器会自己引进来。如果是自己写的比如,iic.c和iic.h,main.c引入了iic.h,这时候就需要添加**-T ./**命令。把对应目录下的头文件引进来。

模式主要信号数据位宽常见外设特点
SPI 模式CS、SCK、MOSI、MISO1-bit普通 SPI简单、慢、引脚少
SD 1-bit 模式CLK、CMD、DAT01-bitSDIO/SDMMC较快,需要专用控制器
SD 4-bit 模式CLK、CMD、DAT0~DAT34-bitSDIO/SDMMC最快,支持高速/UHS

SD 卡座引脚大致对应:

SD卡引脚SD 模式SPI 模式
1 DAT3DAT3CS
2 CMDCMDMOSI/DI
5 CLKCLKSCK
7 DAT0DAT0MISO/DO
8 DAT1DAT1不用
9 DAT2DAT2不用

SPI 模式

上电/复位时,主机通过 CS/DAT3 选择:

  • 发送 CMD0 时把 CS/DAT3 拉低:进入 SPI 模式。
  • 如果 CS/DAT3 保持高:进入 SD 总线模式。

进入某种模式后,通常不能随便切换,要重新上电或复位。

CMD 当 MOSI、DAT0 当 MISO、DAT3 当 CS、`CLK共用

1. 51 架构 (8051)

51 单片机不属于 ARM 这种“通用处理器”生态,其工具链相对专有或开源。

  • SDCC (Small Device C Compiler): 这是目前 51 架构最主流的开源免费工具链。它是一套针对 8 位微控制器的优化 C 编译器,支持 MCS51 (即 51 内核)、Z80、STM8 等。配合 SDCC 本身,加上 stcgal 等烧录工具,可以组成完整的开源开发环境。
  • Keil C51 (商业): 这是 51 架构事实上的“工业标准”,由 ARM 公司(原 Keil)提供。虽然它是商业软件,但在 51 开发中使用率极高,代码密度和稳定性表现优秀。Arm GNU Toolchain 并不能直接编译 51 内核代码。

2. RISC-V 架构 (RV)

RISC-V 作为开源指令集,其 GNU 工具链非常完善,且由基金会和各大厂商积极维护。

  • RISC-V GNU Toolchain (riscv-gnu-toolchain): 这是 RISC-V 的官方 GNU 工具链,功能上类似 Arm GNU Toolchain。它包含 riscv64-unknown-elf-gcc(用于裸机)和 riscv64-linux-gnu-gcc(用于 Linux 系统)等。
    • 源码:托管在 GitHub 的 riscv-collab/riscv-gnu-toolchain。
    • 预编译包:许多 Linux 发行版(如 Ubuntu/Debian)可以直接通过 apt install gcc-riscv64-linux-gnu 或 gcc-riscv64-unknown-elf 安装。
  • 厂商定制版本: 和 ARM 一样,芯片厂商也会提供定制化的工具链。
    • SiFive:提供针对其 CoreIP 优化的 Freedom Studio(基于 Eclipse 和 GNU 工具链)。
    • 沁恒 (WCH):其 RISC-V 系列 MCU(如 CH32V系列)虽也可用标准 GNU 工具链,但官方推荐使用 MounRiver Studio(集成了定制版 GCC 和调试工具)。

3. ARM架构

ARM Cortex-M/R/A很多交叉编译器分别编译不同的系列,常用的Arm GNU Toolchain (arm-none-eabi-gcc)

4. X86架构

x86架构要根据编译的系统做调整,也就是说编译到不同的os平台,比如linux,mac,windows。

如果在windows上编译windwos应用一般使用VS的MSVC,在linux编译windwos使用MinGW-w64/w32工具链。

至于mac,更多时候是在mac是编程自己的应用。

event1

1)事件定义

首先得有一个统一的事件表示。不用搞多复杂,一个类型加一个数据就够了:

typedef struct {
    uint16_t type;    /* 事件类型 */
    uint16_t param;   /* 附带参数 */
} event_t;

事件类型用枚举来管理:

enum {
    EVT_NONE = 0,
    EVT_KEY_PRESS,
    EVT_KEY_RELEASE,
    EVT_UART_RX,
    EVT_TIMER_TICK,
    EVT_ADC_DONE,
    // ...
};

2)事件队列

队列本质上就是一个环形缓冲区,中断里往里塞事件,主循环里取出来处理:

#define EVT_QUEUE_SIZE  32

static event_t evt_queue[EVT_QUEUE_SIZE];
static volatile uint8_t head = 0;
static volatile uint8_t tail = 0;

/* 中断中调用:投递事件 */
void event_post(uint16_t type, uint16_t param)
{
    uint8_t next = (head + 1) % EVT_QUEUE_SIZE;
    if (next != tail) {          /* 队列没满 */
        evt_queue[head].type  = type;
        evt_queue[head].param = param;
        head = next;
    }
}

/* 主循环中调用:取出事件 */
bool event_get(event_t *evt)
{
    if (tail == head)
        return false;            /* 队列为空 */

    *evt = evt_queue[tail];
    tail = (tail + 1) % EVT_QUEUE_SIZE;
    return true;
}

3)事件分发

取出事件之后,怎么交给对应的处理函数?最简单的做法是用一张函数指针表:

typedef void (*event_handler_t)(uint16_t param);

/* 处理函数注册表 */
static event_handler_t handler_table[EVT_MAX] = { NULL };

void event_register(uint16_t type, event_handler_t handler)
{
    if (type < EVT_MAX)
        handler_table[type] = handler;
}

void event_dispatch(event_t *evt)
{
    if (evt->type < EVT_MAX && handler_table[evt->type]) {
        handler_table[evt->type](evt->param);
    }
}

把这三部分组合起来,主循环就变得非常清爽了:

int main(void)
{
    system_init();

    /* 注册各模块的事件处理函数 */
    event_register(EVT_KEY_PRESS,  on_key_press);
    event_register(EVT_UART_RX,    on_uart_receive);
    event_register(EVT_TIMER_TICK, on_timer_tick);
    event_register(EVT_ADC_DONE,   on_adc_done);

    event_t evt;
    while (1) {
        if (event_get(&evt)) {
            event_dispatch(&evt);
        } else {
            __WFI();  /* 没事件就睡觉,省电 */
        }
    }
}
/* 中断里只投递事件 */
void USART1_IRQHandler(void)
{
    uint8_t data = USART1->DR;
    event_post(EVT_UART_RX, data);  // 投递完就走
}

单片机?无非操作寄存器罢了

1. 一切动作,无非操作寄存器

操作 SFR(特殊功能寄存器)就能让 IO 口拉高拉低。 操作 SFR 就能把中断总闸合上。 操作 SFR 就能让定时器开始哒哒哒地数数。 把 IO 引脚的手柄一高一低地掰出节奏,那就是 IIC,就是 SPI,就是 PWM。 ……

什么串口、什么 I2C、什么 SPI、什么 PWM 输出,拆穿了就是照着协议,在正确的时间点去写那几个寄存器、读那几个寄存器。

举个最俗的例子:串口。 忽略掉所有细节,你看: 把 SCON 配好模式,把定时器 1 的 TH1、TL1 装好初值,波特率就定下来了; 再往 PCON 里把 SMOD 那一位拨一下,波特率还能翻倍; 然后往 SBUF 这个寄存器里扔一个字节,硬件自己就按约定的节拍,把起始位、数据位、校验位、停止位一帧一帧颠出去。 另一边,从 SBUF 里读一个字节,电脑发过来的数据就拿到了。 “通过设置几个寄存器的值,不就设置好了?” 对,就是这个道理。

但是,“细节完全不能忽略”,我们学的,恰恰就是怎么去翻手册找到这些寄存器,搞清楚协议是什么样子,以及把这些寄存器在正确的时间点摆弄成什么值。


2. 你写的 C,怎么变成芯片里的动作(以51为例)

2.1 写 C

你想点个灯:

P0_1 = 1;   // 实际操作的是 P0 寄存器的第1位

你眼里是一个变量赋值。这行 C,经过下面的流水线,最终就会变成驱动一颗 LED 亮起的物理电流。

2.2 编译、汇编、链接 → .hex

Keil C51 或者 SDCC 这些编译器,帮你把 C 变成了汇编,再把汇编变成机器码,链接成 .hex 文件。 不知道里面发生了什么,不知道就不知道呗,又不影响写 C。没错,写应用层的时候可以这么任性,为什么单片机现在入门如此简单,就是因为有了c,有编译器帮你去做这些复杂的操作。 但如果哪天你被一个硬件 fault 卡住,想抠那几微秒的时序,就会发现,这个黑箱里的每一步都是你的救命稻草。

2.3 烧录

.hex 文件里是一条一条的机器指令,被烧录器逐字节写进 Flash。

flash
地址     		指令         真实存储
0x0000  [jump 0x0080]     0001 0080  <-假设
0x0004  [mov xx, xx]      0002 xxxx  <-假设
0x0008  [ ]                          <-假设
...     ....

2.4 上电执行

一上电,芯片内部的硬件逻辑“咔嗒”一下,把 PC(程序计数器)归零。 PC=0,CPU 从 Flash 的 0 地址拿出第一条指令,塞进译码器,译码器认出来这是什么活,执行完毕,PC 自动指向下一条指令的地址(51 指令不定长,有 1 字节、2 字节、3 字节,PC 有时候+1有时候+2,或者+3)。 就这么一条一条地从 Flash 里取指令,一条一条地执行,直到断电。


3. 这不巧了吗?计算机组成原理那本书全活了

上电后,PC=0,从 Flash 地址 0 处取第一条指令(针对51,复位向量就在 0x0000)。 指令五花八门: 有操作 RAM 的,把数据搬来搬去; 有操作 SFR 的,把 IO 口拉高拉低,把中断闸门拉开合上; 有跳转的,把 PC 直接踹到别的地址; 还有空转的 NOP,纯粹耗掉一个机器周期。 这不就是你前面那句话——“无非是操作寄存器”吗?

那 CPU 又是个什么玩意?为什么能看懂指令?为什么能算加法? 大学教过啊:译码器把一大串 0/1 翻译成“哦,这是要把某个寄存器的第几位拉高”;ALU 在那吭哧吭哧做加减乘除与或非。

CPU 怎么从 Flash 取指令?怎么操作 RAM?怎么操作 SFR? Flash 是一栋房子,RAM 是另一栋房子,SFR 又是一排控制柜,CPU 自个儿是个总调度室。 房子之间怎么通信?拉一根电话线嘛——这就是总线。 把地址线、数据线、控制线捆在一起,CPU 往地址线上放门牌号,数据线上就传来对应的内容。 单片机这个小区里房子可多了:I2C、SPI 这些是出小区的省道,通向板子上的传感器、存储器; 小区内部的电话线也有讲究,有些用铜线就够(低速总线 APB),有些得上光纤(高速总线 AHB)。高低速总线就分出来了。

那数据传输怎么搞? 让 CPU 从 A 房子搬数据,再搬到 B 房子?行,但 CPU 被这种纯体力活占着太浪费。 于是引出一个专门干这种活儿的搬运工——DMA。CPU 交代一句:“把 UART 接收到的这 256 个字节搬到 RAM 里”,DMA 就闷头搬完,不劳 CPU 再费神。

什么 RAM?SRAM、DRAM。什么 Flash?NOR Flash、NAND Flash。教材里分过类,在这里全都对得上号。 就连中断,也不过是硬件在某个事件发生时,硬生生把 PC 当前值压栈,然后把 PC 强制指到一个预设的中断入口地址,CPU 就跑去执行中断函数了——计算机组成原理里的“中断响应周期”,在你这片 51 上原原本本地发生。

真棒,大学都学过的。知行合一,知道计算机里面有什么,单片机怎么把这个过程实现,怎么使用这些知识。


4. 知不知道又怎样?抽象叠着抽象

可是,上面这些东西,你不知道,也不妨碍你敲出能跑的 C 代码。

你写 EA = 1;,开启了总中断。 你写 P0 = 0xFF;,把 P0 口的 8 个 IO 全拉高了。 你写 int a = 0; a++;,本质上就是在操作 RAM 里的某个寄存器(存储单元),C 编译器帮你决定用哪个地址,帮你生成操作它的指令。 你全程都在读写寄存器,只不过 C 语言给你披了一层温柔的外衣。

学计算机的,最会玩抽象。 从物理电平 → 链路帧 → IP 包 → TCP 流 → HTTP 报文,一层一层往上叠,底层难用是吧?那我再给你抽象一层。

51 抽象层次还不算高,你 EA = 1 一眼还能看出是去摸一个叫 EA 的开关。 到了 STM32,HAL 库直接挡在你和寄存器之间: HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); 这背后是 HAL 库帮你找到 GPIOA 的基地址,算出偏移,定位到输出数据寄存器,然后往那个 32 位寄存器里写了个 (1<<5)。 抽象!你只需告诉管家“把书房的灯打开”,管家自己就去掰开关了。


5. 抽象下面是什么?从 EA = 1 一路拆到晶体管

那如果我们偏偏要撕开这层抽象呢?

EA = 1; 这个 C 语句,在 8051 的语境下,EA 是个特殊功能寄存器中的一位——总中断使能位。 它在字节地址 0xA8(即 IE 寄存器)的第7位。 注意:这里有个大坑。 如果你想用字节操作开总中断,应该写 IE = 0x80;,也就是把 0xA8 这个字节地址里的最高位置 1。 如果你写 EA = 1;,编译器可能会聪明地把它翻译成位操作指令,而 8051 有一个独立的位寻址空间,其中位地址 0xAF 才是 EA。 你绝对不能写成“操作位地址 0xA8”,那个位地址对应的是 IE 寄存器的第 0 位 EX0(外部中断0使能),你这样一搞,总中断没开,外部中断 0 倒给点着了。 所以,严谨地说: EA=1 → 汇编是 SETB 0xAF,机器码 0xD2 0xAF。 IE=0x80 → 汇编是 MOV 0xA8, #0x80,机器码 0x75 0xA8 0x80。 两者都能开总中断,但走的路不同,寻址方式不同。这就是细节。

好,我们把 SETB 0xAF 这条汇编指令翻成机器码。 查 8051 指令集:SETB bit 的指令编码是 0xD2,后面跟一个直接位地址。 位地址 0xAF,二进制就是 1010 1111。 所以整条指令的机器码是: 1101 0010 1010 1111 (0xD2 0xAF)

这两个字节被烧录进 Flash 的某个地址,比如 0x0000 和 0x0001。 上电,PC=0,从 Flash 地址 0 取出 1101 0010,塞进译码器。 译码器一看:“1101 0010?这是 SETB 指令,后面还得再取一个字节。” PC 自动加 1,从地址 1 取出 1010 1111,译码器明白:“要去位寻址空间里,把位地址 0xAF 拉高。” 内部硬件选中位地址 0xAF 所对应的那个锁存器,把它电平拉到高。 这个锁存器恰恰好连着中断控制逻辑的总闸门,闸门一开,整个中断系统就激活了。 从你敲下 EA=1 到中断闸门打开,多米诺骨牌一张没塌,全按你设计的顺序倒下。


6. 那到底要操作哪些寄存器?怎么查?

这时候你问:“操作哪些寄存器?” 答:翻数据手册。一本几百页的 PDF,拆穿了就是一本开关面板说明书。

以经典 51 为例,这几个面板你必须摸透:

6.1 IO 口面板

P0, P1, P2, P3。 写 1 就是输出高电平(同时引脚内部弱上拉),写 0 就是输出低电平。想读引脚状态?直接读这个寄存器就行。

6.2 中断调度面板

IE(中断使能寄存器,字节地址 0xA8):

  • EA (IE.7):总闸
  • ET0 (IE.1):定时器0中断分闸
  • EX0 (IE.0):外部中断0分闸 …… IP(中断优先级寄存器,字节地址 0xB8): 哪个中断优先级高,在这里调。 TCON(定时器/计数器控制寄存器,字节地址 0x88): 里面有定时器的启停开关(TR0、TR1),还有外部中断的触发方式选择(IT0、IT1)。

6.3 定时器车间

TMOD(定时器模式寄存器,不可位寻址): 决定定时器0/1是当定时器还是计数器,以什么方式工作(13位/16位/8位自动重装)。 TCON:启停控制,溢出标志也在这儿。 TH0/TL0, TH1/TL1:装初值的地方。 想用定时器产生精确的时间间隔?在这几个开关和计数器里填好数就行。

6.4 串口收发室

SCON(串口控制寄存器,字节地址 0x98): 选模式(8位/9位),允许接收(REN),发送/接收中断标志。 PCON(电源控制寄存器,字节地址 0x87): SMOD 位控制波特率是否加倍,其他位还有掉电模式、空闲模式。 SBUF(串口数据缓冲器,字节地址 0x99): 写 SBUF = 发;读 SBUF = 收。地址虽是一个,物理上是两个独立的寄存器,发收分离,硬件自己判别。

6.5 复用说明(以增强型51或ARM为例)

经典 51 的引脚功能是硬件绑死的:P3.0/P3.1 固定是 RXD/TXD,一打开串口,这两个脚自动切换到第二功能,不用额外设置。 但后来的 STC15/STC8 或者 STM32,有了专门的引脚功能切换寄存器(如 P_SW1、AFIO),你才能“操作 SFR 就能设置引脚复用”。 这个细节知道就好,别在新手期对着 AT89C51 手册找复用寄存器。

查这些开关的方法极简单: 打开手册的 “Special Function Registers” 表格,或看每个外设章节的寄存器描述,一张表告诉你寄存器名、地址、每一位叫什么、写0写1分别是什么意思。 你要做的,就是对着那张表把二进制拼出来。


7. 从51跳到ARM,无非开关面板变大了

有了 51 的底子,去碰 STM32,你会恍然发现,所有套路一模一样,只是:

  • 寄存器变成了 32 位,一口气能控制 16 个 IO 口的模式、上下拉、输出速度。
  • 每个外设前面多了一道时钟闸门:你得先往 RCC 相关寄存器里写个 1,把外设的钟敲响,它才开始工作。51 不用,51 的外设钟大多默认是通的,或者跟主时钟捆在一起。
  • 中断控制器变得更复杂,NVIC,还要设优先级分组,但灵魂还是那套:使能位、标志位、优先级位。

STM32HAL 库呢? 它就是在这一大片寄存器面板上,又给你糊了一层墙纸。 你调用 HAL_UART_Transmit(&huart1, pData, Size, Timeout),HAL 在后头帮你查状态寄存器,帮你把一字节塞进数据寄存器,帮你等到发送完成。 “不直接操作寄存器”,不代表寄存器不存在,只是有人替你动了手。 当你要调一个极限性能的中断驱动,或者被一个 bug 卡住时,你还是得掀开墙纸,看背后的寄存器波形。


8. 最后的最后:把整个流程串成一张图

单片机 = 一堆房子(外设、存储器)挂在总线走廊上,每个房子都有门牌号(地址),门牌号后面是一排开关(寄存器)。 CPU 这个快递员,按照 Flash 里的一张张指令单(机器码),到各个房子取货(读寄存器)、送货(写寄存器)。偶尔让 DMA 这小哥代劳搬大件。

你写的每一行 C: P0_1 = 0; → 编译器把它变成 CLR 0x91(位地址),机器码 0xC2 0x91 → 烧进 Flash → PC 指向它 → 译码器认出“清0位” → 硬件把位地址 0x91(P0.1)锁存器拉低 → P0.1 引脚输出低电平 → LED 灭。 全过程无魔法,全是大学里教过的硬核知识。

什么译码器、ALU、PC、栈、哈佛结构、冯诺依曼结构,全在你手里这块几块钱的芯片里原原本本跑着。 你问我怎么学单片机? 无非就是:翻开手册 → 找到对应的寄存器 → 根据协议要求算出要写的值 → 写进去 → 世界就动起来了。 这份“罢了”的背后,正是你从底层到应用层自由穿行的底气。

函数入栈

栈帧概念:

某个寄存器入栈(一般是sfr特殊功能寄存器)

OpenOCD GDB

SOC开发笔记

Embedded linux imx6ull

Driver program

注意:

  • linux模块开发必须依赖源码,并且这个源码的linux版本必须与烧录到板子的源码版本一致,也就说整个uboot->linux内核->rootfs->模块开发。一开始就要确定各自的版本,直接拿芯片原厂提供的进行修改最好。

  • 必须要配置linux内核顶层Makefile的交叉编译器和目标架构

#在大概254行

#ARCH		?= $(SUBARCH)
#CROSS_COMPILE	?= $(CONFIG_CROSS_COMPILE:"%"=%)

ARCH		?= arm
CROSS_COMPILE	?= arm-linux-gnueabihf-

Linux三种驱动

  • 字符设备驱动

  • 块设备驱动

  • 网络设备驱动

字符设备驱动介绍

字符设备就是一个一个字节按照字节流的方式进行读写。

第一个驱动

  • 先编译linux内核。/root/tool/linux_driver,执行正点原子提供的脚本imx6ull_alientek_emmc.sh
  • 在aaa_linux_driver文件夹下新建chrdevbase.c文件
#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>

#define CHRDEVBASE_MAJOR	200				/* 主设备号 */
#define CHRDEVBASE_NAME		"chrdevbase" 	/* 设备名     */

static char readbuf[100];		/* 读缓冲区 */
static char writebuf[100];		/* 写缓冲区 */
static char kerneldata[] = {"kernel data!"};

/*
 * @description		: 打开设备
 * @param - inode 	: 传递给驱动的inode
 * @param - filp 	: 设备文件,file结构体有个叫做private_data的成员变量
 * 					  一般在open的时候将private_data指向设备结构体。
 * @return 			: 0 成功;其他 失败
 */
static int chrdevbase_open(struct inode *inode, struct file *filp)
{
	//printk("chrdevbase open!\r\n");
	return 0;
}

/*
 * @description		: 从设备读取数据 
 * @param - filp 	: 要打开的设备文件(文件描述符)
 * @param - buf 	: 返回给用户空间的数据缓冲区
 * @param - cnt 	: 要读取的数据长度
 * @param - offt 	: 相对于文件首地址的偏移
 * @return 			: 读取的字节数,如果为负值,表示读取失败
 */
static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt)
{
	int retvalue = 0;
	
	/* 向用户空间发送数据 */
	memcpy(readbuf, kerneldata, sizeof(kerneldata));
	retvalue = copy_to_user(buf, readbuf, cnt);
	if(retvalue == 0){
		printk("kernel senddata ok!\r\n");
	}else{
		printk("kernel senddata failed!\r\n");
	}
	
	//printk("chrdevbase read!\r\n");
	return 0;
}

/*
 * @description		: 向设备写数据 
 * @param - filp 	: 设备文件,表示打开的文件描述符
 * @param - buf 	: 要写给设备写入的数据
 * @param - cnt 	: 要写入的数据长度
 * @param - offt 	: 相对于文件首地址的偏移
 * @return 			: 写入的字节数,如果为负值,表示写入失败
 */
static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
	int retvalue = 0;
	/* 接收用户空间传递给内核的数据并且打印出来 */
	retvalue = copy_from_user(writebuf, buf, cnt);
	if(retvalue == 0){
		printk("kernel recevdata:%s\r\n", writebuf);
	}else{
		printk("kernel recevdata failed!\r\n");
	}
	
	//printk("chrdevbase write!\r\n");
	return 0;
}

/*
 * @description		: 关闭/释放设备
 * @param - filp 	: 要关闭的设备文件(文件描述符)
 * @return 			: 0 成功;其他 失败
 */
static int chrdevbase_release(struct inode *inode, struct file *filp)
{
	//printk("chrdevbase release!\r\n");
	return 0;
}

/*
 * 设备操作函数结构体
 */
static struct file_operations chrdevbase_fops = {
	.owner = THIS_MODULE,	
	.open = chrdevbase_open,
	.read = chrdevbase_read,
	.write = chrdevbase_write,
	.release = chrdevbase_release,
};

/*
 * @description	: 驱动入口函数 
 * @param 		: 无
 * @return 		: 0 成功;其他 失败
 */
static int __init chrdevbase_init(void)
{
	int retvalue = 0;

	/* 注册字符设备驱动 */
	retvalue = register_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME, &chrdevbase_fops);
	if(retvalue < 0){
		printk("chrdevbase driver register failed\r\n");
	}
	printk("chrdevbase init!\r\n");
	return 0;
}

/*
 * @description	: 驱动出口函数
 * @param 		: 无
 * @return 		: 无
 */
static void __exit chrdevbase_exit(void)
{
	/* 注销字符设备驱动 */
	unregister_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME);
	printk("chrdevbase exit!\r\n");
}

/* 
 * 将上面两个函数指定为驱动的入口和出口函数 
 */
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);

/* 
 * LICENSE和作者信息
 */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");

  • 编写Makefile,这一步很重要,KERNELDIR内核目录配置很重要
KERNELDIR := /root/tool/linux_driver
CURRENT_PATH := $(shell pwd)
obj-m := chrdevbase.o

build: kernel_modules

kernel_modules:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean
  • 复制到开发板
//ssh连接开发板,在开发板中执行以下命令
sshpass -p 20000316 scp root@192.168.0.254:/root/tool/linux_driver/aaa_driver/chrdevbaseApp /home/root/linux_kernel_driver/chrdevbaseApp
  • 加载.ko文件先更新依赖库
depmod
modprobe chrdevbase.ko
  • 或者不用更新依赖库
insmod chrdevbase.ko
  • 创建设备节点文件

​ 这个文件就是操作这个模块的点,应用程序通过节点文件对设备进行操作。

mknod /dev/chrdevbase c 200 0

其中“mknod”是创建节点命令,“/dev/chrdevbase”是要创建的节点文件,“c”表示这是个 字符设备,“200”是设备的主设备号,“0”是设备的次设备号

  • 对设备进行读写
./chrdevbaseApp /dev/chrdevbase 1
  • 写在
rmmod chrdevbase.ko

绪论

由于linux不能直接使用物理内存,linux有mmu(memory manager unit)。

MMU完成虚拟空间到无论空间的内存映射,有内存保护功能,不能直接透过操作系统进行物理地址映射。

MMU同样映射DDR,IO寄存器:

​ ARM、RISC-V 这类嵌入式 CPU,物理地址空间是统一编址的(统一编址 = 内存映射 IO,MMIO):

  • 一部分物理地址 → 对应 DDR 内存
  • 另一部分物理地址 → 对应 片上外设寄存器(GPIO、UART、I2C、时钟控制器……)

CPU 只看物理地址

  • 物理地址空间 = DDR + 各种外设寄存器

  • MMU 只做虚拟地址 → 物理地址翻译

  • 只要物理地址落在外设区间,CPU 就直接访问硬件寄存器,不经过 DDR

ioremap 和 iounmap

​ ioremap 函数用于获取指 定 物 理 地 址 空 间 对 应 的 虚 拟 地 址 空 间 , 定 义 在 arch/arm/include/asm/io.h 文件中

​ iounmap 函数释放掉 ioremap 函数所做的映射

Linux kernel

下载linux内核

安装 apt-get install lzop

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- imx_v7_defconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- all -j8 //all表示编译所有 
//如果没有zImage使用以下命令编译
make zImage ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- all -j8

linux内核的xxx_deconfig文件在/arch/arm/configs/imx_v7_defconfig

编译DTB设备树文件(前面已经编译了)不需要执行

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- ./arch/arm/boot/dts/imx6ull-14x14-emmc-7-1024x600-c.dtb

Linux启动测试

先把zImage和dtb文件拷贝到tftp文件夹

cp /root/tool/linux_kernel/zdyz/arch/arm/boot/dts/imx6ull-14x14-emmc-7-1024x600-c.dtb /srv/tftp/
cp /root/tool/linux_kernel/zdyz/arch/arm/boot/zImage /srv/tftp/

在uboot命令行下执行指令

tftp 80800000 zImage
tftp 83000000 imx6ull-14x14-emmc-7-1024x600-c.dtb
bootz 80800000 - 83000000

Linux内核来源

  1. www.kernel.org 内核官网,有linux所有版本。
  2. SOC厂商在linux内核官网找一个版本,在这个版本下操作。
  3. 板子厂商会从SOC厂商拿到soc厂商维护的内核在进行修改。

NXP官方linux内核编译

上传到ubuntu
修改顶层Makefile
ARCH		?= $(SUBARCH)
CROSS_COMPILE	?= $(CONFIG_CROSS_COMPILE:"%"=%)
改成
ARCH		?= arm
CROSS_COMPILE	?= arm-linux-gnueabihf-

执行以下指令

make clean
make imx_v7_mfg_defconfig
make -j8

添加自己开发版

  • arch/arm/configs的imx_v7_mfg_defconfig文件进行复制改成aa_wyl_defconfig

  • 将aa_wyl_defconfig文件中"CONFIG_ARCH_MULTI_V6=y"注释掉

  • 执行make aa_wyl_defconfig
    

添加设备树文件

cp arch/arm/boot/dts/imx6ul-14x14-evk.dts arch/arm/boot/dts/imx6ull_wyl_defconfig.dts

修改设备树的Makefile

arch/arm/boot/dts/Makefile

找到CONFIG_SOC_IMX6ULL配置添加自己的dtb

dtb-$(CONFIG_SOC_IMX6ULL) += \
	imx6ull_wyl_defconfig.dtb \

自己板子编译

make distclean
make aa_wyl_defconfig
make menuconfig
make all -j8
或者
make zImage all -j8

问题修改

/usr/bin/ld: scripts/dtc/dtc-parser.tab.o  CC      arch/arm/kernel/asm-offsets.s
:(.bss+0x50): multiple definition of `yylloc'; scripts/dtc/dtc-lexer.lex.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

进入到scripts/dtc/dtc-lexer.l和dtc-lexer.lex.c_shipped

YYLTYPE yylloc;
改成
extern YYLTYPE yylloc;

完成

arch/arm/boot/zImage
arch/arm/boot/dts/imx6ull_wyl_defconfig.dtb

测试

复制zImage和dtb文件到tftp目录

tftp 80800000 zImage
tftp 83000000 imx6ull_wyl_defconfig.dtb
bootz 80800000 - 83000000

CPU主频修改

aa_wyl_defconfig文件中

CONFIG_CPU_FREQ_DEFAULT_GOV_ONDEMAND=y
CONFIG_CPU_FREQ_GOV_POWERSAVE=y
CONFIG_CPU_FREQ_GOV_USERSPACE=y
CONFIG_CPU_FREQ_GOV_INTERACTIVE=y

第 1 行,配置 ondemand 为默认调频策略。

第 2 行,使能 powersave 策略。

第 3 行,使能 userspace 策略。

第 4 行,使能 interactive 策略。

第 1 行注释掉,然后在 4 行后面添加: CONFIG_CPU_FREQ_GOV_ONDEMAND=y

EMMC改成8数据线

网络驱动修改

在linux中,所有的驱动修改都涉及到设备树,一般改动只会改动设备树文件内容。

源码

pinctrl_spi4: spi4grp {
			fsl,pins = <
				MX6UL_PAD_BOOT_MODE0__GPIO5_IO10	0x70a1
				MX6UL_PAD_BOOT_MODE1__GPIO5_IO11	0x70a1
				MX6UL_PAD_SNVS_TAMPER7__GPIO5_IO07	0x70a1
				MX6UL_PAD_SNVS_TAMPER8__GPIO5_IO08	0x80000000
			>;
		};

改成

pinctrl_spi4: spi4grp {
			fsl,pins = <
				MX6UL_PAD_BOOT_MODE0__GPIO5_IO10	0x70a1
				MX6UL_PAD_BOOT_MODE1__GPIO5_IO11	0x70a1  
			>;
		};

源码

spi4 {
		compatible = "spi-gpio";
		pinctrl-names = "default";
		pinctrl-0 = <&pinctrl_spi4>;
		pinctrl-assert-gpios = <&gpio5 8 GPIO_ACTIVE_LOW>;
		status = "okay";
		gpio-sck = <&gpio5 11 0>;
		gpio-mosi = <&gpio5 10 0>;
		cs-gpios = <&gpio5 7 0>;
		num-chipselects = <1>;
		#address-cells = <1>;
		#size-cells = <0>;

		gpio_spi: gpio_spi@0 {
			compatible = "fairchild,74hc595";
			gpio-controller;
			#gpio-cells = <2>;
			reg = <0>;
			registers-number = <1>;
			registers-default = /bits/ 8 <0x57>;
			spi-max-frequency = <100000>;
		};
	};

改成

spi4 {
		compatible = "spi-gpio";
		pinctrl-names = "default";
		pinctrl-0 = <&pinctrl_spi4>;
		status = "okay";
		gpio-sck = <&gpio5 11 0>;
		gpio-mosi = <&gpio5 10 0>;
		num-chipselects = <1>;
		#address-cells = <1>;
		#size-cells = <0>;

		gpio_spi: gpio_spi@0 {
			compatible = "fairchild,74hc595";
			gpio-controller;
			#gpio-cells = <2>;
			reg = <0>;
			registers-number = <1>;
			registers-default = /bits/ 8 <0x57>;
			spi-max-frequency = <100000>;
		};
	};

源码

&iomuxc {
	pinctrl-names = "default";
	pinctrl-0 = <&pinctrl_hog_1>;
	imx6ul-evk {
		/*省略其他*/


		pinctrl_enet1: enet1grp {
			fsl,pins = <
				MX6UL_PAD_ENET1_RX_EN__ENET1_RX_EN	0x1b0b0
				MX6UL_PAD_ENET1_RX_ER__ENET1_RX_ER	0x1b0b0
				MX6UL_PAD_ENET1_RX_DATA0__ENET1_RDATA00	0x1b0b0
				MX6UL_PAD_ENET1_RX_DATA1__ENET1_RDATA01	0x1b0b0
				MX6UL_PAD_ENET1_TX_EN__ENET1_TX_EN	0x1b0b0
				MX6UL_PAD_ENET1_TX_DATA0__ENET1_TDATA00	0x1b0b0
				MX6UL_PAD_ENET1_TX_DATA1__ENET1_TDATA01	0x1b0b0
				MX6UL_PAD_ENET1_TX_CLK__ENET1_REF_CLK1	0x4001b031
			>;
		};

		pinctrl_enet2: enet2grp {
			fsl,pins = <
				MX6UL_PAD_GPIO1_IO07__ENET2_MDC		0x1b0b0
				MX6UL_PAD_GPIO1_IO06__ENET2_MDIO	0x1b0b0
				MX6UL_PAD_ENET2_RX_EN__ENET2_RX_EN	0x1b0b0
				MX6UL_PAD_ENET2_RX_ER__ENET2_RX_ER	0x1b0b0
				MX6UL_PAD_ENET2_RX_DATA0__ENET2_RDATA00	0x1b0b0
				MX6UL_PAD_ENET2_RX_DATA1__ENET2_RDATA01	0x1b0b0
				MX6UL_PAD_ENET2_TX_EN__ENET2_TX_EN	0x1b0b0
				MX6UL_PAD_ENET2_TX_DATA0__ENET2_TDATA00	0x1b0b0
				MX6UL_PAD_ENET2_TX_DATA1__ENET2_TDATA01	0x1b0b0
				MX6UL_PAD_ENET2_TX_CLK__ENET2_REF_CLK2	0x4001b031
			>;
		};
}

NXP BSP项目

Rootfs

将正点原子出厂的uboot和linux内核进行编译

注意linux内核编译时出现yylloc错误要修改

进入到scripts/dtc/dtc-lexer.l和dtc-lexer.lex.c_shipped

YYLTYPE yylloc;
改成
extern YYLTYPE yylloc;
  • uboot烧录进板子。

  • zImage放到tftp目录

  • imx6ull-alientek-emmc.dtb放到tftp目录

测试

//zImage改了名,网线插到2口(FEC1)

tftp 80800000 zImage-alientek
tftp 83000000 imx6ull-alientek-emmc.dtb
bootz 80800000 - 83000000

修改Makefile,添加交叉编译器

CROSS_COMPILE必须使用绝对路径

ARCH ?= arm
CROSS_COMPILE ?= /root/tool/arm-linux-gnueabihf/bin/arm-linux-gnueabihf-

添加中文支持

libbb/printable_string.c文件

//printable_string 方法

//注释掉
if (c >= 0x7f)
	break;
	
//即
if (c < ' ')
	break;
/* if (c >= 0x7f)
	break; */
//ENABLE_UNICODE_SUPPORT 判断项

//注释掉
if (c < ' ' || c >= 0x7f)

//添加
if( c < ' ')
//即
while (1) {
	unsigned char c = *d;
	if (c == '\0')
		break;
	if (c < ' ' || c >= 0x7f)
		*d = '?';
	d++;
}

libbb/unicode.c文件

*d++ = (c >= ' ' && c < 0x7f) ? c : '?';
//改成
*d++ = (c >= ' ') ? c : '?';
//即
while ((int)--width >= 0) {
	unsigned char c = *src;
	if (c == '\0') {
		do
			*d++ = ' ';
		while ((int)--width >= 0);
		break;
	}
	// *d++ = (c >= ' ' && c < 0x7f) ? c : '?';
	*d++ = (c >= ' ') ? c : '?';
	src++;
}
/* if (c < ' ' || c >= 0x7f) */
//改成
if(c < ' ')
//即
while (*d) {
	unsigned char c = *d;
	// if (c < ' ' || c >= 0x7f)
	if(c < ' ')
		*d = '?';
	d++;
}

配置busybox

①、defconfig,缺省配置,也就是默认配置选项。

②、allyesconfig,全选配置,也就是选中 busybox 的所有功能

③、allnoconfig,最小配置。

默认使用defconfig

make defconfig
make menuconfig

在图形配置界面做以下配置

静态编译 busybox 还是动态编译

选中为静态编译,我们不选中

Location: 
 -> Settings 
	-> Build static binary (no shared libs)

选中

Location: 
  -> Settings 
	-> vi-style line editing commands

使能 busybox 的 unicode 编码以支持中文

Location: 
 -> Settings
   -> Support Unicode //选中
	 -> Check $LC_ALL, $LC_CTYPE and $LANG environment variables //选中

不选中

Location: 
  -> Linux Module Utilities
	-> Simplified modutils

选中

Location: 
  -> Linux System Utilities 
 	-> mdev (16 kb) //确保下面的全部选中,默认都是选中

编译报错问题

系统时间问题解决在.config文件中注释掉这两个

#CONFIG_RDATE=y
#CONFIG_DATE=y

编译

make
//清除配置 make distclean

make后---命令行会问要不要打开CONFIG_DATE,输入n后回车,再次询问要不要打开CONFIG_RDATE,输入n后回车

make install CONFIG_PREFIX=/root/tool/rootfs/maked-zdyz

编译完成,在/root/tool/rootfs/maked-zdyz有相应文件

添lib加库文件

  • 移动/root/tool/rootfs/maked-zdyz所有文件到/srv/nfs文件夹下
cp -rf /root/tool/rootfs/maked-zdyz/* /srv/nfs
  • 在/root/tool/rootfs/maked-zdyz新建lib文件夹

  • 复制交叉编译器里面的库文件

cd /root/tool/arm-linux-gnueabihf/arm-linux-gnueabihf/libc/lib
cp *so* *.a /srv/nfs/lib/ -d   “-d”表示拷贝符号链接

rm /srv/nfs/lib/ld-linux-armhf.so.3  软链接删除
cp /root/tool/arm-linux-gnueabihf/arm-linux-gnueabihf/libc/lib/ld-linux-armhf.so.3 /srv/nfs/lib/ 

cd /root/tool/arm-linux-gnueabihf/arm-linux-gnueabihf/lib
cp *so* *.a /srv/nfs/lib/ -d

mkdir /srv/nfs/usr/lib
cd /root/tool/arm-linux-gnueabihf/arm-linux-gnueabihf/libc/usr/lib
cp *so* *.a /srv/nfs/usr/lib -d
  • 查看文件系统大小
cd /srv/nfs
du ./lib ./usr/lib/ -sh

//
root@wyl:/srv/nfs# du ./lib ./usr/lib/ -sh
57M     ./lib
67M     ./usr/lib/
  • 创建其他文件夹
mkdir dev proc mnt sys tmp root

挂载测试

nfs配置命令

root=/dev/nfs nfsroot=[<server-ip>:]<root-dir>[,<nfs-options>] ip=<client-ip>:<server-ip>:<gw-ip>:<netmask>:<hostname>:<device>:<autoconf>:<dns0-ip>:<dns1-ip

服务器 IP 地址,也就是存放根文件系统主机的 IP 地址,那就是 Ubuntu 的 IP 地址。

根文件系统的存放路径,/srv/nfs

NFS 的其他可选选项,一般不设置

客户端 IP 地址,也就是我们开发板的 IP 地址,Linux 内核启动以后就会使用 此 IP 地址来配置开发板。

服务器 IP 地址

网关地址192.168.0.1

子网掩码,255.255.255.0。

客户机的名字,一般不设置,此值可以空着。

设备名,也就是网卡名,一般是 eth0,eth1

自动配置,一般不使用,所以设置为 off。

DNS0 服务器 IP 地址,不使用

DNS1 服务器 IP 地址,不使用

在uboot命令行输入

setenv bootargs 'console=ttymxc0,115200 root=/dev/nfs nfsroot=192.168.0.254:/srv/nfs,proto=tcp rw ip=192.168.0.155:192.168.0.254:192.168.0.1:255.255.255.0::eth1:off'//设置 bootargs
saveenv //保存环境变量

或者

setenv bootargs 'console=ttymxc0,115200 root=/dev/nfs nfsroot=192.168.0.254:/srv/nfs,v3,tcp ip=dhcp'

启动linux

tftp 80800000 zImage-alientek
tftp 83000000 imx6ull-alientek-emmc.dtb
bootz 80800000 - 83000000

Uboot

uboot开源项目,一个bootloader。高度抽象的一个bootloader,对比单片机而言,通常要自己手写bootloader。

芯片厂商会从uboot官网下载某一个版本的uboot,然后加入自己芯片的驱动。

开发版的厂商会从芯片厂商下载uboot,再度进行修改。

编译正点原子提供的版级uboot

  • 找到uboot源码
  • 传到vm虚拟机的目录下,解压。
  • 确定DDR3和EMMC大小
  • 编译过程(注意必须要有gcc环境)
sudo apt install build-essential
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- mx6ull_14x14_ddr512_emmc_defconfig
make V=1 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j8

可以在顶层makefile中配置交叉编译器和编译的架构

ARCH=arm
CROSS_COMPILE=arm-linux-gnueabihf-

uboot编译注意点

首次编译生成的u-boot.bin和u-boot.imx,一个u-boot.bin是uboot的代码,直接烧录是用不了的。对于imx6ull来说,BootROM启动必须读取flash头部第一个扇区4KB里面的IVT,DCD,DATA等内容然后初始化ddr。随后才会跳转到uboot,u-boot.imx是u-boot.bin已经添加了头部信息的可直接运行文件。

首先,defconfig配置文件,必须要和板子上的ddr,flash对应。比如emmc_defconfig说明要用emmc,nand_defconfig说明要用到nand。

help命令或者?

**bootz命令

bootz 的内部工作流程大致分为以下几个阶段:

1. 内核映像格式检查

U-Boot 会检查指定地址处是否为一个有效的 zImage。zImage 具有特殊的头部结构(通常包含自解压代码),其前 40 字节中有一个魔数(例如 0x016f2818)用于标识 ARM Linux 内核。若格式错误,命令会报错退出。

2. 处理 initrd(可选)

如果提供了 initrd 地址和大小,U-Boot 会将 initrd 的信息记录到内存中的启动参数区域(或通过设备树传递给内核)。内核启动后会挂载 initrd 作为初始根文件系统。

3. 处理设备树(FDT)

U-Boot 会对提供的 DTB 进行必要的处理:

  • 如果 DTB 地址不在内存中(例如是 flash 地址),可能需要先复制到 RAM。
  • 修正 DTB:U-Boot 会将内存节点(/memory)、启动参数(chosen/bootargs)等信息写入 DTB。这依赖于 U-Boot 的配置(CONFIG_OF_LIBFDT)。
  • 验证 DTB 格式(魔数、版本等)。

如果未指定 FDT 地址,U-Boot 会依次查找:

  • 环境变量 fdtaddr
  • 环境变量 fdtcontroladdr(如果存在) 如果都找不到,则不会传递设备树,内核将回退到使用 ATAGS 启动(老式方式)。

4. 准备启动参数

U-Boot 会根据内核启动约定,将必要的信息传递给内核。在 ARM 体系结构中,启动时寄存器状态约定为:

  • r0 = 0
  • r1 = 机器类型编号(如果使用 ATAGS,不使用设备树时有效)
  • r2 = ATAGS 或 DTB 的物理地址

当使用设备树时,U-Boot 会将设备树地址放入 r2,并将 r1 通常设置为 0(表示使用设备树)。具体的寄存器使用可参考 Linux 内核文档(Documentation/arm/Booting)。

5 .跳转到内核

U-Boot 找到 zImage 的入口点(通常就是加载地址),清理缓存并执行跳转指令,将控制权交给内核的自解压代码。后续由 zImage 完成自解压和最终内核的引导。

分析编译后的uboot

api文件夹

和硬件没有关系的API函数

arch

u-boot.lds链接脚本

NXP

下载NXP提供的uboot
选择xxx_defconfig文件
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- mx6ul_14x14_evk_emmc_defconfig
编译
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- V=1 -j8
清除
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean

移植

1.1添加板子默认配置文件

​ 复制nxp官方mx6ul_14x14_evk_emmc_defconfig重命名为自己的xxx_deconfig

​ mx6ul_14x14_evk_emmc_defconfig

CONFIG_ARM=y
CONFIG_ARCH_MX6=y
CONFIG_TARGET_MX6UL_14X14_EVK=y
CONFIG_SYS_EXTRA_OPTIONS="IMX_CONFIG=board/freescale/mx6ul_14x14_evk/imximage.cfg,MX6UL_EVK_EMMC_REWORK"
CONFIG_CMD_GPIO=y
CONFIG_CMD_DHCP=y
CONFIG_CMD_PING=y

​ 在uboot根目录下的/board/freescale/mx6ullevk/imximage.cfg文件下配置了裸机的头文件

​ xxx_defconfig

CONFIG_ARM=y
CONFIG_ARCH_MX6=y
CONFIG_TARGET_MX6ULL_WYL=y //改动
CONFIG_SYS_EXTRA_OPTIONS="IMX_CONFIG=board/freescale/wyl_mx6ullevk/imximage.cfg,MX6UL_EVK_EMMC_REWORK"//改动
CONFIG_CMD_GPIO=y
CONFIG_CMD_DHCP=y
CONFIG_CMD_PING=y
1.2添加对应头文件
  • ​ 在uboot根目录下/include/configs/mx6ullevk.h定义了配置信息

  • ​ 复制mx6ullevk.h文件修改名为wyl_mx6ullevk.h

  • ​ 修改wyl_mx6ullevk.h里面内容

//头文件修改
#ifndef __WYL_MX6ULLEVK_CONFIG_H
#define __WYL_MX6ULLEVK_CONFIG_H
1.3添加对应的板级文件夹
  • ​ 每个板子都有对应的文件,就是板级文件,现在将6ull evk的板级文件夹board/freescale/mx6ullevk复制命名为wyl_mx6ullevk

  • ​ 重命名wyl_mx6ullevk文件夹中mx6ullevk.c文件为 wyl_mx6ullevk.c

  • ​ 修改wyl_mx6ullevk文件夹下的Makefile

obj-y  := mx6ullevk.o
//修改为
obj-y  := wyl_mx6ullevk.o
  • ​ 修改wyl_mx6ullevk文件夹下的imximage.cfg
PLUGIN	board/freescale/mx6ullevk/plugin.bin 0x00907000
//修改为
PLUGIN	board/freescale/wyl_mx6ullevk/plugin.bin 0x00907000
  • ​ 修改wyl_mx6ullevk文件夹下的Kconfig
if TARGET_MX6ULL_14X14_EVK || TARGET_MX6ULL_9X9_EVK
//修改为
if TARGET_MX6UL_WYL


config SYS_BOARD
	default "mx6ullevk"
//修改为
config SYS_BOARD
	default "wyl_mx6ullevk"
	
	
config SYS_CONFIG_NAME
	default "mx6ullevk"
//修改为
config SYS_CONFIG_NAME
	default "wyl_mx6ullevk"
  • 修改wyl_mx6ullevk文件夹下MAINTAINERS文件
MX6ULLEVK BOARD
M:	Peng Fan <peng.fan@nxp.com>
S:	Maintained
F:	board/freescale/wyl_mx6ullevk/
F:	include/configs/wyl_mx6ullevk.h
F:	configs/aa_wyl_mx6ull_emmc_defconfig

1.4修改uboot配置界面
  • ​ 去到arch/arm/cpu/mx6/Kconfig文件,在207行,加入以下
config TARGET_MX6ULL_WYL
	bool "Support wyl_mx6ullevk"
	select MX6ULL
	select DM
	select DM_THERMAL
加入下面这行
source "board/freescale/wyl_mx6ullevk/Kconfig"

确定IO初始化

board/freescale/wyl_mx6ullevk/wyl_mx6ullevk.c中的定义lcd_pads[]中

注释复位

//MX6_PAD_SNVS_TAMPER9__GPIO5_IO09 | MUX_PAD_CTRL(NO_PAD_CTRL)

/*
	gpio_direction_output(IMX_GPIO_NR(5, 9) , 0);
	udelay(500);
	gpio_direction_output(IMX_GPIO_NR(5, 9) , 1);
*/

LCD参数

在board/freescale/wyl_mx6ullevk/wyl_mx6ullevk.c中的displays[]

主要是理解arch/arm/include/asm/imx-common/video.h中的display_info_t结构体,以及这个结构体下的include/linux/fb.h中fb_videomode结构体(RGB LCD参数)

struct fb_videomode {
	const char *name;	/* optional */
	u32 refresh;		/* optional */
	u32 xres;
	u32 yres;
	u32 pixclock;
	u32 left_margin;
	u32 right_margin;
	u32 upper_margin;
	u32 lower_margin;
	u32 hsync_len;
	u32 vsync_len;
	u32 sync;
	u32 vmode;
	u32 flag;
};

修改LCD参数

struct display_info_t const displays[] = {{
	.bus = MX6UL_LCDIF1_BASE_ADDR,
	.addr = 0,
	.pixfmt = 24,
	.detect = NULL,
	.enable	= do_enable_parallel_lcd,
	.mode	= {
		.name			= "TFT7016",
		.xres           = 1024,
		.yres           = 600,
		.pixclock       = 19531,
		.left_margin    = 140,
		.right_margin   = 160,
		.upper_margin   = 20,
		.lower_margin   = 12,
		.hsync_len      = 20,
		.vsync_len      = 3,
		.sync           = 0,
		.vmode          = FB_VMODE_NONINTERLACED
} } };

修改头文件

修改include/configs/wyl_mx6ullevk.h中的panel(有两处)

panel=TFT7016

如果有问题黑屏

这是因为之前有将环境变量保存到 EMMC 中,uboot 启动以后会先从 EMMC 中读取环境 变量,如果 EMMC 中没有环境变量的话才会使用 mx6ull_alientek_emmc.h 中的默认环境变量。 如果 EMMC 中的环境变量 panel 不等于 TFT7016,那么 LCD 显示肯定不正常,我们只需要在 uboot 中修改 panel 的值为 TFT7016 即可。

setenv panel TFT7016
saveenv
reset

驱动修改

修改include/configs/wyl_mx6ullevk.h文件

①、修改 ENET1 网络 PHY 的地址。

②、修改 ENET2 网络 PHY 的地址。

③、使能 REALTEK 公司的 PHY 驱动

在第325行,以下是源文件

#ifdef CONFIG_CMD_NET
#define CONFIG_CMD_PING
#define CONFIG_CMD_DHCP
#define CONFIG_CMD_MII
#define CONFIG_FEC_MXC
#define CONFIG_MII
#define CONFIG_FEC_ENET_DEV		1

#if (CONFIG_FEC_ENET_DEV == 0)
#define IMX_FEC_BASE			ENET_BASE_ADDR
#define CONFIG_FEC_MXC_PHYADDR          0x2
#define CONFIG_FEC_XCV_TYPE             RMII
#elif (CONFIG_FEC_ENET_DEV == 1)
#define IMX_FEC_BASE			ENET2_BASE_ADDR
#define CONFIG_FEC_MXC_PHYADDR		0x1
#define CONFIG_FEC_XCV_TYPE		RMII
#endif
#define CONFIG_ETHPRIME			"FEC"

#define CONFIG_PHYLIB
#define CONFIG_PHY_MICREL
#endif
修改后
#ifdef CONFIG_CMD_NET
#define CONFIG_CMD_PING
#define CONFIG_CMD_DHCP
#define CONFIG_CMD_MII
#define CONFIG_FEC_MXC
#define CONFIG_MII
#define CONFIG_FEC_ENET_DEV		1

#if (CONFIG_FEC_ENET_DEV == 0)
#define IMX_FEC_BASE			ENET_BASE_ADDR
#define CONFIG_FEC_MXC_PHYADDR          0x2
#define CONFIG_FEC_XCV_TYPE             RMII
#elif (CONFIG_FEC_ENET_DEV == 1)
#define IMX_FEC_BASE			ENET2_BASE_ADDR
#define CONFIG_FEC_MXC_PHYADDR		0x1
#define CONFIG_FEC_XCV_TYPE		RMII
#endif
#define CONFIG_ETHPRIME			"FEC"

#define CONFIG_PHYLIB
#define CONFIG_PHY_REALTEK //改动
#endif

删除uboot中74LV595的驱动代码

board/freescale/wyl_mx6ullevk/wyl_mx6ullevk.c

①源代码如下

#define IOX_SDI IMX_GPIO_NR(5, 10)
#define IOX_STCP IMX_GPIO_NR(5, 7)
#define IOX_SHCP IMX_GPIO_NR(5, 11)
#define IOX_OE IMX_GPIO_NR(5, 8)

②修改成下面

// #define IOX_SDI IMX_GPIO_NR(5, 10)
// #define IOX_STCP IMX_GPIO_NR(5, 7)
// #define IOX_SHCP IMX_GPIO_NR(5, 11)
// #define IOX_OE IMX_GPIO_NR(5, 8)
#define ENET1_RESET IMX_GPIO_NR(5, 7)
#define ENET2_RESET IMX_GPIO_NR(5, 8)

③接着找到iox_pads[]数组,注释或者删除

源码如下

static iomux_v3_cfg_t const iox_pads[] = {
	/* IOX_SDI */
	MX6_PAD_BOOT_MODE0__GPIO5_IO10 | MUX_PAD_CTRL(NO_PAD_CTRL),
	/* IOX_SHCP */
	MX6_PAD_BOOT_MODE1__GPIO5_IO11 | MUX_PAD_CTRL(NO_PAD_CTRL),
	/* IOX_STCP */
	MX6_PAD_SNVS_TAMPER7__GPIO5_IO07 | MUX_PAD_CTRL(NO_PAD_CTRL),
	/* IOX_nOE */
	MX6_PAD_SNVS_TAMPER8__GPIO5_IO08 | MUX_PAD_CTRL(NO_PAD_CTRL),
};

④找到iox74lv_init和iox74lv_set函数,注释或者删除

⑤找到 board_init 函数,删除或注释,mx_iomux_v3_setup_multiple_pads和iox74lv_init函数调用

int board_init(void)
{
	/* Address of boot parameters */
	gd->bd->bi_boot_params = PHYS_SDRAM + 0x100;

	// imx_iomux_v3_setup_multiple_pads(iox_pads, ARRAY_SIZE(iox_pads));

	// iox74lv_init();  //注释
	...
}

⑥找到fec1_pads[]和fec2_pads[]数组,修改io配置

static iomux_v3_cfg_t const fec1_pads[]{
	...
    //最后一行添加
    MX6_PAD_SNVS_TAMPER7__GPIO5_IO07 | MUX_PAD_CTRL(NO_PAD_CTRL),
}
static iomux_v3_cfg_t const fec2_pads[] = {
	...
    //最后一行添加
    MX6_PAD_SNVS_TAMPER8__GPIO5_IO08 | MUX_PAD_CTRL(NO_PAD_CTRL),
}

⑦找到setup_iomux_fec函数

源码

static void setup_iomux_fec(int fec_id)
{
	if (fec_id == 0)
		imx_iomux_v3_setup_multiple_pads(fec1_pads,
						 ARRAY_SIZE(fec1_pads));
	else
		imx_iomux_v3_setup_multiple_pads(fec2_pads,
						 ARRAY_SIZE(fec2_pads));
}

修改为

static void setup_iomux_fec(int fec_id)
{
	if (fec_id == 0){
		imx_iomux_v3_setup_multiple_pads(fec1_pads,
						 ARRAY_SIZE(fec1_pads));
		gpio_direction_output(ENET1_RESET, 1);
		gpio_set_value(ENET1_RESET, 0);
		mdelay(20);
		gpio_set_value(ENET1_RESET, 1);
	}	
	else{
		imx_iomux_v3_setup_multiple_pads(fec2_pads,
						 ARRAY_SIZE(fec2_pads));
		gpio_direction_output(ENET2_RESET, 1);
		gpio_set_value(ENET2_RESET, 0);
		mdelay(20);
		gpio_set_value(ENET2_RESET, 1);
	}
	mdelay(150); /* 复位结束后至少延时 150ms 才能正常使用*/
}

烧录测试

串口打印->Net: FEC1,成功配置,为网口2

在uboot命令行界面配置

setenv ipaddr 192.168.0.55 //开发板 IP 地址
setenv ethaddr b8:ae:1d:01:00:00 //开发板网卡 MAC 地址
setenv gatewayip 192.168.0.1 //开发板默认网关
setenv netmask 255.255.255.0 //开发板子网掩码
setenv serverip 192.168.0.254 //服务器地址,也就是 Ubuntu 地址
saveenv //保存环境变量
ping 192.168.0.254

ping返回:

Using FEC1 device
host 192.168.0.254 is alive

测试网口2

修改include/configs/wyl_mx6ullevk.h文件

#define CONFIG_FEC_ENET_DEV		1
//修改为// 0 1 对应网口1和2
#define CONFIG_FEC_ENET_DEV		0

重新编译烧录

网线连接到enet1口

串口打印->Net: FEC0,成功配置,为网口1

直接ping 192.168.0.254能ping通

print查看ip信息。

其他需要修改的地方

board/freescale/wyl_mx6ullevk/wyl_mx6ullevk.c中checkboard函数

int checkboard(void)
{
	if (is_mx6ull_9x9_evk())
		puts("Board: MX6ULL 9x9 EVK\n");
	else
		puts("Board: MX6ULL 14x14 EVK\n");

	return 0;
}

修改为

int checkboard(void)
{
	if (is_mx6ull_9x9_evk())
		puts("Board: wyl_MX6ULL 9x9 EVK\n");
	else
		puts("Board: wyl_MX6ULL_EVK\n");

	return 0;
}

确保EMMC里面有完整的正点原子系统

由于一直使用的是SD卡测试,EMMC中的系统一直都是没有动过的

  1. 查看emmc中是否有linux镜像和.dtb文件

    =>mmc dev 1 //切换到EMMC
    
    switch to partitions #0, OK
    mmc1(part 0) is current device
    
    =>mmcinfo //mmc基本信息
    Device: FSL_SDHC
    Manufacturer ID: 15
    OEM: 100
    Name: 8GTF4
    Tran Speed: 52000000
    Rd Block Len: 512
    MMC version 4.0
    High Capacity: Yes
    Capacity: 7.3 GiB
    Bus Width: 4-bit
    Erase Group Size: 512 KiB
    
    =>fatls mmc 1:1 //查看EMMC分区1文件
      6785480   zimage
        39459   imx6ull-14x14-emmc-4.3-480x272-c.dtb
        39459   imx6ull-14x14-emmc-4.3-800x480-c.dtb
        39459   imx6ull-14x14-emmc-7-800x480-c.dtb
        39459   imx6ull-14x14-emmc-7-1024x600-c.dtb
        39459   imx6ull-14x14-emmc-10.1-1280x800-c.dtb
        40295   imx6ull-14x14-emmc-hdmi.dtb
        40203   imx6ull-14x14-emmc-vga.dtb
    
    8 file(s), 0 dir(s)
    
    =>fatload mmc 1:1 80800000 zimage //加载分区1中的zimage到ddr地址80800000中
    reading zimage
    6785480 bytes read in 362 ms (17.9 MiB/s)
    
    =>fatload mmc 1:1 83000000 imx6ull-14x14-emmc-7-1024x600-c.dtb //加载分区1中的dtb到ddr地址83000000中
    reading imx6ull-14x14-emmc-7-1024x600-c.dtb
    39459 bytes read in 24 ms (1.6 MiB/s)
    
    =>bootz 80800000 - 83000000 //加载linux
    系统启动成功
    

网络启动Linux

  • uboot下安装ftp服务器

    apt install vsftpd -y
    
  • 设置文件夹权限

    chmod -R 775 /root/tool/uboot/ftpboot
    
  • 修改ftp配置

    //备份
    cp /etc/vsftpd.conf /etc/vsftpd.conf.backup
    
    
    //添加
    local_root=/root/tool/uboot/ftpboot
    
    systemctl stop vsftpd
    systemctl start vsftpd
    systemctl stop ufw.service //关闭防火墙
    
  • 将正点原子提供的zImage和dtb文件上传到ubuntu中的某一个位置/root/tool/uboot/ftpboot

  • 在uboot命令行界面执行命令

    tftp 80800000 zimage
    tftp 83000000 imx6ull-14x14-emmc-7-1024x600-c.dtb
    

bootcmd

include/env_default.h

bootcmd 保存着 uboot 默认命令,uboot 倒计时结束以 后就会执行 bootcmd 中的命令

bootargs

bootargs 保存着 uboot 传递给 Linux 内核的参数

uboot 启动 Linux 测试

EMMC 启动 Linux 系
setenv bootargs 'console=ttymxc0,115200 root=/dev/mmcblk1p2 rootwait rw'
setenv bootcmd 'mmc dev 1; fatload mmc 1:1 80800000 zImage; fatload mmc 1:1 83000000 
imx6ull-alientek-emmc.dtb; bootz 80800000 - 83000000;'
saveenv
网络启动 Linux 系统
setenv bootargs 'console=ttymxc0,115200 root=/dev/mmcblk1p2 rootwait rw'
setenv bootcmd 'tftp 80800000 zImage; tftp 83000000 imx6ull-alientek-emmc.dtb; bootz 
80800000 - 83000000'
saveenv

裸机

imxdownload软件下载的bin文件,会在bin文件头部添加IVT,DCD数据。

uboot

uboot编译会生成u-boot.imx,也就是添加了头部信息的bin文件。

编译时打印信息在根目录下/tool/mkimage文件

./tools/mkimage -n board/freescale/wyl_mx6ullevk/imximage.cfg.cfgtmp -T imximage -e 0x87800000 -d u-boot.bin u-boot.imx 

可以看出添加的头部信息在imximage.cfg.cfgtmp文件下,实际上是没有imximage.cfg.cfgtmp的只有imximage.cfg文件。编译过程中会生成这个文件。这里面保存了DCD数据,DCD包含了DDR初始化。

校验

使用nxp官方提供的ddr_stress_tester工具挨个校验。主要是校准值。不校验超频会死机

地址:0x021B083C 值改成:0x01380138
地址:0x021B0848 值改成:0x40402E32
地址:0x021B0850 值改成:0x40403432

修改以下board/freescale/wyl_mx6ullevk/imximage.cfg文件

DATA 4 0x021B083C 0x41640158
DATA 4 0x021B0848 0x40403237
DATA 4 0x021B0850 0x40403C33
修改为:
DATA 4 0x021B083C 0x01380138
DATA 4 0x021B0848 0x40402E32
DATA 4 0x021B0850 0x40403432

①uboot 或 Linux 内核可以通过输入“make menuconfig”来打开图形化配置界面,menuconfig 是一套图形化的配置工具,需要 ncurses 库支持。ncurses 库提供了一系列的 API 函数供调用者 生成基于文本的图形界面,因此需要先在 Ubuntu 中安装 ncurses 库,命令如下:

sudo apt-get install build-essential
sudo apt-get install libncurses5-dev

②必须在uboot顶层Makefile中执行以下编译命令

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- mx6ull_wyl_emmc_defconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig

Kconfig文件

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- mx6ull_wyl_emmc_defconfig之后。

.config文件中一堆内容,这些默认的内容是从顶层 Kconfig:根目录下的 Kconfig 是整个配置系统的入口,它会通过 source 语句包含其他子目录的 Kconfig。分散在整个 U-Boot 源码树中的 Kconfig 文件

图形化界面配置DNS

在command line interface --> Network commands --> dns

=> setenv dnsip 114.114.114.114
=> saveenv
=> dns www.baidu.com
111.45.11.5

原理

安装

sudo apt update
sudo apt install vsftpd -y
sudo systemctl start vsftpd
sudo systemctl enable vsftpd   # 设置开机自启

修改配置文件

在/etc/vsftpd.conf目录下

注意ftp文件目录是/srv/ftp

# 基本设置
listen=YES
listen_ipv6=NO
anonymous_enable=YES
local_enable=NO
write_enable=YES
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
connect_from_port_20=YES

# 匿名用户权限
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES

# 匿名根目录(请确保目录存在)
anon_root=/srv/ftp

# 被动模式端口范围(需防火墙放行)
pasv_enable=YES
pasv_min_port=30000
pasv_max_port=31000

# 安全限制(可选,先注释掉以减少干扰)
# chroot_local_user=YES
# allow_writeable_chroot=YES

windows访问测试

  • 文件资源管理器

    ftp://192.168.0.254
    

以IMX6ULL为例子

裸机启动

在编写led裸机程序链接生成bin文件。还要加上3k的头文件SPL程序。

内部BootROM启动一级程序,判断一个寄存器里面的值(外部flash用哪个)。将SPL程序读到内部ram中的0x09000000,执行SPL完成ddr初始化。再引导led程序到ddr中所链接的地址,从ddr链接地址位置开始执行。完成点灯。

uboot

uboot编译出来时就有uboot.bin和uboot.imx,其中uboot.imx有3k的SPL程序。

内部BootROM启动一级程序,判断一个寄存器里面的值(外部flash用哪个)。将SPL程序读到内部ram中的0x09000000,执行SPL完成ddr初始化。再将uboot加载到ddr中所链接的地址,跳转到ddr链接地址位置开始执行。

uboot启动linux

uboot启动完成之后,会自己执行一些命令将linux镜像解压等工作,随后放入ddr。跳转到ddr中linux的位置,启动linux。

安装

apt update
apt upgrade
apt install nfs-kernel-server

修改文件

在/etc/exports添加

/srv/nfs *(rw,sync,no_subtree_check,no_root_squash)

重启

systemctl status nfs-kernel-server.service
systemctl restart nfs-kernel-server.service

安装

apt install tftpd-hpa -y

修改配置文件

在/etc/default/tftpd-hpa

默认就好,不需要改

# /etc/default/tftpd-hpa
TFTP_USERNAME="tftp"
TFTP_DIRECTORY="/srv/tftp"
TFTP_ADDRESS=":69"
TFTP_OPTIONS="--secure"

1.去官网下载交叉编译器

Linaro Releases 版本:gcc-arm-none-eabi-10.3-2021.10-x86_64-linux.tar.bz2

2.上传到ubuntu

3.设置环境变量

export PATH=$PATH:/root/tool/arm-linux-gnueabihf

PATH 								环境变量
= 									赋值
$PATH 								当前PATH的值
:/root/tool/arm-linux-gnueabihf 	追加一行

4.查看是否设置成功

arm-linux-gnueabihf-gcc -v

直接使用apt进行安装

apt install arm-linux-gnueabihf-gcc
.global _start @全局标号

_start:
    /*使能所有外设时钟*/
    ldr r0, =0x020c4068 @CCGR0
    ldr r1, =0xffffffff @向ccgr0要写入的数据
    str r1, [r0]

    ldr r0, =0x020c406c @CCGR1
    @ldr r1, =0xffffffff @向ccgr01要写入的数据
    str r1, [r0]

    ldr r0, =0x020c4070 @CCGR2
    str r1, [r0]

    ldr r0, =0x020c4074 @CCGR3
    str r1, [r0]

    ldr r0, =0x020c4078 @CCGR4
    str r1, [r0]
    
    ldr r0, =0x020c407c @CCGR5
    str r1, [r0]

    ldr r0, =0x020c4080 @CCGR6
    str r1, [r0]

    /*配置功能复用*/
    ldr r0, =0x020e0068
    ldr r1, =0x5
    str r1, [r0]

    /*配置电气属性*/
    ldr r0, =0x020e02f4
    ldr r1, =0x10b0 
    str r1, [r0]

    /*设置gpio*/
    ldr r0, =0x0209c004
    ldr r1, =0x8 
    str r1, [r0]

    /*打开led*/
    ldr r0, =0x0209c000
    ldr r1, =0x0
    str r1, [r0]

loop:
    b loop

将汇编编程编译后文件(.o)

arm-linux-gnueabihf-gcc -g -c leds.s -o leds.o

将.o文件链接为elf文件

arm-linux-gnueabihf -Ttext 0X87800000 leds.o -o leds.elf

将elf文件编程bin文件

arm-linux-gnueabihf-objcopy -O binary -S -g leds.elf leds.bin

反汇编

arm-linux-gnueabihf-objdump -D leds.elf > leds.dis

启动方式选择:

​ boot模式配置,通过配置引脚来配置启动方式。

设置启动设备:

​ 设置完启动模式后,还需要配置nor flash还是raw nand ,sd,mmc,spi,eeprom。

烧录的bin文件需要添加头部

​

  1. 要想使用cpu的一些功能。就要使用特权模式SVC模式。
  2. 汇编初始化SP指针。
  3. 设置跳转到main函数。即可使用c语言环境
.global _start
    _start:
        /*设置处理器进入svc模式*/
        mrs r0, cpsr 
        bic r0, #0x1f
        orr r0, r0, #0x13
        msr cpsr, r0

        /*设置sp指针*/
        ldr sp, =0x80200000
        b main /*跳转到c语言main函数*/

说明翻到后面

main.h

#ifndef __MAIN_H
#define __MAIN_H


/* 
 * CCM相关寄存器地址 
 */
#define CCM_CCGR0 			*((volatile unsigned int *)0X020C4068)
#define CCM_CCGR1 			*((volatile unsigned int *)0X020C406C)

#define CCM_CCGR2 			*((volatile unsigned int *)0X020C4070)
#define CCM_CCGR3 			*((volatile unsigned int *)0X020C4074)
#define CCM_CCGR4 			*((volatile unsigned int *)0X020C4078)
#define CCM_CCGR5 			*((volatile unsigned int *)0X020C407C)
#define CCM_CCGR6 			*((volatile unsigned int *)0X020C4080)

/* 
 * IOMUX相关寄存器地址 
 */
#define SW_MUX_GPIO1_IO03 	*((volatile unsigned int *)0X020E0068)
#define SW_PAD_GPIO1_IO03 	*((volatile unsigned int *)0X020E02F4)

/* 
 * GPIO1相关寄存器地址 
 */
#define GPIO1_DR 			*((volatile unsigned int *)0X0209C000)
#define GPIO1_GDIR 			*((volatile unsigned int *)0X0209C004)
#define GPIO1_PSR 			*((volatile unsigned int *)0X0209C008)
#define GPIO1_ICR1 			*((volatile unsigned int *)0X0209C00C)
#define GPIO1_ICR2 			*((volatile unsigned int *)0X0209C010)
#define GPIO1_IMR 			*((volatile unsigned int *)0X0209C014)
#define GPIO1_ISR 			*((volatile unsigned int *)0X0209C018)
#define GPIO1_EDGE_SEL 		*((volatile unsigned int *)0X0209C01C)

#endif

main.c

#include "main.h"

/*
 * @description	: 使能I.MX6U所有外设时钟
 * @param 		: 无
 * @return 		: 无
 */
void clk_enable(void)
{
	CCM_CCGR0 = 0xffffffff;
	CCM_CCGR1 = 0xffffffff;
	CCM_CCGR2 = 0xffffffff;
	CCM_CCGR3 = 0xffffffff;
	CCM_CCGR4 = 0xffffffff;
	CCM_CCGR5 = 0xffffffff;
	CCM_CCGR6 = 0xffffffff;
}

/*
 * @description	: 初始化LED对应的GPIO
 * @param 		: 无
 * @return 		: 无
 */
void led_init(void)
{
	/* 1、初始化IO复用 */
	SW_MUX_GPIO1_IO03 = 0x5;	/* 复用为GPIO1_IO03 */

	/* 2、、配置GPIO1_IO03的IO属性	
	 *bit 16:0 HYS关闭
	 *bit [15:14]: 00 默认下拉
     *bit [13]: 0 kepper功能
     *bit [12]: 1 pull/keeper使能
     *bit [11]: 0 关闭开路输出
     *bit [7:6]: 10 速度100Mhz
     *bit [5:3]: 110 R0/6驱动能力
     *bit [0]: 0 低转换率
     */
	SW_PAD_GPIO1_IO03 = 0X10B0;		

	/* 3、初始化GPIO */
	GPIO1_GDIR = 0X0000008;	/* GPIO1_IO03设置为输出 */

	/* 4、设置GPIO1_IO03输出低电平,打开LED0 */
	GPIO1_DR = 0X0;
}

/*
 * @description	: 打开LED灯
 * @param 		: 无
 * @return 		: 无
 */
void led_on(void)
{
	/* 
	 * 将GPIO1_DR的bit3清零	 
	 */
	GPIO1_DR &= ~(1<<3); 
}

/*
 * @description	: 关闭LED灯
 * @param 		: 无
 * @return 		: 无
 */
void led_off(void)
{
	/*    
	 * 将GPIO1_DR的bit3置1
	 */
	GPIO1_DR |= (1<<3);
}

/*
 * @description	: 短时间延时函数
 * @param - n	: 要延时循环次数(空操作循环次数,模式延时)
 * @return 		: 无
 */
void delay_short(volatile unsigned int n)
{
	while(n--){}
}

/*
 * @description	: 延时函数,在396Mhz的主频下
 * 			  	  延时时间大约为1ms
 * @param - n	: 要延时的ms数
 * @return 		: 无
 */
void delay(volatile unsigned int n)
{
	while(n--)
	{
		delay_short(0x7ff);
	}
}

/*
 * @description	: mian函数
 * @param 	    : 无
 * @return 		: 无
 */
int main(void)
{
	clk_enable();		/* 使能所有的时钟		 	*/
	led_init();			/* 初始化led 			*/

	while(1)			/* 死循环 				*/
	{	
		led_off();		/* 关闭LED   			*/
		delay(500);		/* 延时大约500ms 		*/

		led_on();		/* 打开LED		 	*/
		delay(500);		/* 延时大约500ms 		*/
	}

	return 0;
}

makefile

objs = start.o main.o #定义变量 start.o在前
ledc.bin : $(objs) #使用变量
	arm-linux-gnueabihf-ld -Ttext 0x87800000 $^ -o ledc.elf
	arm-linux-gnueabihf-objcopy -O binary -S ledc.elf $@
	arm-linux-gnueabihf-objdump -D -m arm ledc.elf > ledc.dis

%.o : %.c #    %匹配所有
	arm-linux-gnueabihf-gcc -Wall -nostdlib -c -o $@ $<

%.o : %.s
	arm-linux-gnueabihf-gcc -Wall -nostdlib -c -o $@ $<

clean :
	rm -rf *.o ledc.bin ledc.elf ledc.dis

链接脚本编写

SECTIONS{
	. = 0X87800000;
	.text :
	{
		start.o 
		main.o 
		*(.text)
	}
	.rodata ALIGN(4) : {*(.rodata*)}     
	.data ALIGN(4)   : { *(.data) }    
	__bss_start = .;    
	.bss ALIGN(4)  : { *(.bss)  *(COMMON) }    
	__bss_end = .;
}

Rk github

Uboot

env.txt里面存放了分区信息,可以不进行烧录

mtdparts=spi-nand0:256K(env),1M@256K(idblock),1M(uboot),4M(boot),32M(rootfs),48M(oem),32M(userdata)
sys_bootargs= ubi.mtd=4 ubi.block=0,rootfs root=/dev/ubiblock0_0 rootfstype=squashfs rk_dma_heap_cma=66M
sd_parts=mmcblk0:16K@512(env),512K@32K(idblock),4M(uboot)

env.img生成是由uboot下的/tools/mkenvimage去生成,是uboot内置的一个工具。

./tools/mkenvimage -s 0x10000 -o env.img env.txt

-s 0x10000 指定环境变量分区大小(64KB)。这个

📦 U-Boot 编译生成的文件详解

当你执行 make rv1106_defconfig 和 make -j16 后,会生成以下关键文件:

1. 核心镜像文件

u-boot.bin ✅ 最重要的文件

# 这是最终的 U-Boot 二进制镜像
# 包含:U-Boot 主程序 + 设备树(DTB)
ls -lh u-boot.bin
  • 内容:完整的 U-Boot 可执行代码
  • 格式:原始二进制格式
  • 用途:可以直接烧录到存储设备
  • 加载地址:由 CONFIG_SYS_TEXT_BASE 决定(RV1106 是 0x00200000)

u-boot-nodtb.bin

# U-Boot 主程序(不包含设备树)
ls -lh u-boot-nodtb.bin
  • 内容:纯 U-Boot 代码,不含设备树
  • 用途:用于 FIT 镜像打包,或与外部 DTB 合并
  • 大小:比 u-boot.bin 小(少了 DTB)

u-boot.dtb

# 设备树二进制文件
ls -lh u-boot.dtb
  • 内容:硬件描述信息(内存、外设、引脚等)
  • 来源:从 .dts 文件编译而来
  • 用途:告诉 U-Boot 板子的硬件配置

2. SPL/TPL 相关文件

spl/u-boot-spl.bin

# Secondary Program Loader (二级引导程序)
ls -lh spl/u-boot-spl.bin
  • 作用:第一个运行的代码(在 DDR 初始化之前)

  • 大小:通常 < 128KB

  • 功能:

    1. 初始化 DDR 内存
    2. 初始化时钟
    3. 从存储设备加载主 U-Boot (u-boot.bin)
    4. 跳转到主 U-Boot

tpl/u-boot-tpl.bin(如果有)

# Tertiary Program Loader (三级引导程序)
ls -lh tpl/u-boot-tpl.bin
  • 作用:比 SPL 更早运行(在某些平台上)
  • 功能:初始化最基本的硬件(SRAM、时钟)

3. FIT 镜像相关文件

u-boot.img

# 带 mkimage 头部的 U-Boot 镜像
ls -lh u-boot.img
  • 内容:u-boot.bin + 64 字节的头部信息

  • 头部包含:

    • 魔数(0x27051956)
    • 加载地址
    • 入口地址
    • 镜像大小
    • CRC 校验码
    • 镜像名称
  • 用途:U-Boot 可以通过 bootm 命令加载这种格式

u-boot-dtb.img

# 包含设备树的 FIT 格式镜像(如果启用 FIT)
ls -lh u-boot-dtb.img
  • 内容:U-Boot + DTB 的 FIT 格式打包
  • 用途:支持更灵活的镜像管理

u-boot.itb(Image Tree Blob)

# FIT 格式的完整镜像
ls -lh u-boot.itb
  • 内容:可以包含多个组件(SPL、U-Boot、DTB、TEE 等)

  • 格式:基于设备树的镜像描述

  • 优势:

    • 支持签名验证
    • 支持多个配置选项
    • 支持压缩

4. 调试和符号文件

u-boot.sym

# 符号表文件
ls -lh u-boot.sym
cat u-boot.sym | head -20
  • 内容:所有函数和变量的地址映射

  • 格式:地址 类型 名称

  • 用途:

    • 调试时查找崩溃位置
    • 分析内存布局
    • 使用 addr2line 转换地址到源码行号

示例:

00200000 T _start
00200100 t board_init_f
00210000 T main_loop

u-boot.map

bash# 链接映射文件(非常详细)
ls -lh u-boot.map
head -50 u-boot.map
  • 内容:完整的内存布局信息

  • 包含:

    • 每个段的位置和大小
    • 每个函数的地址
    • 全局变量的位置
    • 内存使用情况统计
  • 用途:

    • 优化内存使用
    • 调试链接问题
    • 分析代码大小

示例输出:

Memory Configuration

Name             Origin             Length             Attributes
*default*        0x00000000         0xffffffff

Linker script and memory map

.text           0x00200000   0x123456
                0x00200000                . = ALIGN (0x4)
                0x00200000                _start = .
 *(.text._start)
 .text._start   0x00200000       0x80 arch/arm/cpu/armv7/start.o

System.map

# 系统符号映射(类似 u-boot.sym)
ls -lh System.map
  • 内容:内核风格的符号表
  • 用途:与 Linux 内核工具兼容

5. ELF 格式文件

u-boot(无扩展名)

# ELF 格式的可执行文件
ls -lh u-boot
file u-boot
  • 格式:ELF(Executable and Linkable Format)

  • 内容:包含调试信息、符号表、重定位信息

  • 用途:

    • GDB 调试
    • 反汇编分析
    • 查看详细的段信息

常用命令:

# 查看 ELF 头信息
readelf -h u-boot

# 查看段信息
readelf -S u-boot

# 反汇编
arm-linux-gnueabihf-objdump -D u-boot > u-boot.dis

# 提取特定段
arm-linux-gnueabihf-objcopy -O binary -j .text u-boot u-boot-text.bin

u-boot.srec

# Motorola S-Record 格式
ls -lh u-boot.srec
  • 格式:文本格式的十六进制数据
  • 用途:用于某些编程器或仿真器

u-boot.hex

# Intel HEX 格式
ls -lh u-boot.hex
  • 格式:文本格式的十六进制数据
  • 用途:用于 JTAG 烧录或仿真器

6. Rockchip 特定文件

当你使用 ./make.sh 编译时,还会生成:

uboot.img

bash# Rockchip 格式的 U-Boot 镜像
ls -lh uboot.img
  • 内容:经过 Rockchip 工具处理的 U-Boot

  • 特点:

    • 添加了 Rockchip 特定的头部
    • 可能进行了加密或签名
    • 对齐到特定边界

trust.img

# ARM Trusted Firmware / OP-TEE 镜像
ls -lh trust.img
  • 内容:安全世界固件(ATF 或 TEE)
  • 用途:提供安全启动支持

rv1106_idblock_*.img

# IDB(Initial Data Block)镜像
ls -lh rv1106_idblock_v*.img
  • 内容:DDR 初始化代码 + SPL
  • 用途:第一个被 ROM Code 加载的代码块
  • 烧录位置:SPI Flash/NAND 的 offset 0x0

rv1106_download_*.bin

# 完整的下载镜像
ls -lh rv1106_download_v*.bin
  • 内容:DDR + SPL + U-Boot + Trust 的完整打包
  • 用途:通过 USB 一次性烧录所有内容

📊 文件关系流程图

源代码 (.c, .S)
    ↓
编译 (.o 文件)
    ↓
链接
    ↓
┌─────────────────────────────────────┐
│         u-boot (ELF 格式)            │ ← 用于 GDB 调试
└─────────────────────────────────────┘
    ↓ objcopy (去除调试信息)
┌─────────────────────────────────────┐
│      u-boot-nodtb.bin (二进制)       │ ← 纯 U-Boot 代码
└─────────────────────────────────────┘
    ↓ cat (合并设备树)
┌─────────────────────────────────────┐
│       u-boot.bin (二进制)            │ ← 可直接烧录
└─────────────────────────────────────┘
    ↓ mkimage (添加头部)
┌─────────────────────────────────────┐
│        u-boot.img (带头部)           │ ← bootm 加载
└─────────────────────────────────────┘
    ↓ rkbin 工具打包
┌─────────────────────────────────────┐
│        uboot.img (Rockchip 格式)     │ ← 最终烧录
└─────────────────────────────────────┘

同时:
spl/ 目录生成 → spl/u-boot-spl.bin → 打包到 idblock.img

🔍 如何查看文件内容

1. 查看文件大小和类型

file u-boot*
ls -lh u-boot*

2. 查看符号表

# 查看所有符号
arm-linux-gnueabihf-nm u-boot | head -20

# 查看特定函数
arm-linux-gnueabihf-nm u-boot | grep board_init

# 按大小排序
arm-linux-gnueabihf-nm --size-sort u-boot | tail -20

3. 反汇编

# 反汇编整个文件
arm-linux-gnueabihf-objdump -D u-boot > u-boot.dis

# 反汇编特定函数
arm-linux-gnueabihf-objdump -D u-boot | grep -A 20 "<board_init_f>:"

# 交互式反汇编
arm-linux-gnueabihf-objdump -d -S u-boot | less

4. 查看段信息

# 查看所有段
readelf -S u-boot

# 查看程序头
readelf -l u-boot

# 查看符号
readelf -s u-boot

5. 提取特定部分

# 只提取 .text 段
arm-linux-gnueabihf-objcopy -O binary -j .text u-boot text_only.bin

# 提取 .rodata 段
arm-linux-gnueabihf-objcopy -O binary -j .rodata u-boot rodata_only.bin

💡 实际使用建议

对于 RV1106 SPI NAND 启动:

你需要的是:

  1. spl/u-boot-spl.bin → 打包到 rv1106_idblock_*.img
  2. u-boot.bin → 打包到 uboot.img

手动编译步骤:

cd /root/rk-tools/u-boot

# 1. 配置
make rv1106-spi-nand-tb_defconfig

# 2. 修改 .config(禁用 META)
scripts/config --disable CONFIG_ROCKCHIP_META

# 3. 编译
make -j16 CROSS_COMPILE=arm-linux-gnueabihf-

# 4. 检查生成的文件
ls -lh u-boot.bin spl/u-boot-spl.bin

# 5. 使用 rkbin 工具打包(需要 make.sh 或手动调用脚本)
cd ../uboot  # 回到有 make.sh 的目录
./make.sh loader  # 打包 loader
./make.sh uboot   # 打包 uboot

U-Boot 设备树的来源


📂 设备树文件的层次结构

1. 设备树源文件(.dts/.dtsi)

arch/arm/dts/
├── rv1106.dtsi                    # RV1106 SoC 公共定义(包含)
├── rv1106-evb.dts                 # RV1106 EVB 开发板配置
├── rv1106-evb2.dts                # RV1106 EVB2 开发板配置
├── rv1106b-evb2.dts               # RV1106B EVB2 开发板配置
└── rv1106b-evb2-spi-nand.dts      # RV1106B SPI NAND 配置 ⭐ 你的板子用这个

🌳 设备树来源详解

设备树的三层结构

┌─────────────────────────────────────────────────┐
│  1. rv1106b-evb2-spi-nand.dts (板级配置)          │ ← 最顶层:具体板子
│     #include "rv1106b-evb2.dts"                  │    - 选择启动设备
│                                                  │    - 启用/禁用外设
└────────────────────┬────────────────────────────┘
                     ↓ include
┌─────────────────────────────────────────────────┐
│  2. rv1106b-evb2.dts (开发板配置)                 │ ← 中间层:开发板
│     #include "rv1103b.dtsi"                      │    - 内存配置
│     #include "rv1103b-u-boot.dtsi"               │    - 外设使能
│                                                  │    - 引脚定义
└────────────────────┬────────────────────────────┘
                     ↓ include
┌─────────────────────────────────────────────────┐
│  3. rv1106.dtsi / rv1103b.dtsi (SoC 基础定义)    │ ← 底层:芯片
│     - CPU 核心                                   │    - CPU 信息
│     - 内存控制器                                  │    - 所有外设地址
│     - 中断控制器                                  │    - 时钟定义
│     - 所有外设节点(默认 disabled)                │    - 寄存器映射
└─────────────────────────────────────────────────┘

📝 设备树编译流程

步骤 1:从 .dts 到 .dtb

编译流程图

1. 配置阶段 (make rv1106-spi-nand-tb_defconfig)
   ↓
   .config 中设置:
   CONFIG_DEFAULT_DEVICE_TREE="rv1106-evb"
   
2. 编译阶段 (make -j16)
   ↓
   a. 预处理 .dts 文件
      rv1106-evb.dts + rv1106.dtsi + ... 
      ↓ (cpp 预处理器)
      rv1106-evb.dts.pre.tmp
   
   b. 编译成 .dtb
      rv1106-evb.dts.pre.tmp
      ↓ (dtc - Device Tree Compiler)
      arch/arm/dts/rv1106-evb.dtb
   
   c. 合并到 U-Boot
      u-boot-nodtb.bin + rv1106-evb.dtb
      ↓ (cat 命令)
      u-boot-dtb.bin
   
   d. 打包成镜像
      u-boot-dtb.bin
      ↓ (mkimage)
      u-boot-dtb.img

🔍 实际查看生成的设备树

查看设备树内容的方法

# 方法 1: 反编译 .dtb 为可读的 .dts 格式
dtc -I dtb -O dts /root/rk-tools/u-boot/arch/arm/dts/rv1106b-evb2-spi-nand.dtb

# 方法 2: 使用 U-Boot 工具
fdtdump /root/rk-tools/u-boot/arch/arm/dts/rv1106b-evb2-spi-nand.dtb

# 方法 3: 在 U-Boot 命令行中查看(烧录后)
=> fdt list /
=> fdt print /memory
=> fdt print /spi_nand

📚 完整答案:设备树从哪里来?

1. 设备树的来源

设备树不是"从某处复制"的,而是:

  1. Rockchip 官方编写 → 放在 U-Boot 源码的 arch/arm/dts/ 目录
  2. 编译时自动生成 → 通过 dtc (Device Tree Compiler) 编译成二进制

2. 设备树文件的位置

U-Boot 源码目录:
/root/rk-tools/u-boot/arch/arm/dts/
├── rv1106.dtsi                    # SoC 基础定义(Rockchip 编写)
├── rv1106-u-boot.dtsi             # U-Boot 特定配置
├── rv1106-evb.dts                 # EVB 开发板配置
├── rv1106b-evb2.dts               # EVB2 开发板配置
└── rv1106b-evb2-spi-nand.dts      # ⭐ SPI NAND 板子配置

3. 设备树的继承关系

dts// rv1106b-evb2-spi-nand.dts (你的板子)
/dts-v1/;
#include "rv1106b-evb2.dts"        // ← 包含开发板配置

/ {
    model = "Rockchip RV1106B EVB2 Board";
    compatible = "rockchip,rv1106b-evb2-spi-nand", "rockchip,rv1106b";
    
    chosen {
        stdout-path = &uart0;
        u-boot,spl-boot-order = &spi_nand;  // ← 从 SPI NAND 启动
    };
};

&spi_nand {
    u-boot,dm-spl;                  // ← SPL 阶段启用 SPI NAND
    status = "okay";                // ← 启用 SPI NAND
};
dts// rv1106b-evb2.dts (开发板层)
/dts-v1/;
#include "rv1103b.dtsi"            // ← 包含 SoC 定义
#include "rv1103b-u-boot.dtsi"     // ← U-Boot 配置

/ {
    model = "Rockchip RV1106B EVB2 Board";
    
    chosen {
        u-boot,spl-boot-order = &spi_nor, &emmc;  // 默认从 SPI NOR/eMMC 启动
    };
};

&spi_nand {
    status = "disabled";            // ← 默认禁用 SPI NAND
};
dts// rv1106.dtsi / rv1103b.dtsi (SoC 层 - Rockchip 编写)
/ {
    compatible = "rockchip,rv1106";
    
    cpus {
        cpu0: cpu@f00 {
            compatible = "arm,cortex-a7";
            reg = <0xf00>;
        };
    };
    
    // 所有外设节点(默认 disabled)
    spi_nand: spi@20d40000 {
        compatible = "rockchip,rv1106-sfc";
        reg = <0x20d40000 0x1000>;
        interrupts = <GIC_SPI 59 IRQ_TYPE_LEVEL_HIGH>;
        clocks = <&cru CLK_SFC>;
        status = "disabled";        // ← 默认禁用,由板级配置启用
    };
    
    // ... 更多外设定义
};

🔧 设备树编译过程


✅ 最终答案

设备树 u-boot.dtb / u-boot-dtb.img 的来源:

  1. 源文件位置:

    /root/rk-tools/u-boot/arch/arm/dts/
    ├── rv1106.dtsi              # Rockchip 官方编写的 SoC 定义
    ├── rv1106-evb.dts           # Rockchip 官方编写的开发板配置
    └── rv1106b-evb2-spi-nand.dts # 你的板子配置
    
  2. 编译过程:

    # 步骤 1: 预处理(展开 #include)
    cpp rv1106-evb.dts > rv1106-evb.dts.pre.tmp
    
    # 步骤 2: 编译成二进制 DTB
    dtc -I dts -O dtb -o rv1106-evb.dtb rv1106-evb.dts.pre.tmp
    
    # 步骤 3: 复制到输出目录
    cp rv1106-evb.dtb dts/dt.dtb
    
    # 步骤 4: 合并到 U-Boot
    cat u-boot-nodtb.bin dts/dt.dtb > u-boot-dtb.bin
    
    # 步骤 5: 打包成镜像
    mkimage -A arm -T firmware -d u-boot-dtb.bin u-boot-dtb.img
    
  3. 由谁编写:

    • ✅ Rockchip 官方工程师编写和维护
    • ✅ 放在 U-Boot 源码的 arch/arm/dts/ 目录
    • ✅ 随 U-Boot 源码一起发布
    • ✅ 你可以根据需要修改或创建新的 .dts 文件
  4. 如何查看内容:

    # 反编译为可读格式
    dtc -I dtb -O dts /root/rk-tools/u-boot/arch/arm/dts/rv1106-evb.dtb
    
    # 或在 U-Boot 命令行中
    => fdt list /
    => fdt print /memory
    

💡 关键点总结

  • 设备树不是"从某处复制",而是 Rockchip 官方编写并包含在 U-Boot 源码中
  • 编译时自动生成 .dtb 文件
  • 三层结构:SoC 定义 (.dtsi) → 开发板配置 (.dts) → 具体板子配置 (.dts)
  • 通过 #include 继承,避免重复定义
  • 最终合并到 u-boot.bin 形成 u-boot-dtb.bin

开发板_defconfig 文件在configs目录,这个和kernel有出入

方式1:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- rv1106_defconfig
make V=1 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j8

方式2:

在uboot的顶层makefile中添加

ARCH=arm
CROSS_COMPILE=arm-linux-gnueabihf-

直接执行

make distclean
make rv1106_defconfig
make -j8

方式3:

直接执行rk提供的uboot下的make.sh脚本。这个步骤会把rkbin相关的东西也编译。

大多数Rockchip 芯片(包括 RK、RV 系列)的 BootROM 启动逻辑基本遵循这个模式

Rockchip(RK/RV 系列)的启动介质选择机制与 i.MX 完全不同:RK 芯片一般不通过引脚直接选择启动介质,而是依靠 BootROM 内置的固定优先级顺序 或 OTP/eFuse 配置的启动顺序。

SPI NAND

  • 特性:NAND 介质出厂可能包含坏块,且擦写次数多后坏块会增多。

  • 策略:用 "逐块扫描 + 签名匹配" 的方式。

BootROM 初始化 SPI NAND 控制器
         ↓
读取 Block 0 (0x000000 - 0x01FFFF) → 检查是否有 BOOT 签名?
         ↓  (没有)
读取 Block 1 (0x020000 - 0x03FFFF) → 检查是否有 BOOT 签名?
         ↓  (没有)
读取 Block 2 (0x040000 - 0x05FFFF) → 检查是否有 BOOT 签名?
         ↓  (找到! 签名 "BOOT" = 0x544F4F42)
BootROM 根据 ID Block 的描述,将 **TPL**(FlashData,即 ddr_*.bin)加载到 SRAM(如 0x10000000)
         ↓
跳转到 TPL 执行 → TPL 初始化 DDR
         ↓
TPL 从 NAND 的指定位置(由 ID Block 或固定偏移)读取 **SPL**(FlashBoot,即 spl_spi_nand_*.bin)到 DDR 中(如 0x60000000)
         ↓
跳转到 SPL 执行 → SPL 初始化其他外设、加载 U‑Boot
         ↓
U‑Boot 启动内核

SPI NOR

  • 特性:无坏块问题,支持直接按字节寻址,随机读取性能好。
  • 策略:固定偏移扫描(通常从 0x00000000 开始)。BootROM 会直接读取 NOR 起始地址处的数据,检查是否有有效签名(如 BOOT 或 ID Block 标志)。
  • 某些 BootROM 也支持“双备份”模式:检查 offset 0 和 offset 某个页大小(如 0x1000)两个位置,但不做全盘逐块扫描。

eMMC / SD 卡

  • 特性:基于扇区(512 字节(0x4000))访问,有 MBR/GPT 分区表,不存在物理坏块(由卡内部控制器管理)。
  • 策略:
    • 优先检查用户分区 LBA 0(第一个扇区),寻找 BOOT 签名或 ID Block 结构。
    • 如果未找到,部分 BootROM 会尝试检查 eMMC 的 Boot Area Partition 1/2(若支持且配置了)。
    • 一般不会扫描多个 LBA,因为 SD/eMMC 的坏块管理由设备自身处理,BootROM 无需跳过坏块。
  • 注意:某些 Rockchip 芯片允许通过 efuse 或 OTP 配置从 eMMC 的特定偏移(如 4KB、8KB)启动,但默认仍是固定扇区 0。

其他介质(如 SDIO、UART 等)

  • 同样使用签名匹配,但扫描方式不同。例如 UART 下载模式是等待主机发送包含签名的数据流,而非扫描存储区域。

当uboot和kernel&设备树编译完成之后,就要用到rkbin这个工具了

输入文件

u-boot.bin
zImage
xxx.dtb

目录结构

rkbin/
├── bin/rv11/          ← RV1106 的预编译固件(DDR、SPL、TEE、USB下载固件等)
├── tools/              ← 打包工具(boot_merger, trust_merger, mkimage 等)
├── RKBOOT/             ← Loader 打包配置文件(*.ini)
├── RKTRUST/            ← Trust/TEE 打包配置文件(*.ini)
├── scripts/            ← 辅助脚本
└── img/                ← 其他镜像
原料工具产物烧录位置
u-boot.bin 或 spl/u-boot-spl.binmkimage -T rksduboot.imgNAND: uboot分区
DDR (闭源二进制)boot_mergeridblock.imgNAND: idblock分区
USB (闭源二进制)-download.bin烧录工具用
TEE (闭源二进制)trust_mergertrust.img可选
zImage + dtb + resource.imgmkimage -f boot.itsboot.imgNAND: boot分区
env.txtmkenvimageenv.imgNAND: env分区
rootfs目录mkfs.ubifs + ubinizerootfs_*.ubi
rootfs.img (软链接)
NAND: rootfs分区
oem目录同上oem_*.ubiNAND: oem分区
userdata目录同上userdata_*.ubiNAND: userdata分区
以上全部afptool + rkImageMakerupdate.img整包

建议直接用配置好的docker环境

直接在windows安装docker,再加载提供的镜像。再使用vscode安装dev contarner插件,直接使用界面进行开发。

安装docker

省略

加载镜像

docker load -i rk-develop.tar

启动容器

  • windows下(必须使用绝对路径)
docker run -it -d --name rk-develop --privileged -v E:\rk-develop\v-share:/home 4a59acf452d5 /bin/bash
  • linux下(可以使用相对路径)
docker run -it -d --name rk-develop --privileged -v ./v-share:/home 4a59acf452d5 /bin/bash

RK的github地址

https://github.com/rockchip-linux

RK的SOC的支持包都在这里面。

下载

尽量直接在github网页上下载zip文件。直接使用git clone命令会下载很慢。

  • rkbin
  • u-boot
  • kernel 主要要下载5.10以上的分支

下载交叉编译器

根据下载的uboot中的make.sh脚本中可以看到rk推荐的交叉编译器版本。

https://developer.arm.com/Downloads/-/Legacy%20Linaro%20GNU%20Toolchains
  • 64位
gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu
  • 32位
gcc-linaro-6.3.1-2017.05-x86_64_arm-linux-gnueabihf

配置交叉编译器

vim /etc/profile
export PATH=$PATH:/rk-tools/gcc-linaro-6.3.1-2017.05-x86_64_arm-linux-gnueabihf/bin

export PATH=$PATH:/rk-tools/gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu/bin
source /etc/profile
arm-linux-gnueabihf-gcc -v
aarch64-linux-gnu-gcc -v

虚拟机下载必要的环境

编译需要gcc等

apt-get update
apt-get install -y git ssh make gcc gcc-multilib g++-multilib module-assistant expect g++ gawk texinfo libssl-dev bison flex fakeroot cmake unzip gperf autoconf device-tree-compiler libncurses5-dev pkg-config bc python-is-python3 passwd openssl openssh-server openssh-client vim file cpio rsync

docker 一键部署

使用docker,docker镜像打包时已经包含了一些环境。该docker的基础镜像是ubuntu22.04,所以可以使用一些ubuntu的命令

docker镜像版本规定

  • rk-docker-development:1.0.0

    这个版本只有虚拟机必要的环境gcc等,没有rk的uboot,rkbin等。

  • rk-docker-development:2.0

    这个版本在1.0.0的基础上把rk git的rkbin,u-boot,rkdeveloptool,kernel4.4,交叉编译器。注意没有配置交叉环境

  • rk-docker-development:3.0

    这个版本配置了所有的环境,交叉编译器,gcc,还有uboot,rkbin等源码。注意,没有rk其他工具,比如rknpu,rknn-toolkit等

  • rk-docker-development:4.0

    待续------

手动编译未使用env.img

  • download : 不输入地址

  • idblock : 0x0000 0000 (占用8M)

  • uboot : 0x0080 0000 (占用512K)

  • boot : 0x0088 0000 (占用4M)

  • rootfs : 0x00C8 0000 (占用剩余的256M减4M减512K减8M)

  • mtd启动命令(rk写法-根据块)

    mtd dev 1; mtd read 0x02000000 0x4400 0x2000; bootm 0x02000000
    

    第0x4400块对应0x0088 0000,0x2000对应读4M到0x02000000RAM地址中

手动编译使用env.img

首先

在rockchip的github下载的内容,这一步已经在docker里面有这些内容了。我自己配置了docker环境包含了所有的开发环境(rk官方资料以及可编译的必要环境)

  • 选开发板,编译生成u-boot.bin

这个步骤也会生成uboot.img这个与imx6ull编译uboot产生的uboot.imx异曲同工。rk厂商的uboot的makefile也提供了类似nxp的功能。如果是uboot厂商编译出来的可能就没有这个相似的文件。

  • 选开发板,编译生成kernel(rv系列必须是5.10以上的内核版本,rk只提供了5.10以上的版本)

生成zImage和dtb设备树文件以及一些资源文件resource.img。

生成完download.bin&idblock.img后,在编译uboot时可以不指定ini文件。

注意:

  • 官方提供的串口的波特率比较高:1500000
  • RV1106MINIALL_SPI_NAND_TB.ini这种带有TB的都是启动链不同的。可以前往rkbin生成spl注意点.md
  • RV1106MINIALL.ini是rv1106芯片最基础的SPL,通用型SPL,这个SPL会去nand flash的0x00800000找uboot。

进入到rkbin

./tools/boot_merger RKBOOT/RV1106MINIALL.ini

rk-github编译流程-rkbin使用1

TB 就是 Tiny Boot

  • 正常理解的启动链一般是:
  • BootROM -> idblock/SPL -> U-Boot -> 停在 U-Boot 或再启动内核
  • TB 路线更像是:
  • BootROM -> Tiny SPL/Trust 组合 -> 直接找 boot 分区/系统镜像 -> 尽快起系统
#必须修改为RV1106TOS.ini否则肯定会进入Trust TB(tiny boot)
#CONFIG_TRUST_INI="RV1106TOS_TB.ini"
CONFIG_TRUST_INI="RV1106TOS.ini"
  • TB :
    • 默认继续找系统分区
    • 可能带 MCU 检查
    • 可能带 Trust tiny-boot 路径
    • 没有系统分区时就容易卡死
  • 普通 U-Boot 路线:
    • 更容易停在命令行
    • 更适合调试
    • 逻辑更直观
    • 你只烧 idblock + uboot 往往就能看到主 U-Boot banner

编译uboot

添加串口打印输出

  • 路径

    /uboot/u-boot/board/rockchip/evb_rv1106/evb_rv1106.c
    
  • 添加

    int rk_board_init(void)
    {
    	printf("\n[MY_MOD] =====================================\n");
    	printf("[MY_MOD] U-Boot 已修改!当前为自定义版本!\n");
    	printf("[MY_MOD] =====================================\n\n");
    	return 0;
    }
    

uboot使用的设备树在:配置文件中

CONFIG_DEFAULT_DEVICE_TREE="rv1106-evb"

注意:

  • rkbin必须在uboot同级目录并且名称也必须时rkbin,可以在make.sh进行修改

    RKBIN_TOOLS=../rkbin/tools
    

使用rk-github仓库的uboot有两种编译方式。

  1. rk提供的build.sh编译
  2. 原生编译(Makefile),不推荐。

2. 手动编译

2.1 rkbin生成

cd rkbin
./tools/boot_merge RKBOOT/RV1106MINIALL.ini
rk-github编译流程-uboot编译5

2.2 修改xxx_defconfig

  • 复制一个新的
cp ./configs/rv1106-spi-nand-tb-nofastae_defconfig ./configs/rv1106-spi-nand-nomcu_defconfig
  • 配置内容,注释/删除
CONFIG_LOADER_INI="RV1106MINIALL.ini"
CONFIG_TRUST_INI="RV1106TOS.ini"
  • 检查nand配置项
rk-github编译流程-uboot编译2
  • 设置uboot倒计时
CONFIG_BOOTDELAY=3

2.3 手动make编译

如果删除了xxx_defconfig里面的CONFIG_LOADER_INI,CONFIG_TRUST_INI内容只能进行手动make。

make rv1106-spi-nand-nomcu_defconfig
make -j16 #不使用make.sh需要指定编译核数

2.4 在串口工具调试

进入串口工具之后,有3秒倒计时

ctrl + c #成功进入uboot

2.5 注意

  • 串口打印 Trying fit image at 0x4000 sector,说明从4000扇区开始读uboot,即0x00800000(nand 1扇区2k,共2048个块*16扇区)

  • 如果打印了,说明要进行env.img校验

    ENVF: !bad CRC @ 0x0
    ENVF: !bad CRC @ 0x0
    No env partition table
    ## Unknown partition table type 0
    

    注释或者删除即可处理这个问题。

    CONFIG_ENVF=y
    
  • 改uboot的env.img的获取地址

    CONFIG_ENV_NAND_OFFSET= 0x0 #放入第0扇区
    CONFIG_ENV_NAND_SIZE = 0x40000 #大小,按字节,而不是扇区
    
  • 如果要修改uboot的启动地址

    • 在xxx_defconfig修改,或者临时修改在**.config**文件
    CONFIG_MTD_BLK_U_BOOT_OFFS=0x2000 #0x00400000 / 512 = 0x2000
    
    • 利用uboot生成的spl和tpl

    • 将tpl和spl放到rkbin/bin目录

      u-boot-tpl.bin 和 u-boot-spl.bin
      
    • 创建在RKBOOT目录创建RV1106MINIALL_MY_TPL_SPL.ini,复制RV1106MINIALL.ini内容到RV1106MINIALL_MY_TPL_SPL.ini

      [CHIP_NAME]
      NAME=RV1106
      [VERSION]
      MAJOR=1
      MINOR=1
      [CODE471_OPTION]
      NUM=1
      Path1=bin/rv11/u-boot-tpl.bin
      Sleep=1
      [CODE472_OPTION]
      NUM=1
      Path1=bin/rv11/rv1106_usbplug_v1.09.bin
      [LOADER_OPTION]
      NUM=2
      LOADER1=FlashData
      LOADER2=FlashBoot
      FlashData=bin/rv11/u-boot-tpl.bin
      FlashBoot=bin/rv11/u-boot-spl.bin
      [OUTPUT]
      PATH=rv1106_download_v1.15.108.bin
      IDB_PATH=rv1106_idblock_v1.15.102.img
      [SYSTEM]
      NEWIDB=true
      [FLAG]
      471_RC4_OFF=true
      RC4_OFF=true
      CREATE_IDB=true
      
    • tpl可以不改也没必要改,但也提供思路

      [CHIP_NAME]
      NAME=RV1106
      [VERSION]
      MAJOR=1
      MINOR=1
      [CODE471_OPTION]
      NUM=1
      Path1=bin/rv11/rv1106_ddr_924MHz_v1.15.bin
      Sleep=1
      [CODE472_OPTION]
      NUM=1
      Path1=bin/rv11/rv1106_usbplug_v1.09.bin
      [LOADER_OPTION]
      NUM=2
      LOADER1=FlashData
      LOADER2=FlashBoot
      FlashData=bin/rv11/rv1106_ddr_924MHz_v1.15.bin
      FlashBoot=bin/rv11/u-boot-spl.bin #只改动这里
      [OUTPUT]
      PATH=rv1106_download_v1.15.108.bin
      IDB_PATH=rv1106_idblock_v1.15.102.img
      [SYSTEM]
      NEWIDB=true
      [FLAG]
      471_RC4_OFF=true
      RC4_OFF=true
      CREATE_IDB=true
      

1. make.sh编译uboot

1.1 配置交叉编译环境

  • 修改uboot根目录的make.sh,注意要绝对路径
CROSS_COMPILE_ARM32=/root/rk-tools/gcc-linaro-6.3.1-2017.05-x86_64_arm-linux-gnueabihf/bin/arm-linux-gnueabihf-
CROSS_COMPILE_ARM64=/root/rk-tools/gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu/bin/aarch64-linux-gnu-
  • 修改uboot根目录的makefile
ARCH = arm
CROSS_COMPILE = arm-linux-gnueabihf-

1.2 修改xxx_defconfig

对于现有的uboot的_defconfig是不能满足我们需要的功能的。

  • 复制一个新的
cp ./configs/rv1106-spi-nand-tb-nofastae_defconfig ./configs/rv1106-spi-nand-nomcu_defconfig
  • 配置内容

由于官方提供得spi-nand都有TB,启动链方式要验证kernel。所以要配置RV1106TOS.ini

CONFIG_LOADER_INI="RV1106MINIALL.ini"
CONFIG_TRUST_INI="RV1106TOS.ini"
  • 检查nand配置项
rk-github编译流程-uboot编译2

1.3 使用make.sh选择xxx_defconfig文件

这一步相当于make xxx_defconfig,make.sh帮我们做了这个步骤。

#注意make.sh会自己拼接_defconfig
./make.sh rv1106-spi-nand-nomcu

等待执行完

rk-github编译流程-uboot编译3

1.4 烧录

rk官方提供的RV1106MINIALL.ini这个SPL会去nand flash的0x00800000找uboot。

如果要指定SPL找uboot的地址。需要在配置文件中配置

CONFIG_SYS_LOAD_ADDR = 

rk-github编译流程-uboot编译4

命令

make distclean
make clean
make xxx_defconfig
make menuconfig
make xxx.config

编译kernel

- 注意:

  • 默认情况下,rv1106_defconfig使用的是rv1106g-evb1-v11-spi-nand-cvr.dts设备树

- 配置交叉编译器

在kernel顶层makefile添加

ARCH = arm
CROSS_COMPILE = arm-linux-gnueabihf-

- 配置xxx_defconfig(必须)通过下面的合并xxx.config也能实现

CONFIG_MTD=y
CONFIG_MTD_NAND=y
CONFIG_MTD_SPI_NAND=y
CONFIG_MTD_UBI=y
CONFIG_UBIFS_FS=y
CONFIG_MTD_CMDLINE_PARTS=y
CONFIG_MTD_OF_PARTS=y
CONFIG_SPI_ROCKCHIP_SFC=y

- 选择xxx_defconfig

kernel顶层执行:

make rv1106_defconfig

- 合并xxx.config

这一步主要是将自定义或者其他的xxx.config合并到现有的kernel根目录下的**.config**

又称:基础 defconfig + 若干 fragment

make rv1106-nand.config
make rv1106-cvr.config #或 make rv1106-evb.config

工作原理:

  • rv1106_defconfig 会生成基础的 .config 文件
  • rv1106-nand.config 会被识别为配置片段(fragment),通过 merge_config.sh 脚本合并到现有的 .config 中
  • 合并后会自动执行 olddefconfig 来解决依赖关系

- 执行编译

  • 编译所有

    make -j16
    
  • 只编译设备树

    make dtbs
    
  • 编译单个设备树

    设备树名通常与对应的 .dts 文件名相同

    make <dts文件名>.dtb
    

编译结果:

  • arch/arm/boot/zImage文件
  • arch/arm/boot/dts/* 所有设备树文件
    • rv1106_defconfig使用的是rv1106g-evb1-v11-spi-nand-cvr.dts设备树

Linux 内核的标准构建目标就是这些:

  • zImage / Image
  • dtbs
  • modules

基于内存的临时文件系统(ramdisk)

在**.its**文件进行配置

现在不需要,所以在配置中关闭xxx_defconfig/.config

#CONFIG_BLK_DEV_INITRD =y

resources.img用来存放开机logo,开机视频等资源文件

rkbin/tools/resource_tool 会工具在当前目录生成resource.img

./rkbin/tools/resource_tool --pack \
    --image=resource.img \
    rv1106g-evb1-v11-spi-nand-cvr.dtb \      # 必须是第一个文件
    logo.bmp \
    logo_kernel.bmp

resources.img中的dtb

  • 主 DTB 放在 FIT 的 fdt 节点,U-Boot 传递给内核。
  • resources.img 放在 FIT 的 loadables 节点,U-Boot 只是把它搬运到约定地址,之后内核通过主 DTB 中的 rockchip,resource 属性找到它并解析。
  • 两者职责不同,缺一不可。

Android boot.img(暂时用不到)

kernel/scripts/mkbootimg

mkbootimg --kernel zImage \
          --ramdisk ramdisk.img \ #可以留空
          --second dtb_file.dtb \          # dtb 装入 second 区
          --cmdline "console=ttyS2,115200n8 root=/dev/mmcblk0p2 rw init=/init" \
          --base 0x02000000 \              # RV1106 DDR 起始可能不同,需确认
          --kernel_offset 0x00008000 \
          --ramdisk_offset 0x04000000 \
          --second_offset 0x00f00000 \
          --tags_offset 0x00000100 \
          -o boot.img

目标:

  • 将多个组件(内核、DTB、ramdisk、资源文件等)打包成一个镜像文件。让uboot能快速启动内核。

注意点:

  • 128M RAM:0x0800 0000

  • uboot需要将boot.img镜像放到ddr中某个位置

mtd read 0x02000000 0x04400000 0x800000
  • 从nand的0x04400000位置读取0x800000(8M)数据到ddr的0x02000000

  • 内核load = <0x03000000>; 尽量不和boot.img在ddr的8M地址冲突

  • 内核启动之后,会释放uboot占用的ddr,boot.img占用的ddr也释放,uboot通过FIT加载的设备树的ddr也会释放,除了内核不会被释放(0x03000000开始的部分)

1. 创建boot.its文件

/dts-v1/;
/ {
    description = "RV1106 NAND boot image";
    #address-cells = <1>;

    images {
        kernel@1 {
            data = /incbin/("zImage");
            type = "kernel";
            arch = "arm";
            os = "linux";
            compression = "none";
            load = <0x03000000>; // 告诉uboot将内核放到ddr的0x03000000
            entry = <0x03000000>; 
        };
        fdt@1 {
            data = /incbin/("rv1106g-evb1-v11-spi-nand-cvr.dtb");
            type = "flat_dt";
            arch = "arm";
            compression = "none";
            // load = <0x02800000>; 不分配地址,让uboot自己分配
        };
		/*
		resource@1 {
            data = /incbin/("resource.img");
            type = "firmware";
            arch = "arm";
            compression = "none";
            load = <0x06000000>;
        };
		*/
    };

    configurations {
        default = "conf@1";
        conf@1 {
            kernel = "kernel@1";
            fdt = "fdt@1";
			//loadables = "resource@1";
        };
    };
};

2. **生成FIT格式的数据

../uboot/tools/mkimage -f ./boot.its ./boot.img

3. 如果用到resource,需要修改设备树

rv1106g-evb1-v11-spi-nand-cvr.dts

/ {
    reserved-memory {
        #address-cells = <1>;
        #size-cells = <1>;
        ranges;

        /* 假设 resources.img 大小为 8MB */
        resource: resource@0x3f800000 {
            compatible = "rockchip,resource";
            reg = <0x3f800000 0x800000>; /* 从 1016MB 处开始,预留 8MB */
            no-map;
        };
    };
};
  • FIT做打包只依赖与uboot下的tools/mkimage,与uboot本身并没有关系,其实就是说,uboot能识别FIT镜像,所以要提供FIT镜像给到uboot。所以要在uboot的编译文件xxx_defconfig/.config配置开启。

    CONFIG_FIT=y
    #CONFIG_FIT_ENABLE_SHA256_SUPPORT=y #关闭校验
    #其他的FIT地址不列举了
    

必须在uboot的xxx_defconfig/.config进行配置

例子在:rv1106-spi-nand-nomcu_defconfig.md

CONFIG_FIT=y
# CONFIG_SPL_KERNEL_BOOT is not set #关闭spl启动内核

#CONFIG_ENVF=y #关闭校验env.img
#CONFIG_ENV_IS_NOWHERE=y
#CONFIG_ANDROID_BOOT_IMAGE=y #安卓镜像
#CONFIG_FIT_ENABLE_SHA256_SUPPORT=y
#CONFIG_FIT_ENABLE_RSASSA_PKCS=y
CONFIG_BOOTDELAY=3 #3s后无操作的话,启动kernel
CONFIG_BAUDRATE=115200
CONFIG_MTD=y
#CONFIG_CMD_MTD=y
#CONFIG_MTD_PARTITIONS=y #开启这个会导致mtd dev 1命令找不到设备号
CONFIG_ENVF=y
CONFIG_ENV_NAND_OFFSET=0x400000
CONFIG_ENV_NAND_SIZE=0x400000

3. 配置dev.img方式启动

CONFIG_ENVF=y
CONFIG_ENV_NAND_OFFSET=0x400000#烧录地址
CONFIG_ENV_NAND_SIZE=0x400000#大小

CONFIG_ENV_NAND_OFFSET=0x400000:设置 ENV 的物理偏移量为 4M( env 分区位置即烧录地址)。

CONFIG_ENV_NAND_SIZE=0x400000:设置 ENV 大小为 4M。

# 1. MTD 分区表(SPI NAND)
mtdparts=spi-nand0:4M(idblock),4M(env),512K(uboot),4M(boot),-(rootfs)
#    		↑          ↑                                        ↑
#         设备名    分区定义                                  剩余空间给rootfs

# 2. 内核启动参数
sys_bootargs= ubi.mtd=4 root=ubi0:rootfs rootfstype=ubifs rk_dma_heap_cma=66M
#              ↑         ↑              ↑                    ↑
#           MTD索引4  UBI卷名      文件系统类型        DMA内存预留66M

# 3. SD卡分区表(可选,用于SD卡启动)
sd_parts=mmcblk0:16K@512(env),512K@32K(idblock),4M(uboot)
../uboot/tools/mkenvimage -s 0x400000 -o env.img .env.txt
  • env注意点

    这3者必须完全对应。Config,mkenvimage,烧录地址。

U-Boot ConfigCONFIG_ENV_OFFSET=0x400000 CONFIG_ENV_SIZE=0x400000告诉 U-Boot 去 0x400000 读,读 4M
mkenvimage-s 0x400000生成4M 大小的镜像
烧录地址0x400000将 env.img 烧录到 Flash 的 0x400000 处

Rockchip 平台的特殊性

再次强调,Luckfox RV1106 默认使用 CONFIG_ENVF (Environment Framework)。

  • 在这种模式下,U-Boot 可能忽略 CONFIG_ENV_OFFSET,而是通过 MTD 分区表 来查找名为 env 的分区。

  • 如果是这种情况,只要你:

    1. .env.txt 中的 mtdparts 定义了 env 在正确位置。
    2. 烧录工具根据 mtdparts 将 env.img 烧录到正确位置。
    3. U-Boot 能正确解析 MTD 分区。

    那么即使 CONFIG_ENV_OFFSET 没改(或者是错的),系统可能仍然能正常工作,因为 ENVF 框架更智能。

  • 但是,如果你禁用了 CONFIG_ENVF 或强制使用 CONFIG_ENV_IS_IN_NAND,那么必须严格一致

启动命令解析(针对1,2)

mtd dev 1
mtd read 0x02000000 0x4400 0x4000
iminfo 0x02000000
bootm 0x02000000
  • **mtd:**选 NAND 设备
  • mtd read :
    • 0x4400:nand存放boot.img镜像快地址 (0x0088 0000/512)
    • 0x02000000 :DDR 加载地址
    • 0x4000: boot.img 大小 0x0080 0000(8M) /512
  • **bootm:**启动 FIT 镜像(必须是boot.img)
  • **iminfo:**检查镜像

1. uboot命令行启动

在本篇md中启动命令解析这一章节

这种方式无法挂载文件系统!!

2. uboot配置修改启动

  • 目标:直接uboot直接启动kernel。不需要env.img配置

uboot启动kernel详细在:env.img文件&uboot启动kernel流程.md

  • 2.1 uboot/include/configs/rv1106_common.h修改

    include"rockchip-common.h"之后添加

#include "rockchip-common.h" 

#ifndef RKIMG_BOOTCOMMAND
#define RKIMG_BOOTCOMMAND	"mtd dev 1; mtd read 0x02000000 0x4400 0x4000; bootm 0x02000000"
#endif
  • 2.2 将
    #undef RKIMG_BOOTCOMMAND
    #ifdef CONFIG_FIT_SIGNATURE
    #define RKIMG_BOOTCOMMAND		\
    	"boot_fit;"
    #else
    #define RKIMG_BOOTCOMMAND		\
    	"boot_fit;"			\
    	"boot_android ${devtype} ${devnum};"
    #endif
    
  • 2.3改为
    #undef RKIMG_BOOTCOMMAND
    #define RKIMG_BOOTCOMMAND	"mtd dev 1; mtd read 0x02000000 0x4400 0x4000; bootm 0x02000000"
    

这种方式无法挂载文件系统!!

在xxx_defconfig文件中有一个配置ENVF(Environment File) 功能

CONFIG_ENVF=y

什么是 ENVF?

  • ENVF 是 Rockchip 的一种机制,让 U-Boot 从外部文件(env.img)读取环境变量
  • 环境变量包括:bootargs(内核启动参数)、bootcmd(启动命令)、mtdparts(分区信息)等
  • 这个 env.img 文件通常存储在 SPI NAND 的某个分区中

启动流程

1. U-Boot 启动
2. 从 SPI NAND(emmc,nor等) 读取 env.img 文件
3. 从 env.img 中获取 bootargs 和 bootcmd
4. 执行 bootcmd(通常是 "boot_fit")
5. 使用 bootargs 启动 Linux 内核

boot_fit 是 一个自定义的 U‑Boot 环境变量,通常用来封装“加载并启动 FIT(Flattened Image Tree)镜像”的一系列操作。它就是第4步中的实际执行者,连接了环境变量与内核引导的“最后一公里”。

boot_fit 最终会调用 bootm(或 booti)等命令,解析 FIT 镜像,把内核、设备树、initramfs 加载好,并把 bootargs 传递给内核,跳转执行。此后 Linux 接管系统。

下载

https://buildroot.org/

不要使用root账户

#修改root密码
passwd root 
#创建一个新账户
useradd -m builder
#删除
userdel builder
#使用root账户给builder用户分配解压的buildroot文件夹权限
chown -R builder:builder /home/builder/buildroot
#后面再使用builder用户
su builder

修改分区规划

.env.txt文件

mtdparts=spi-nand0:4M(env),4M(idblock),512K(uboot),4M(boot),30M(oem),10M(userdata),-(rootfs)
sys_bootargs= ubi.mtd=6 root=ubi0:rootfs rootfstype=ubifs rk_dma_heap_cma=66M;

配置Target options

make menuconfig
Target options
        -> Target Architecture = ARM (little endian)
        -> Target Binary Format = ELF
        -> Target Architecture Variant = cortex-A7
        -> Target ABI = EABIhf
        -> Floating point strategy = NEON/VFPv4
        -> ARM instruction set = ARM

配置Toolchain

Toolchain
   -> Toolchain type = External toolchain
   -> Toolchain = Custom toolchain //选择用户的交叉编译器
   -> Toolchain origin = Pre-installed toolchain
   -> Toolchain path =/root/gcc-linaro-6.5.0-2019.12-x86_64_arm-linux-gnueabihf //交叉编译器路径
   -> Toolchain prefix = arm-linux-gnueabihf //前缀
   -> External toolchain gcc version = 6.x
   -> External toolchain kernel headers series = 5.10.x
   -> External toolchain C library = glibc/eglibc
   -> [*] Toolchain has SSP support? (NEW) //选中
   -> [*] Toolchain has RPC support? (NEW) //选中
   -> [*] Toolchain has C++ support? //选中
   -> [*] Enable MMU support (NEW) //选中

配置System configuration

System configuration
   -> System hostname = Embedfire_imx6ull //平台名字
   -> System banner = Welcome to embedfire i.mx6ull //欢迎语
   -> Init system = BusyBox //使用 busybox
   -> /dev management = Dynamic using devtmpfs + mdev //使用 mdev
   -> [*] Enable root login with password (NEW) //使能登录密码
   -> Root password = root //登录密码为 root

配置Filesystem images

-> Filesystem images
   -> [*] ext2/3/4 root filesystem //如果是 EMMC 或 SD 卡的话就用 ext3/ext4
      -> ext2/3/4 variant = ext4 //选择 ext4 格式
-> [*] ubi image containing an ubifs root filesystem //如果使用 NAND 的话就用 ubifs
- > [*] squashfs root filesystem
	-> block size (128k)
	-> compression algorithm (zstd)

nand配置

  • nand信息
rk-github编译流程-rootfs构建6
  • 配置

    [*] ubi image containing an ubifs root filesystem
        physical eraseblock size           = 0x20000
        sub-page size                      = 0
        
    [*] ubifs root filesystem
        logical eraseblock size            = 0x1f000
        minimum I/O unit size              = 0x800
        maximum logical eraseblock count   = 2000
        ubifs runtime compression          = lzo
        Compression method                 = no compression
    

    rk-github编译流程-rootfs构建7

关闭kernel和uboot的编译(默认关闭)

-> Kernel
-> [ ] Linux Kernel //取消 Linux Kernel 选项!

-> Bootloaders
-> [ ] U-Boot //取消 U-Boot 选项!

执行编译

make -j32

下载

https://buildroot.org/

不要使用root账户

#修改root密码
passwd root 
#创建一个新账户
useradd -m builder
#删除
userdel builder
#使用root账户给builder用户分配解压的buildroot文件夹权限
chown -R builder:builder /home/builder/buildroot
#后面再使用builder用户
su builder

配置Target options

make menuconfig
Target options
        -> Target Architecture = ARM (little endian)
        -> Target Binary Format = ELF
        -> Target Architecture Variant = cortex-A7
        -> Target ABI = EABIhf
        -> Floating point strategy = NEON/VFPv4
        -> ARM instruction set = ARM

配置Toolchain

Toolchain
   -> Toolchain type = External toolchain
   -> Toolchain = Custom toolchain //选择用户的交叉编译器
   -> Toolchain origin = Pre-installed toolchain
   -> Toolchain path =/root/gcc-linaro-6.5.0-2019.12-x86_64_arm-linux-gnueabihf //交叉编译器路径
   -> Toolchain prefix = arm-linux-gnueabihf //前缀
   -> External toolchain gcc version = 6.x
   -> External toolchain kernel headers series = 5.10.x
   -> External toolchain C library = glibc/eglibc
   -> [*] Toolchain has SSP support? (NEW) //选中
   -> [*] Toolchain has RPC support? (NEW) //选中
   -> [*] Toolchain has C++ support? //选中
   -> [*] Enable MMU support (NEW) //选中

配置System configuration

System configuration
   -> System hostname = Embedfire_imx6ull //平台名字
   -> System banner = Welcome to embedfire i.mx6ull //欢迎语
   -> Init system = BusyBox //使用 busybox
   -> /dev management = Dynamic using devtmpfs + mdev //使用 mdev
   -> [*] Enable root login with password (NEW) //使能登录密码
   -> Root password = root //登录密码为 root

配置Filesystem images

-> Filesystem images
   -> [*] ext2/3/4 root filesystem //如果是 EMMC 或 SD 卡的话就用 ext3/ext4
      -> ext2/3/4 variant = ext4 //选择 ext4 格式
-> [*] ubi image containing an ubifs root filesystem //如果使用 NAND 的话就用 ubifs

nand配置

  • nand信息
rk-github编译流程-rootfs构建6
  • 配置

    [*] ubi image containing an ubifs root filesystem
        physical eraseblock size           = 0x20000
        sub-page size                      = 0 
        
    [*] ubifs root filesystem
        logical eraseblock size            = 0x1f000
        minimum I/O unit size              = 0x800
        maximum logical eraseblock count   = 2000
        ubifs runtime compression          = lzo
        Compression method                 = no compression
    

    1. [*] ubi image containing an ubifs root filesystem

    • 作用:告诉 Buildroot 生成一个 UBI 镜像(ubi.img),其中包含一个 UBIFS 格式的根文件系统。
    • 背景:UBI (Unsorted Block Images) 是 Linux 内核中为原始 NAND Flash 设计的磨损平衡、坏块管理层。UBI 镜像可以直接烧写到 NAND 分区上,内核通过 UBI 驱动挂载内部的 UBIFS。

    子参数:

    physical eraseblock size = 0x20000

    • 含义:NAND Flash 芯片的物理擦除块大小(Physical Erase Block Size),十六进制 0x20000 = 128 KiB。
    • 作用:告诉 UBI 镜像生成工具(ubinize)你的 Flash 芯片每个擦除块实际有多大。这个值必须与硬件严格匹配,否则镜像无法正确烧写或挂载。

    sub-page size = 0

    • 含义:NAND Flash 的子页大小(Sub-page Size)。值为 0 表示不使用子页功能。
    • 作用:某些 NAND 芯片支持比页更小的写入单元(子页),用于优化小数据写入。设为 0 表示禁用该特性,UBI 将按完整页操作。

    2. [*] ubifs root filesystem

    • 作用:启用 UBIFS 格式的根文件系统(而不是 ext4、squashfs 等)。UBIFS 是专为 UBI 层设计的日志型文件系统,适合 NAND Flash。

    子参数:

    logical eraseblock size = 0x1f000

    • 含义:逻辑擦除块大小(Logical Erase Block Size),十六进制 0x1f000 = 126,976 字节(约 124 KiB)。
    • 计算:LEB 大小 = PEB 大小 - UBI 头开销。UBI 在每个物理擦除块头部占用少量字节(通常 2 个页或 64 字节)。 这里 PEB = 128 KiB,LEB ≈ 128 KiB - 开销 = 124 KiB,吻合。这个值由 ubinize 自动计算,一般无需手动改。

    minimum I/O unit size = 0x800

    • 含义:最小 I/O 单元大小,即 NAND Flash 的页大小(Page Size)。 0x800 = 2048 字节(2 KiB)。
    • 作用:UBIFS 和 UBI 必须按页对齐写入。常见 NAND 页大小为 2KiB、4KiB 或 8KiB。此值必须匹配硬件。

    maximum logical eraseblock count = 2000

    • 含义:UBIFS 文件系统中允许的最大逻辑擦除块数量。
    • 作用:限制 UBIFS 占用的最大体积。实际文件系统大小 = LEB 大小 × 最大 LEB 数量。 这里:124 KiB × 2000 ≈ 242 MiB。如果你的 UBI 分区大于此值,UBIFS 只会使用前 2000 个 LEB;如果分区更小,则自动缩小。 建议:将此值设置为分区 LEB 总数的上限,或留一些余量(过大会浪费内存用于元数据)。

    ubifs runtime compression = lzo

    • 含义:UBIFS 在运行时写入数据时使用的压缩算法,这里为 LZO。
    • 作用:当应用程序向文件系统写入文件时,UBIFS 会尝试用 LZO 压缩数据后再存入 Flash。可节约空间,但牺牲少量 CPU 性能。可选 none、lzo、zlib 等。

    Compression method = no compression

    • 含义:Buildroot 生成 UBIFS 镜像时(即制作根文件系统镜像阶段)采用的压缩方法。这里为 no compression(不压缩)。
    • 注意:这与“运行时压缩”是两个不同概念:
      • 镜像生成压缩:制作 ubifs.img 时是否压缩整个文件系统。如果选 lzo,生成的镜像体积小,但挂载时需要解压(只读,如 squashfs 方式)。UBIFS 通常不启用此选项,因为它本身支持运行时压缩。
      • 运行时压缩:文件系统运行时对写入文件实时压缩。
    • 为什么选 no compression:防止 Buildroot 在生成镜像时对内容做额外压缩,因为运行时压缩已经足够,且双重压缩无意义。

    rk-github编译流程-rootfs构建7

关闭kernel和uboot的编译(默认关闭)

-> Kernel
-> [ ] Linux Kernel //取消 Linux Kernel 选项!

-> Bootloaders
-> [ ] U-Boot //取消 U-Boot 选项!

执行编译

make -j32

中文支持

  • libbb/printable_string.c

    //注释 30行左右的
    
    //	if (c >= 0x7f)
    //		break;
    
    //修改40行左右的
    while (1) {
    	unsigned char c = *d;
    	if (c == '\0')
    		break;
    	// if (c < ' ' || c >= 0x7f) //这里
    	if (c < ' ')
    		*d = '?';
    	d++;
    }
    
  • libbb/unicode.c

    //修改1020行左右的
    while ((int)--width >= 0) {
    	unsigned char c = *src;
    	if (c == '\0') {
    		do
    			*d++ = ' ';
    		while ((int)--width >= 0);
    		break;
    	}
    	//*d++ = (c >= ' ' && c < 0x7f) ? c : '?'; //这里
    	*d++ = (c >= ' ') ? c : '?'; 
    	src++;
    }
    
    //修改1040行左右的
    while (*d) {
    	unsigned char c = *d;
    	//if (c < ' ' || c >= 0x7f) //这里
    	if (c < ' ')
    		*d = '?';
    	d++;
    }
    
  • 图形化配置界面

    Settings
    	-> [*] Support Unicode
    

编译失败修改

关闭yescrypt

Login/Password Management Utilities
	-> [] Enable yescrypt functions 

编译结果

bin/
sbin/
user/
linuxrc
mkdir dev etc lib mnt proc sys tmp var

文件移植

确保编译时没有静态编译

Settings
	-> [] Build static binary (no shared libs)

将交叉编译器的

  • etc

    • 创建init.d文件夹
    /etc/init.d
    
    • 创建init.d/rcS文件
    
    
  • lib

    cp -r /root/rk-tools/gcc-linaro-6.3.1-2017.05-x86_64_arm-linux-gnueabihf/arm-linux-gnueabihf/libc/lib/* . 
    

安装mtd-utils

mkfs.ubifs 的作用是将普通的文件夹转换成一个 UBIFS 格式的镜像文件。

apt-get update
apt-get install mtd-utils

生成可烧写的oem.img

  1. 创建oem文件夹

  2. 执行命令生成ubifs文件

    mkfs.ubifs -r oem -m 2048 -e 126976 -c 247 -o oem_ubifs.img
    
    • r <目录>:指定要打包的根目录(如 oem),该目录下的所有文件和权限都会被保留并打包。
    • -m <大小>:最小 I/O 单元大小(Minimum I/O unit size),单位字节。Page Size(页大小)
    • -e <大小>:逻辑擦除块大小(Logical Erase Block, LEB),单位字节。
      • 计算关系:-e 的值通常等于物理擦除块大小(PEB)减去 UBI 头部开销。例如,如果物理擦除块是 128KB (131072字节),UBI 开销通常占去 2个页大小,即 131072 - 2048*2 = 126976。
    • -c <数量>:文件系统可使用的最大逻辑擦除块数量(Max LEB count)。按 30 MiB = 30 × 1024 × 1024 字节。
      • c=(30×1024×1024)/126976≈247.8,向下取整得到 247 个 LEB。
    • -o <文件名>:指定输出的 UBIFS 镜像文件名。
  3. 封装 UBI 镜像(ubinize)

    ubinize 的作用是将上一步生成的 UBIFS 镜像,加上 UBI 层的卷(Volume)管理信息,封装成最终可以烧录到 NAND Flash 上的 .ubi 文件。

    • 创建配置文件 oem-ubinize.cfg

      [oem_volume]
      mode=ubi
      image=oem_ubifs.img
      vol_id=0
      vol_size=30MiB
      vol_type=dynamic
      vol_name=oem
      #vol_flags=autoresize
      
      • mode=ubi:指定工作模式为 UBI。

      • image=:指向第一步生成的 UBIFS 镜像文件。

      • vol_id=:卷的 ID 编号,必须唯一。例如 oem 设为 0,userdata 可以设为 1。

      • vol_size=:卷的大小。

        强烈建议直接写具体的数值(如 64MiB)。

        • 避坑指南:这个值不能大于你在 parameter.txt 中为该分区预留的物理空间大小,且建议预留 8~10MB 的余量给 UBI 自身的元数据开销,否则 ubinize 会报错提示空间不足。
      • vol_type=dynamic:卷类型。日常使用的读写分区(如 oem, userdata)都设为 dynamic;如果是只读分区可设为 static。

      • vol_name=:卷的名称。这个名字非常重要,后续在 /etc/fstab 中挂载时,就是通过 ubi0:oem 这样的名字来识别的。

      • vol_flags=autoresize:自动调整大小标志。如果设置该标志,文件系统会在第一次挂载时自动扩展到该卷允许的最大尺寸,非常适合 userdata 这种需要利用剩余空间的分区。

    • 执行封装命令

      ubinize -o oem.ubi -m 2048 -p 128KiB -s 512 oem-ubinize.cfg
      
      • -o <文件名>:指定最终生成的 UBI 镜像文件名(如 oem.ubi)。
      • -m <大小>:最小 I/O 单元大小,必须与 mkfs.ubifs 中的 -m 参数保持一致(即 NAND 的页大小)。
      • -p <大小>:物理擦除块大小(Physical Erase Block, PEB),单位支持 KiB/MiB。这个值必须严格匹配你的 NAND Flash 硬件手册中的 Block Size(擦除块大小),常见值为 128KiB 或 256KiB。
      • -s <大小>:子页大小(Sub-page size)。如果不确定,通常设为 512 或与 -m 相同即可。
  4. 生成oem.img

    直接修改oem.ubi名称就好了

    cp oem.ubi oem.img
    

生成可烧写的userdata.img

  1. 创建userdata文件夹

  2. 执行命令生成ubifs文件

    mkfs.ubifs -r userdata -m 2048 -e 126976 -c 247 -o userdata.img
    
  3. 封装 UBI 镜像(ubinize)

    • 创建配置文件 userdata-ubinize.cfg

      [userdata_volume]
      mode=ubi
      image=userdata_ubifs.img
      vol_id=0
      vol_size=30MiB
      vol_type=dynamic
      vol_name=userdata
      vol_flags=autoresize
      
    • 执行封装命令

      ubinize -o userdata.ubi -m 2048 -p 128KiB -s 512 userdata-ubinize.cfg
      
    1. 生成oem.img

      cp userdata.ubi userdata.img
      

只需要将rootfs.ubi重命名就好

cp rootfs.ubi rootfs.img

使用 Rockchip 工具打包

  1. afptool: 将各个分区镜像打包成一个中间文件(通常叫 package-file 或 firmware.img)。
  2. rkImageMaker: 给中间文件加上 Rockchip 专用的头部信息,生成最终的 update.img。

Rk sdk修改

交叉编译器:

  • 路径:tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/

sdk根目录的config目录

软链接,方便修改和查看配置?不需要自己去翻那么深层的目录。

  • buildroot_defconfig ---> sysdrv/.../configs/luckfox_pico_defconfig
  • dts_config -> sysdrv/source/kernel/arch/arm/boot/dts/rv1106-luckfox-pico-pro-max-ipc.dtsi
  • kernel_defconfig -> sysdrv/source/kernel/arch/arm/configs/luckfox_rv1106_linux_defconfig

模块目录

所有模块都放在这个地方。编译完成之后的模块会放到oem分区。

  • sysdrv/drv_ko

uboot配置文件:

  • 路径:sysdrv/source/uboot/u-boot/configs/luckfox_pico_defconfig

uboot使用的设备树:

  • 路径:sysdrv/source/uboot/u-boot/arch/arm/dts/rv1106-luckfox.dts

kernel配置文件:

  • 路径:sysdrv/source/kernel/arch/arm/configs/luckfox_rv1106_linux_defconfig

设备树文件:

  • 路径:sysdrv/source/kernel/arch/arm/boot/dts/rv1106g-luckfox-pico-pro-max.dts

buildroot文件:

  • 路径:sysdrv/source/buildroot/buildroot-2023.02.6/configs/luckfox_pico_defconfig

buildroot-overlay:

一、拿到新板子后要改的核心文件

按重要性和改动频率从高到低排列:

1. Device Tree (DTS) — 最核心,几乎必改

位置: sysdrv/source/kernel/arch/arm/boot/dts/

这是硬件描述文件,PCB 上每个外设都要在这里声明。你需要:

需求做法
GPIO 复用(哪个引脚做什么)改 pinctrl 节点
新增/换用 SPI/I2C/UART/PWM在 dts 里使能对应控制器,配引脚
换摄像头 sensor改 rockchip-cif + sensor 节点,更新 RK_CAMERA_SENSOR_IQFILES
改网口 / WiFi 模块配 gmac / sdio 节点
换屏幕改 display / backlight / panel 节点
改 DDR 类型或频率见下文 DDR 部分

推荐做法: 拷贝一个最接近你硬件的 .dts(比如 rv1106g-luckfox-pico-pro-max.dts),改成你板子的名字,然后:

  • 删掉你板上没有的外设
  • 加上你板上有但 Luckfox 没有的外设
  • 检查所有 GPIO 引脚号是否匹配 PCB 原理图

然后在对应 BoardConfig .mk 里把 RK_KERNEL_DTS 指向你的新 .dts。


2. BoardConfig.mk — 每块板子一个配置文件

位置: project/cfg/BoardConfig_IPC/

存放位置,把你的配置拷贝一份,改成你自己的名字:

cp BoardConfig-SPI_NAND-Buildroot-RV1106_Luckfox_Pico_Pro_Max-IPC.mk \
   BoardConfig-SPI_NAND-Buildroot-RV1106_My_Company_Board-IPC.mk

需要修改的关键项:

RK_KERNEL_DTS          → 指向你的新 dts
RK_PARTITION_CMD_IN_ENV → 按 flash 大小调整分区
RK_CAMERA_SENSOR_IQFILES → 换成你用的 sensor
LF_WIFI_SSID / LF_WIFI_PSK → 公司 WiFi
RK_POST_OVERLAY        → 你的自定义 overlay

最后 ln -sf 到 .BoardConfig.mk。


3. U-Boot Defconfig — 一般不动,除非换了启动介质

位置: sysdrv/source/uboot/u-boot/configs/luckfox_rv1106_uboot_defconfig

Luckfox 的 U-Boot 配置已经适配了 RV1106,大多数情况下直接用。需要改的情况:

  • 换了 SPI NAND / SPI NOR / eMMC / SD 卡启动 → 换 RK_UBOOT_DEFCONFIG_FRAGMENT
  • 换了 DDR 频率 → 改 RKBOOT/*.ini 里选的 DDR bin
  • 需要在 U-Boot 阶段加驱动(比如网卡)→ 改 defconfig

4. DDR 配置 — 换 RAM 芯片才需要动

位置: sysdrv/source/uboot/rkbin/

DDR 是 Rockchip 提供的闭源二进制 .bin。Luckfox 默认使用 rv1106_ddr_924MHz_v1.15.bin。

如果你公司板子换了 DDR 颗粒(比如 DDR3→DDR4、或改了频率),需要用 tools/ddrbin_param.txt + tools/ddrbin_tool 生成新的 DDR bin,然后修改 RKBOOT/RV1106MINIALL.ini 指向新 bin。


5. Kernel Defconfig — 加驱动才需要动

位置: sysdrv/source/kernel/arch/arm/configs/luckfox_rv1106_linux_defconfig

默认已经启用了常用的驱动。你如果需要:

  • 新的 USB 设备驱动 → make menuconfig 或直接改 defconfig
  • 额外的网络协议 / 文件系统
  • 开启/关闭某些内核特性

二、新板 bring-up 的标准流程

1.  PCB 工程师给原理图
      ↓
2.  选一个最接近的 Luckfox dts 拷贝为 xxx-my-board.dts
      ↓
3.  根据原理图改 pinctrl(GPIO复用)、regulator(电源)、
     i2c/spi/uart/pwm 等外设节点
      ↓
4.  编译内核:./build.sh kernel  → 出 dtb
      ↓
5.  如果能进系统了,逐个验证外设:
    - 串口 →  dmesg | grep tty
    - 网络 → ifconfig, ping
    - 摄像头 → v4l2-ctl --list-devices
    - 屏幕 → 看背光、fb 设备
      ↓
6.  调 ISP(摄像头画质),需要 IQ 文件调优
      ↓
7.  固件打包:./build.sh firmware
      ↓
8.  烧录验证

三、总结对照表

你要做什么改哪个文件
改 GPIO / 外设.dts (kernel)
换 flash / 调分区BoardConfig.mk
换摄像头 sensor.dts + RK_CAMERA_SENSOR_IQFILES
换 WiFi 模块.dts + kernel defconfig
改主机名/密码overlay 或 defconfig
换 DDR 颗粒rkbin/ DDR bin + INI
加内核驱动kernel defconfig
换启动方式U-Boot defconfig

luckfox-sdk的RV系列SOC是幸狐官方针对rk官方sdk进行修改的。可直接作为rk官方sdk进行参考。

luckfox提供了比较好的sdk,相比于在rk的github手动下载uboot,kernel,自己去构建rootfs还要从root,yocto,ubuntu等地方去下载,难度比较大。

  • 可以快速处理从SPL到rootfs过程。
  • 还能直接将生成的一堆img文件打包成一个update.img文件,不需要手动填写地址进行烧录。

luckfox编译流程

一、选择开发板

进入根目录执行

./build.sh lunch

按提示步骤

root@6e056017e4b4:~/luckfox-dev/luckfox-pico# ./build.sh lunch   
You're building on Linux
  Lunch menu...pick the Luckfox Pico hardware version:
  选择 Luckfox Pico 硬件版本:
                [0] RV1103_Luckfox_Pico
                [1] RV1103_Luckfox_Pico_Mini
                [2] RV1103_Luckfox_Pico_Plus
                [3] RV1103_Luckfox_Pico_WebBee
                [4] RV1106_Luckfox_Pico_Pro_Max
                [5] RV1106_Luckfox_Pico_Ultra
                [6] RV1106_Luckfox_Pico_Pi
                [7] RV1106_Luckfox_Pico_86Panel
                [8] RV1106_Luckfox_Pico_Zero
                [9] custom
Which would you like? [0~9][default:0]: 4
  Lunch menu...pick the boot medium:
  选择启动媒介:
                [0] SD_CARD
                [1] SPI_NAND
Which would you like? [0~1][default:0]: 1
  Lunch menu...pick the system version:
  选择系统版本:
                [0] Buildroot 
Which would you like? [0][default:0]: 0 

二、编译

编译uboot,kernel,rootfs,recovery image。既全量编译

./build.sh all

选择编译

./build.sh uboot

方式一、移除 overlay

编辑 .BoardConfig.mk 第 124 行,把 overlay-luckfox-buildroot-shadow 从 RK_POST_OVERLAY 中去掉:

overlay-luckfox-buildroot-shadow

export RK_POST_OVERLAY="overlay-luckfox-config overlay-luckfox-buildroot-init overlay-luckfox-wifibt-firmware

sysdrv/source/buildroot/buildroot-2023.02.6/configs/luckfox_pico_defconfig

修改

BR2_TARGET_GENERIC_HOSTNAME="wyl" #主机名称
BR2_TARGET_GENERIC_ISSUE="Welcome to wyl" #欢迎语
#BR2_TARGET_GENERIC_ROOT_PASSWD="luckfox" #原始密码
BR2_TARGET_GENERIC_ROOT_PASSWD="root" #密码

方式二、修改overlay中密码

project/cfg/BoardConfig_IPC/overlay/下的overlay-luckfox-buildroot-shadow/etc下

shadow文件

root:$1$dXmV8ZLO$eNAQzSYOgRkYMJRdsHwLS1:19664::::::
daemon:*:::::::
bin:*:::::::
sys:*:::::::
sync:*:::::::
mail:*:::::::
www-data:*:::::::
operator:*:::::::
nobody:*:::::::
  • openssl passwd -1 # 然后输入你要的密码

  • 将生成的 hash 替换 overlay 中 root 行的 $1$dXmV8ZLO$eNAQzSYOgRkYMJRdsHwLS1 部分。

方法三、在 shadow overlay 中直接写明文让你当前的密码生效

在 overlay 中放一个空的 /etc/shadow,让 Buildroot 生成的 shadow 文件不被覆盖。或者把 overlay 中的 shadow 文件内容改成空的 root 行,系统会在首次启动时用 Buildroot 的密码。

内容

mtdparts=spi-nand0:256K(env),256K@256K(idblock),512K(uboot),4M(boot),30M(oem),10M(userdata),210M(rootfs)
sys_bootargs= ubi.mtd=6 root=ubi0:rootfs rootfstype=ubifs rk_dma_heap_cma=66M
sd_parts=mmcblk0:16K@512(env),512K@32K(idblock),4M(uboot)

解析

.BoardConfig.mk第 38 行 — RK_PARTITION_CMD_IN_ENV

export RK_PARTITION_CMD_IN_ENV="256K(env),256K@256K(idblock),512K(uboot),4M(boot),30M(oem),10M(userdata),210M(rootfs)"

脚本 project/build.sh:1696-1716 根据 RK_BOOT_MEDIUM=spi_nand 拼上 mtdparts=spi-nand0: 前缀,然后写入文件:

# 第 1713 行
RK_PARTITION_ARGS="mtdparts=spi-nand0:$RK_PARTITION_CMD_IN_ENV"
# 第 1729 行
echo "${RK_PARTITION_ARGS}" >$ENV_CFG_FILE

第 2 行:sys_bootargs= ubi.mtd=6 root=ubi0:rootfs rootfstype=ubifs rk_dma_heap_cma=66M

这行由 4 个来源拼接而成:

参数来源脚本位置
ubi.mtd=6遍历分区列表时,rootfs 是第 6 个分区(0开始计数)[build.sh:1787]
root=ubi0:rootfs rootfstype=ubifsRK_PARTITION_FS_TYPE_CFG 中 rootfs 为 ubifs 类型[build.sh:2120]
rk_dma_heap_cma=66MBoardConfig 的 RK_BOOTARGS_CMA_SIZE="66M"[build.sh:2161-2162]

第 3 行:sd_parts=mmcblk0:16K@512(env)...

硬编码在 build_env() 函数中:

# build.sh 第 767 行
echo "sd_parts=mmcblk0:16K@512(env),512K@32K(idblock),4M(uboot)" >>$ENV_CFG_FILE

rk提供的一些rkbin示例

  • rkbin的会前往flash的0x0008 0000去拿uboot。

  • 然后放到RAM的0x0002 0000去启动。

由于生成env.img时修改了uboot的存放位置。所以uboot编译时会将这个地址进行改动

uboot基本配置不会进行修改。

但是会根据env.img文件拿到信息启动内核,以及将参数传递给内核。

256K(env),256K@256K(idblock),512K(uboot),4M(boot),20M(oem),10M(userdata),92M(rootfs)

rootfs从210M减少到90M左右

情况1:

​ 如果nand的块大小,页大小都不变,不需要修改nand的参数。既不需要修改编译rootfs时的配置情况。

​ 只需要修改env文件就好,即修改[.BoardConfig.mk]第 38 行:

export RK_PARTITION_CMD_IN_ENV="256K(env),256K@256K(idblock),512K(uboot),4M(boot),20M(oem),10M(userdata),92M(rootfs)"

情况2:

​ nand的块大小,页大小发生了变化,需要修改rootfs编译时的配置信息。

. 软件包管理 — 增删功能

Buildroot 的 defconfig[luckfox_pico_defconfig]控制哪些软件包编译进 rootfs。

常用类别:

类别示例包配置项
脚本语言python3, lua, perlBR2_PACKAGE_PYTHON3=y
网络服务openssh, dhcpcd, nginx, vsftpdBR2_PACKAGE_OPENSSH=y
调试工具strace, gdb, valgrind, tcpdump需要自己加
系统工具htop, nano, iperf3, iptablesBR2_PACKAGE_HTOP=y
USB gadget串口/网卡/摄像头/U盘 模拟内核配置,rootfs 配启动脚本
数据库sqlite, influxdb需要自己加
媒体库ffmpeg, gstreamerRockchip 有自己的 media 库,也可加通用库

添加方法:编辑 defconfig,加上 BR2_PACKAGE_xxx=y,然后重新编译 rootfs。

# 也可用 menuconfig 交互式选择
./build.sh buildrootconfig

2. 文件系统类型 — 可换其他的

当前用的是 ubifs,可以改 RK_PARTITION_FS_TYPE_CFG 切换:

类型特点适用场景
ubifs可读写、压缩、坏块管理需要写 rootfs(当前配置)
squashfs只读、高压缩率、挂载快稳定版固件,不可变系统
erofs只读、比 squashfs 更高效只读 rootfs(改进版)
jffs2可读写SPI NOR Flash 专用

例如切换为 squashfs 只读(更省空间,更稳定):

RK_PARTITION_FS_TYPE_CFG=rootfs@IGNORE@squashfs,oem@/oem@ubifs,userdata@/userdata@ubifs

同时 sys_bootargs 会自动从 root=ubi0:rootfs 变为 root=/dev/ubiblock0_0 rootfstype=squashfs。


3. 初始化和启动行为

现有的 init 脚本在 overlay 中,可以:

  • 添加新的开机服务:创建 S<数字><名称> 脚本
  • 修改开机启动顺序:调整数字大小
  • 自定义 /etc/inittab:修改串口登录行为、自动登录
  • 自定义 /etc/profile:改环境变量、PATH、别名
  • 自动登录 root:修改 /etc/inittab 或 /etc/securetty

现有 init 脚本位置:overlay-luckfox-buildroot-init/etc/init.d/


4. 摄像头/ISP 配置

通过 BoardConfig 中的 RK_CAMERA_SENSOR_IQFILES 和 RK_CAMERA_SENSOR_CAC_BIN:

export RK_CAMERA_SENSOR_IQFILES="sc4336_OT01_40IRC_F16.json sc3336_CMK-OT2119-PC1_30IRC-F16.json mis5001_CMK-OT2115-PC1_30IRC-F16.json"

IQ 文件会被打包到 OEM 分区,系统启动时 ISP 加载。

  • 换摄像头传感器:改 IQ 文件列表
  • 加多个摄像头:增加列表中的文件
  • 调图像质量:修改 IQ JSON 文件中的参数(AE、AWB、AF 等)

5. USB 功能模拟 (USB Gadget)

RV1106 的 USB 可以模拟多种设备。参考 S99usb0config:

  • USB 串口:模拟 USB 转串口,通过 USB 登录板子
  • USB 网卡(RNDIS/ECM/NCM):板子虚拟成 USB 网卡
  • USB 摄像头(UVC):板子采集摄像头通过 USB 输出视频流
  • USB 声卡(UAC):模拟 USB 音频设备
  • USB 大容量存储:模拟 U 盘

内核配置中这些功能已启用(CONFIG_USB_GADGET=y),rootfs 配启动脚本即可切换。


6. 网络功能增强

功能修改方式
WiFi 连接BoardConfig 中 LF_WIFI_SSID / LF_WIFI_PSK
4G/5G 模块内核有 rv1106-wwan-ndis-ppp.config 配置片段
有线网络设备树中启用 GMAC(当前 Pro Max 禁用)
蓝牙内核有 rv1106-bt.config 配置片段
热点模式用 hostapd + dhcpd
WireGuard VPN内核已支持(CONFIG_WIREGUARD=y)
Docker 容器内核有 luckfox_rv1106-docker.config 片段

7. 显示/屏幕

Pro Max 板子带 RGB 显示屏(640x480),可以:

  • 换显示分辨率:改设备树的 display-timings
  • 换屏幕型号:改 SPI 初始化命令序列
  • LVDS/MIPI DSI:如果硬件支持
  • framebuffer 应用:直接写 /dev/fb0
  • Qt/LVGL 等 GUI 框架:需要 Buildroot 中添加

8. NPU 推理

RV1106 内置 NPU(0.5TOPS),支持:

  • RKNN 模型推理:在 rootfs 中放 RKNN 模型文件和推理程序
  • TensorFlow Lite / ONNX:转成 RKNN 格式后运行
  • NPU 驱动已经在内核中启用
  • runtime 库在 media 编译中生成

9. 文件系统扩容与分区自定义

除了改分区大小,还可以:

  • 增加新分区:比如加一个 config 分区单独存配置
  • 修改分区文件系统:比如 userdata 用 ext4 而非 ubifs
  • OEM 分区存 app:RK_BUILD_APP_TO_OEM_PARTITION=y 把 app 和资源分开

10. 调试与开发

功能配置
串口调试默认 1500000 波特率
网络文件系统(NFS)内核支持,rootfs 挂载网络目录
交叉编译工具链SDK 自带
FIQ 调试器内核已启用,可通过串口中断

11. OTA 远程升级

Buildroot + rootfs 支持 OTA:

  • recovery 模式:独立分区做系统还原
  • 双备份启动(AB 系统):两个 rootfs 分区切换
  • update.img 可网络推送烧录

动手建议路线

简单 ──────────────────────────────────────────→ 复杂

改密码 → 加 init 脚本 → 加自己的 app
                         ↓
                 加软件包 (python/nano/htop)
                         ↓
                 换 squashfs 只读系统
                         ↓
                 摄像头调优 / 换传感器
                         ↓
                 USB Gadget 模式
                         ↓
                 加 NPU 推理 + OTA 升级

你想先做哪个方向?我可以给具体步骤和配置细节。

说到底,编译文件系统其实就是生成一堆文件和文件夹,同时引入一些依赖库。怎么打包成可以烧录的rootfs.img,再结合rk提供的构建工具将uboot,kernel,rootfs等打包成一个update.img整体

源代码/Buildroot包
       ↓
  目录树(bin, lib, etc, usr, ...)
       │
       │ mkfs.ubifs -d 目录 ...
       ↓
 rootfs.img (UBI 格式,含卷头)
       │
       │  + uboot.img + boot.img + oem.img + ...
       │  afptool -pack 目录 update_tmp.img
       │  rkImageMaker -RK1106 download.bin update_tmp.img update.img
       ↓
  update.img  ← 烧录工具最终认的文件
  1. buildroot从网上下载需要的包。

    这个步骤Buildroot 从 defconfig 读取包列表,下载源码

  2. 交叉编译以适配目标开发板。

    配置了交叉编译器、编译生成需要的文件、安装到 staging 目录

  3. 生成rootfs.img

    两个步骤:

    ① 目录 → UBIFS 镜像(sysdrv/tools/pc/mtd-utils/mkfs_ubi.sh)

    # 第 190 行:目录 → ubifs 裸镜像
    mkfs.ubifs -x lzo -e 0x1FC00 -m 2048 -c 468 -d rootfs_uclibc_rv1106/ -o rootfs.ubifs
      │          │       │        │       │         │
      │          │       │        │       │         └── 源目录
      │          │       │        │       └── 最大逻辑擦除块数 (分区大小/LEB)
      │          │       │        └── 最小 IO 大小 (NAND 页大小)
      │          │       └── LEB 大小 (块大小 - 2×页大小)
      │          └── lzo 压缩
      └── mkfs.ubifs 工具
    
    # 第 202 行:ubifs → UBI 镜像 (包含 UBI 头部)
    ubinize -o rootfs.img -m 2048 -p 0x20000 -v ubinize.cfg
    

    ubinize.cfg 描述了卷信息:

    [ubifs]
    mode=ubi
    vol_id=0
    vol_name=rootfs
    vol_type=dynamic
    vol_flags=autoresize
    image=rootfs.ubifs
    

    最终产出 rootfs.img,可以直接烧录到 NAND 的 rootfs 分区。

    ② 编译时用的是 build_mkimg()(project/build.sh:2332-2333):

    # 对于 ubifs 类型,直接调 mkfs_ubi.sh
    $RK_PROJECT_TOOLS_MKFS_UBIFS $src $dst $part_size $part_name $fs_type $comp
    

    其他文件系统也一样简单:

    文件系统命令
    ext4mkfs.ext4 -d 目录 镜像 大小
    squashfsmksquashfs 目录 镜像 -comp xz
    jffs2mkfs.jffs2 -d 目录 -o 镜像
    erofsmkfs.erofs 镜像 目录

    本质上都是把目录 + 元数据 + 压缩选项丢给一个 mkfs 工具。

  4. 使用afptool打包出update.img

    核心是 2 个工具tools/linux/Linux_Pack_Firmware/:

    ① afptool — 把多个子镜像打包成一个

    mk-update_pack.sh:223:

    afptool -pack 镜像目录 update_tmp.img
    

    afptool 读取 package-file 清单文件,它列出了每个分区的文件名:

    # package-file(自动生成)
    bootloader	download.bin
    env	        env.img
    uboot	        uboot.img
    boot	        boot.img
    oem	        oem.img
    rootfs	        rootfs.img
    userdata	userdata.img
    package-file	package-file
    

    afptool 把所有子镜像按分区顺序拼接成一个 update_tmp.img,并在头部写入每个分区的偏移和大小信息。

    ② rkImageMaker — 加上 Rockchip 头部

    mk-update_pack.sh:225:

    rkImageMaker -RK1106 download.bin update_tmp.img update.img -os_type:androidos
    

    rkImageMaker 在 update_tmp.img 前面加上:

    • 芯片标识(-RK1106)
    • download.bin(含 DDR 初始化 + SPL 的 loader)

    最终产出 update.img,这就是烧录工具(rkflash.sh / RKdevTool)识别的完整固件包。

一、开启某些功能支持

比如开启c++支持,adb支持。

  • 通过menuconfig进行界面配置
  • 通过直接修改配置文件开启某一项支持

二、集成自己的程序

有两种方法:

方法一:使用 overlay(推荐)

在 [project/cfg/BoardConfig_IPC/overlay/]下创建你自己的 overlay 目录,按根文件系统路径放置文件。

例如创建一个 overlay-myapp/ 目录结构:

project/cfg/BoardConfig_IPC/overlay/
└── overlay-myapp/
    ├── usr/
    │   └── bin/
    │       └── myapp              ← 你的应用程序
    └── etc/
        └── init.d/
            └── S99myapp           ← 开机启动脚本

然后在 .BoardConfig.mk 的 RK_POST_OVERLAY 中添加:

export RK_POST_OVERLAY="overlay-luckfox-config overlay-luckfox-buildroot-init overlay-luckfox-wifibt-firmware overlay-myapp"

方法二:直接放在 custom_root/(SDK 根目录)

SDK 根目录有 custom_root/(如没有则创建),该目录内容也会被拷贝到 rootfs:

custom_root/
└── usr/
    └── bin/
        └── myapp

这种方式不需要改 BoardConfig。

三、配置开机自启,配置一些脚本

/root/luckfox-dev/luckfox-pico/project/cfg/BoardConfig_IPC/overlay/overlay-luckfox-config/etc/init.d

参考现有的 S99python 脚本风格,创建一个启动脚本。命名规则:S<数字><名称>,数字越小越早执行。

示例 S99myapp:

#!/bin/sh

DAEMON=/usr/bin/myapp

start() {
    printf "Starting myapp: "
    if [ -f $DAEMON ]; then
        $DAEMON &
        echo "OK"
    else
        echo "FAIL: $DAEMON not found"
    fi
}

stop() {
    printf "Stopping myapp: "
    killall myapp
    echo "OK"
}

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart|reload)
        stop
        start
        ;;
    *)
        echo "Usage: $0 {start|stop|restart}"
        exit 1
esac

exit $?

注意事项:

  • 脚本要加可执行权限:chmod +x S99myapp
  • 如果 app 是后台守护进程,用 & 放到后台
  • 如果想开机启动后还能登录,不要用 exec,要用 &

验证

# 只重新打包固件(不需要重编整个 rootfs)
./build.sh firmware

Overlay 的文件会在 __PACKAGE_ROOTFS 阶段自动拷贝到 rootfs 中,然后打包成 rootfs.img。烧录后 app 就在板子上了,开机也会自动启动。

Rootfs及分区概念

SPI NAND
├── env            原始分区,不挂载
├── idblock        原始启动分区,不挂载
├── uboot          U-Boot镜像,不挂载
├── boot           Kernel/DTB/resource,不挂载
├── rootfs         UBIFS,挂载为 /
│   ├── bin
│   ├── etc
│   ├── usr
│   │   ├── bin
│   │   └── lib
│   ├── oem         挂载点
│   └── userdata    挂载点
├── oem            UBIFS,挂载到 /oem
├── userdata       UBIFS,挂载到 /userdata
└── logo           原始BMP分区,不挂载

Linux只有一棵目录树。rootfs首先挂载为根目录 /,然后 oem、userdata 再挂载到这棵目录树中的 /oem、/userdata。

/usr,/usr/bin,/usr/lib,/etc,/bin 全部属于 rootfs。

未挂载时,/oem、/userdata 仍然只是 rootfs 里的普通目录,写进去的数据会实际消耗并修改 rootfs;以后分区挂载成功,这些文件又会被“遮住”。

下载

https://buildroot.org/

不要使用root账户

#创建一个新账户
useradd -m builder
#使用root账户给builder用户分配解压的buildroot文件夹权限
chown -R builder:builder /home/builder/buildroot
#后面再使用builder用户
su builder

配置Target options

Target options
        -> Target Architecture = ARM (little endian)
        -> Target Binary Format = ELF
        -> Target Architecture Variant = cortex-A7
        -> Target ABI = EABIhf
        -> Floating point strategy = NEON/VFPv4
        -> ARM instruction set = ARM

配置Toolchain

Toolchain
   -> Toolchain type = External toolchain
   -> Toolchain = Custom toolchain //选择用户的交叉编译器
   -> Toolchain origin = Pre-installed toolchain
   -> Toolchain path =/root/gcc-linaro-6.5.0-2019.12-x86_64_arm-linux-gnueabihf //交叉编译器路径
   -> Toolchain prefix = arm-linux-gnueabihf //前缀
   -> External toolchain gcc version = 6.x
   -> External toolchain kernel headers series = 5.10.x
   -> External toolchain C library = glibc/eglibc
   -> [*] Toolchain has SSP support? (NEW) //选中
   -> [*] Toolchain has RPC support? (NEW) //选中
   -> [*] Toolchain has C++ support? //选中
   -> [*] Enable MMU support (NEW) //选中

配置System configuration

System configuration
   -> System hostname = Embedfire_imx6ull //平台名字
   -> System banner = Welcome to embedfire i.mx6ull //欢迎语
   -> Init system = BusyBox //使用 busybox
   -> /dev management = Dynamic using devtmpfs + mdev //使用 mdev
   -> [*] Enable root login with password (NEW) //使能登录密码
   -> Root password = root //登录密码为 root

配置Filesystem images

-> Filesystem images
   -> [*] ext2/3/4 root filesystem //如果是 EMMC 或 SD 卡的话就用 ext3/ext4
      -> ext2/3/4 variant = ext4 //选择 ext4 格式
-> [*] ubi image containing an ubifs root filesystem //如果使用 NAND 的话就用 ubifs

关闭kernel和uboot的编译(默认关闭)

-> Kernel
-> [ ] Linux Kernel //取消 Linux Kernel 选项!

-> Bootloaders
-> [ ] U-Boot //取消 U-Boot 选项!

执行编译

make

Rv1106笔记&流程

Jw board rv1106 v1 sdk(生产)

006. 脚本修改

UMS配置

需要内核开启UMS功能

CONFIG_USB_GADGET=y
CONFIG_USB_CONFIGFS=y
CONFIG_USB_CONFIGFS_MASS_STORAGE=y  #(UMS功能)

设置开机即为SD卡读取模式,即,电脑插入设备,设备会被当做读卡器,自动将sd里面映射为盘符。

/sysdrv/tools/board/android-tools/S50usbdevice
#也可以使用overlay进行覆盖

修改

UMS_EN=on
UMS_BLOCK=/dev/mmcblk1p1

开机自启脚本

start)
	ifconfig lo up
	if [ ! -e "/tmp/.usb_config" ]; then
		echo "$0: Cannot find .usb_config"
		# exit 0
		USB_CONFIG_FILE=/tmp/.usb_config
		echo "usb_adb_en" >> $USB_CONFIG_FILE   #这里的意思是配置为adb模式,我们改成usb_ums_en
	fi
echo "usb_adb_en" >> $USB_CONFIG_FILE
#这里的意思是配置为adb模式,我们改成
 echo "usb_ums_en" >> $USB_CONFIG_FILE

计划

#!/bin/sh

HUB_GPIO=0
PHY_MODE=/sys/devices/platform/ff3e0000.usb2-phy/otg_mode
DWC3_MODE=/sys/kernel/debug/usb/ffb00000.usb/mode

mount_debugfs() {
	mountpoint -q /sys/kernel/debug 2>/dev/null && return 0
	mount -t debugfs none /sys/kernel/debug 2>/dev/null
}

set_host() {
	echo "设置 USB Host 模式..."

	mount_debugfs

	# 导出 Hub GPIO
	[ ! -d /sys/class/gpio/gpio${HUB_GPIO} ] && echo "$HUB_GPIO" > /sys/class/gpio/export 2>/dev/null
	echo out > /sys/class/gpio/gpio${HUB_GPIO}/direction 2>/dev/null

	# 断开 Hub 防止切换期间毛刺
	echo 0 > /sys/class/gpio/gpio${HUB_GPIO}/value 2>/dev/null
	sleep 1

	# PHY → host
	[ -e "$PHY_MODE" ] && echo host > "$PHY_MODE" 2>/dev/null

	# DWC3 控制器 → host
	[ -e "$DWC3_MODE" ] && echo host > "$DWC3_MODE" 2>/dev/null

	# 轮询等待 xHCI 控制器就绪(替代 sleep 3)
	for i in 1 2 3 4 5 6 7 8 9 10; do
		[ -d /sys/bus/usb/devices/usb1 ] && break
		sleep 1
	done

	# 接通 Camera Hub,USB 设备开始枚举
	echo 1 > /sys/class/gpio/gpio${HUB_GPIO}/value 2>/dev/null
	echo "Hub 已接通,USB 枚举开始"
}

case "$1" in
	start)  set_host ;;
	stop)   ;;
	restart|reload)  set_host ;;
	*) echo "Usage: $0 {start|stop|restart}"
	   exit 1 ;;
esac
exit 0

  1. 精简 S98lvgld(删除冗余 USB 切换代码,保留 UVC 等待)
#!/bin/sh

APP="/usr/lvgl_demo"
APP_NAME="lvgl_demo"

start() {
    chmod 755 "$APP" 2>/dev/null

    # 导出 GPIO(应用启动前一次性导出,后续直接操作 /sys/class/gpio/gpioN/value)
    for gpio in 2 4 40 97; do
        [ -d "/sys/class/gpio/gpio$gpio" ] || echo "$gpio" > /sys/class/gpio/export 2>/dev/null
    done

    # 修复被展平的软链接(构建系统产物)
    for lib in librockchip_mpp.so.1 librockit_full.so librockit.so; do
        [ -f "/usr/$lib" ] && [ ! -L "/usr/$lib" ] && rm -f "/usr/$lib"
    done

    # oem 分区库优先
    LD_LIBRARY_PATH="/oem/usr/lib:${LD_LIBRARY_PATH}"
    export LD_LIBRARY_PATH

    # 触发一次 DRM lastclose,将 fbdev 模式提交到硬件 CRTC
    : < /dev/dri/card0 2>/dev/null

    echo "Starting ${APP_NAME}..."
    cd /usr
    "$APP" &
}

stop() {
    PID=$(pidof "$APP_NAME" 2>/dev/null)
    if [ -n "$PID" ]; then
        echo "Stopping ${APP_NAME} (pid $PID)..."
        kill $PID 2>/dev/null
        sleep 1
        REMAINING=$(pidof "$APP_NAME" 2>/dev/null)
        [ -n "$REMAINING" ] && kill -9 $REMAINING 2>/dev/null || true
    fi
    echo "${APP_NAME} stopped"
}

status() {
    PID=$(pidof "$APP_NAME" 2>/dev/null)
    if [ -n "$PID" ]; then
        echo "${APP_NAME} is running (pid $PID)"
        return 0
    fi
    echo "${APP_NAME} is not running"
    return 1
}

case "$1" in
    start)   start   ;;
    stop)    stop    ;;
    restart) stop; sleep 1; start ;;
    status)  status  ;;
    *) echo "Usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac

007. 项目开发

参考

注意:没有必要直接操作/dev/rtc0这个节点

:要用应用层的角度看待问题

rtc.h

/*
 * rtc.h — 实时时钟接口
 *
 * 读取 / 设置系统时间。底层走标准 C 库 time() / settimeofday(),
 * 设置时间后自动调用 hwclock -w 同步到硬件 RTC。
 * 不直接操作 /dev/rtc0 节点。
 */

#ifndef RTC_H
#define RTC_H

#include <time.h>

int  rtc_get_time(struct tm *t);
int  rtc_set_time(const struct tm *t);

#endif

rtc.c

#include "rtc.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int rtc_get_time(struct tm *t)
{
    time_t now = time(NULL);
    if (now == (time_t)-1) {
        perror("time");
        return -1;
    }
    if (localtime_r(&now, t) == NULL) {
        perror("localtime_r");
        return -1;
    }
    return 0;
}

int rtc_set_time(const struct tm *t)
{
    char cmd[128];
    snprintf(cmd, sizeof(cmd),
             "date -s '%04d-%02d-%02d %02d:%02d:%02d' 2>/dev/null",
             t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
             t->tm_hour, t->tm_min, t->tm_sec);

    if (system(cmd) != 0) {
        fprintf(stderr, "date -s failed\n");
        return -1;
    }

    if (system("hwclock -w 2>/dev/null") != 0) {
        /* hwclock 写入硬件 RTC 失败,不影响系统时间 */
    }
    return 0;
}

方式一、软件去做实现

方式二、通过S50脚本去切换ums和adb。(废弃)

01. Uboot修改

U-Boot Proper                                         

│    arch/arm/mach-rockchip/board.c                         │
│                                                          │
│    reset → _main → board_init_f() → relocate →           │
│    board_init_r()                                         │
│                                                          │
│    board_init() ← Rockchip 公共实现                        │
│      ├─ board_debug_uart_init()                           │
│      ├─ early_download() 检测下载按键                      │
│      ├─ clks_probe()     时钟驱动                         │
│      ├─ regulators_enable_boot_on() 电源                  │
│      ├─ io_domain_init()                                 │
│      └─ rk_board_init() ← evb_rv1106.c 没重写             │
│                                                          │
│    board_late_init() ← 环境变量、启动参数                   │
│      ├─ rockchip_set_ethaddr()   MAC 地址                 │
│      ├─ rockchip_set_serialno()  序列号                   │
│      ├─ setup_boot_mode()  正常/恢复/下载                  │
│      ├─ env_fixup()  环境变量调整                          │
│      └─ rk_board_late_init() ← evb_rv1106.c 没重写         │
│                                                          │
│    进入命令行/bootcmd:                                     │
│      boot_fit; boot_android ${devtype} ${devnum};          │
│    → 加载 kernel/DTB → bootm → 启动 Linux            

解析

uboot核心层

  1. _main 函数
  2. board_init_f_alloc_reserve,board_init_f_init_reserve (两步)
  3. board_init_f 比较重要
  4. 省略一堆
  5. relocate 比较重要
  6. board_init_r 比较重要

板级层

uboot配置中

CONFIG_ARCH_ROCKCHIP=y

arch/arm/Makefile:80:

machine-$(CONFIG_ARCH_ROCKCHIP) += rockchip
machdirs := $(patsubst %,arch/arm/mach-%/,$(machine-y))
# → machdirs = "arch/arm/mach-rockchip/"
  • RK芯片的默认入口就是这个文件
/uboot/arch/arm/mach-rockchip/board.c
  • 这个文件下有很多启动时会调用的函数,一般不会在这里进行修改

board.c到vb_rv1106.c使用__weak 弱函数实现分层架构,两个 .c 文件都生成 .o,链接器按"强符号覆盖弱符号"规则决定最终用哪个函数

  • 最后会走到某一个板子的目录下
/uboot/board/rockchip/evb_rv1106/evb_rv1106.c
  • 一般在这个目录下做板级修改。比如开机管理等。

uboot凭什么用我们自己的板子

以rv1106为例

/uboot/board/rockchip/evb_rv1106/Kconfig #Kconfig选项
/uboot/board/rockchip/evb_rv1106/Makefile #配置了编译对象 obj-y	+= evb_rv1106.o
/uboot/board/rockchip/evb_rv1106/evb_rv1106.c #源码

在Kconfig配置了

if TARGET_EVB_RV1106

config SYS_BOARD
	default "evb_rv1106"

config SYS_VENDOR
	default "rockchip"

config SYS_CONFIG_NAME
	default "evb_rv1106"

config BOARD_SPECIFIC_OPTIONS # dummy
	def_bool y

endif

又在uboot配置文件开启了

CONFIG_TARGET_EVB_RV1106=y

所以通过SYS_BOARD ,SYS_VENDOR,SYS_CONFIG_NAME拼接

添加自己的板子

  1. 在 board/rockchip/ 下新建目录(如 board/rockchip/myboard/)
  2. 新建 Kconfig,定义 TARGET_MYBOARD,设置 SYS_BOARD/SYS_VENDOR
  3. 新建 include/configs/myboard.h
  4. 在 arch/arm/mach-rockchip/rv1106/Kconfig 中添加 source "board/rockchip/myboard/Kconfig"
  5. 新建 config-uboot/myboard_defconfig,设置 CONFIG_TARGET_MYBOARD=y

充电功能

流程

rk_board_late_init()
  ├── 有 USB → show_charge_logo()
  │     ├── 初始化 GPIO0_A2(输出低) / A3(输入)
  │     ├── 轮询 5 秒间隔:
  │     │   ├── USB 拔出 → 退出循环(硬件掉电)
  │     │── 按键按下 → 进入 2 秒等待
  │     │   ├── 2 秒内松开 → 取消,继续轮询
  │     │   ├── 2 秒内 USB 拔出 → 退出
  │     │   └── 按住满 2 秒 → 拉高 A2 → 启动内核
  │     │   └── 充电状态变化 → 打印
  │
  └── 无 USB → return 0(正常启动内核)

需要修改以下文件

/uboot/board/rockchip/evb_rv1106/evb_rv1106.c

添加测试

int rk_board_init(void) //一般不用这个,这个函数只做一些简单的功能
{
	printf("\n[test] =====================================\n");
	printf("[test] U-Boot 已修改!\n");
	printf("[test] =====================================\n\n");
	return 0;
}
int rk_board_late_init(void) //用这个
{
	printf("\n[test] =====================================\n");
	printf("[test] U-Boot 已修改!\n");
	printf("[test] =====================================\n\n");
	return 0;
}

evb_rv1106.c

/*
 * SPDX-License-Identifier:     GPL-2.0+
 *
 * (C) Copyright 2022 Rockchip Electronics Co., Ltd
 */

#include <common.h>
#include <asm/io.h>
#include <dwc3-uboot.h>
#include <usb.h>
#include <video_rockchip.h>

DECLARE_GLOBAL_DATA_PTR;

/*
 * ==================== USB VBUS 检测 ====================
 * USB_VBUSDET (SoC引脚25) → USB 2.0 PHY → utmi_bvalid
 * 寄存器: PERI_GRF (0xff000000) + 0x60 bit 9
 * 来源: drivers/phy/phy-rockchip-inno-usb2.c
 *
 * ==================== 电源按键 ====================
 * GPIO0_A3 (SoC引脚65) = 电源按键, 按下为低电平
 * GPIO0_A2 (SoC引脚64) = 电源保持, 拉高后维持供电
 * 对应 DT uboot-charge 节点:
 *   power-key-gpios  = <&gpio0 RK_PA3 GPIO_ACTIVE_LOW>
 *   power-hold-gpios = <&gpio0 RK_PA2 GPIO_ACTIVE_HIGH>
 *
 * GPIO3_B7 (SoC引脚124) = 充电芯片 NSTDBY, 低电平=充满
 */
#define PERI_GRF_BASE		0xff000000
#define USB_UTMI_BVALID_OFF	0x0060
#define USB_UTMI_BVALID_BIT	BIT(9)

#define GPIO0_BASE		0xff380000
#define GPIO0_DR_L		0x0000
#define GPIO0_DDR_L		0x0008
#define GPIO0_EXT_PORT		0x0070
#define GPIO0_A2		2
#define GPIO0_A3		3
#define GPIO0_A2_MASK		(1 << GPIO0_A2)
#define GPIO0_A3_MASK		(1 << GPIO0_A3)

#define PMU_IOC_BASE		0xff388000
#define GPIO0A_IOMUX_SEL_L	0x00
#define GPIO0_A2_IOMUX_BIT	8	/* bits[11:8] */
#define GPIO0_A3_IOMUX_BIT	12	/* bits[15:12] */

#define GPIO1_BASE		0xff530000
#define GPIO1_B0_MASK		BIT(8)

/* V2 GPIO 读写宏(与 drivers/gpio/rk_gpio.c 一致) */
#define gpio_v2_read(addr)	(readl(addr) & 0xFFFF)
#define gpio_v2_write(addr, val)	writel((val) | 0xFFFF0000, addr)

static int check_usb_powered(void)
{
	u32 val = readl(PERI_GRF_BASE + USB_UTMI_BVALID_OFF);
	return (val & USB_UTMI_BVALID_BIT) ? 1 : 0;
}

/* 配置电源按键和电源保持引脚 */
static void power_gpio_init(void)
{
	/* IOMUX: GPIO0_A2/A3 设为 GPIO 功能(function 0)*/
	writel((0xFF << (GPIO0_A2_IOMUX_BIT + 16)) |
	       (0 << GPIO0_A2_IOMUX_BIT) |
	       (0 << GPIO0_A3_IOMUX_BIT),
	       PMU_IOC_BASE + GPIO0A_IOMUX_SEL_L);

	/* DDR: A3=输入(按键), A2=输出(电源保持), 初始低 */
	u32 ddr = gpio_v2_read(GPIO0_BASE + GPIO0_DDR_L);
	ddr |= GPIO0_A2_MASK;		/* A2 = output */
	ddr &= ~GPIO0_A3_MASK;		/* A3 = input */
	gpio_v2_write(GPIO0_BASE + GPIO0_DDR_L, ddr);

	/* DR: A2 = low(不供电)*/
	u32 dr = gpio_v2_read(GPIO0_BASE + GPIO0_DR_L);
	dr &= ~GPIO0_A2_MASK;
	gpio_v2_write(GPIO0_BASE + GPIO0_DR_L, dr);
}

/* 拉高 LCD 背光(GPIO1_B0)*/
static void backlight_init(void)
{
	u32 ddr = gpio_v2_read(GPIO1_BASE + GPIO0_DDR_L);
	ddr |= GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DDR_L, ddr);

	u32 dr = gpio_v2_read(GPIO1_BASE + GPIO0_DR_L);
	dr |= GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DR_L, dr);
	printf("[LCD] Backlight GPIO1_B0 on\n");
}

/* 拉低 LCD 背光(GPIO1_B0)*/
static void backlight_off(void)
{
	u32 dr = gpio_v2_read(GPIO1_BASE + GPIO0_DR_L);
	u32 ddr;

	/* Latch low before switching to output, avoiding a visible high pulse. */
	dr &= ~GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DR_L, dr);

	ddr = gpio_v2_read(GPIO1_BASE + GPIO0_DDR_L);
	ddr |= GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DDR_L, ddr);
}

/* 拉高 A2 保持供电 */
static void power_hold_on(void)
{
	u32 dr = gpio_v2_read(GPIO0_BASE + GPIO0_DR_L);
	dr |= GPIO0_A2_MASK;
	gpio_v2_write(GPIO0_BASE + GPIO0_DR_L, dr);
	printf("[PWR] Power hold enabled\n");
}

/* 读按键状态: 按下=低电平, 返回 1=按下 */
static int power_key_pressed(void)
{
	u32 ext = readl(GPIO0_BASE + GPIO0_EXT_PORT);
	return (ext & GPIO0_A3_MASK) ? 0 : 1;
}

static const char *charge_bmps[] = {
	"battery_0.bmp",
	"battery_1.bmp",
	"battery_2.bmp",
	"battery_3.bmp",
	"battery_4.bmp",
	"battery_5.bmp",
	// "battery_fail.bmp",
};

#define CHARGE_BMP_COUNT  ARRAY_SIZE(charge_bmps)
#define CHARGE_BMP_PERIOD 600	/* ms, 同 charge_animation.c */
#define CHARGE_FRAME_SETTLE_MS 30	/* > 2 frames at 70 Hz */

/*
 * Load and decode every frame while the backlight is off.  Subsequent calls
 * only switch the VOP to an already cached buffer, so storage I/O and decode
 * work cannot disturb scanout in the animation loop.
 */
static int prepare_charge_bmps(void)
{
	int i, ret;

	for (i = 0; i < CHARGE_BMP_COUNT; i++) {
		ret = rockchip_show_bmp(charge_bmps[i]);
		if (ret)
			printf("[LCD] Failed to preload %s: %d\n",
			       charge_bmps[i], ret);
	}

	ret = rockchip_show_bmp(charge_bmps[0]);
	if (ret)
		return ret;
	mdelay(CHARGE_FRAME_SETTLE_MS);

	return 0;
}

/*
 * 充电动画+休眠:
 *   USB 供电时进入, 轮播电池图, 5s 无操作灭屏休眠
 *   按键唤醒 → 重新 5s 倒计时
 *   按键 2 秒长按 → 拉高 A2 → 退出启动内核
 *   USB 拔出 → 直接掉电
 */
static void show_charge_logo(void)
{
	int i, idx = 0, ret;
	u32 key_start;
	bool screen_on = false;
	u32 last_activity;

	printf("\n[CHARGE] USB Powered - Press power button 2s to boot\n");

	power_gpio_init();
	backlight_off();
	ret = prepare_charge_bmps();
	if (!ret) {
		backlight_init();
		screen_on = true;
	} else {
		printf("[LCD] Charge images unavailable: %d\n", ret);
	}
	last_activity = get_timer(0);

	while (1) {
		for (i = 0; i < CHARGE_BMP_PERIOD / 10; i++) {
			mdelay(10);

			if (!check_usb_powered())
				goto exit;

			if (screen_on && get_timer(last_activity) > 5000) {
				backlight_off();
				screen_on = false;
			}

			if (power_key_pressed()) {
				last_activity = get_timer(0);

				/* 休眠唤醒 */
				if (!screen_on) {
					ret = rockchip_show_bmp(charge_bmps[idx]);
					if (!ret) {
						mdelay(CHARGE_FRAME_SETTLE_MS);
						backlight_init();
						screen_on = true;
					}
				}

				key_start = get_timer(0);
				printf("[PWR] Power key pressed, hold 2s to boot...\n");

				while (get_timer(key_start) < 2000) {
					mdelay(10);

					if (!check_usb_powered())
						goto exit;

					if (!power_key_pressed()) {
						printf("[PWR] Key released, cancel\n");
						last_activity = get_timer(0);
						break;
					}
				}

				if (power_key_pressed()) {
					power_hold_on();
					goto exit;
				}
			}
		}

		if (screen_on) {
			idx = (idx + 1) % CHARGE_BMP_COUNT;
			rockchip_show_bmp(charge_bmps[idx]);
		}
	}

exit:
	// rockchip_show_logo();
	printf("[CHARGE] Charge loop exit, booting...\n");
}

/* 在 board_late_init 画 logo 之前把背光拉灭, 避免 USB 供电开机时 logo 闪一帧 */
int rk_board_init(void)
{
	backlight_off();
	return 0;
}

/* USB 供电时禁止 uboot 绘制 logo, 避免闪屏 */
int rk_board_show_uboot_logo(void)
{
	return !check_usb_powered();
}

	int rk_board_late_init(void)
{
	
	if (check_usb_powered()) {
		printf("[CHARGE] USB Power detected!\n");
		/* 充电循环会在首帧稳定后自行开背光 */
		show_charge_logo();
		
	} else {
		/* 电池启动:立即拉高电源保持引脚以维持供电 */
		power_gpio_init();
		power_hold_on();

		backlight_init();
	}

	// /* 初始化显示并显示 logo */
	// rockchip_show_logo();

	return 0;
}

# LH24030C50 (ST7789V) 显示屏的 U-Boot 配置片段
# 基础配置: rv1106_defconfig
# 用法: ./make.sh rv1106-lcd.config

CONFIG_BASE_DEFCONFIG="rv1106_defconfig"

# ========== 视频驱动核心 ==========
CONFIG_DM_VIDEO=y

# ========== Display Uclass ==========
# rockchip_rgb 需要 UCLASS_DISPLAY
CONFIG_DISPLAY=y

# ========== DRM 核心 + RGB 并口 ==========
CONFIG_DRM_ROCKCHIP=y          # 自动选中 PHY, VIDEO_BRIDGE
CONFIG_DRM_ROCKCHIP_RGB=y      # 自动选中 DRM_ROCKCHIP_PANEL 不需要手动打开CONFIG_DRM_ROCKCHIP_PANEL
CONFIG_DRM_PANEL_LH24030C50=y  # 我们的 LH24030C50 面板驱动
CONFIG_DRM_MEM_RESERVED_SIZE_MBYTES=32

# ========== 帧缓冲颜色深度 ==========
CONFIG_VIDEO_BPP32=y

# ========== GPIO 背光 ==========
CONFIG_BACKLIGHT_GPIO=y
#CONFIG_BACKLIGHT_PWM is not set

# ========== 电压 regulator(驱动链接需要) ==========
CONFIG_DM_REGULATOR=y

# ========== SPI Flash 启动 ==========
CONFIG_ROCKCHIP_SFC_IOMUX=y

VOP 显示输出模块 → rgb_in_vop 接口 → RGB控制器(rgb节点) → rgb_out_panel端点 → panel_in_rgb端点 → LCD屏panel

这个是内核设备树,uboot拿的是内核设备树来使用

#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/pinctrl/rockchip.h>
#include <dt-bindings/display/media-bus-format.h>
#include <dt-bindings/clock/rv1106-cru.h>

/ {
	backlight: backlight {
		status = "okay";
		compatible = "gpio-backlight";
		gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_HIGH>;
		default-on;
		power-supply = <&vcc_3v3>;
	};

	panel: panel {
		compatible = "lh,lh24030c50", "simple-panel"; 
		reset-gpios = <&gpio1 RK_PB1 GPIO_ACTIVE_LOW>; //内核不用,uboot用到
		spi-scl-gpios = <&gpio4 RK_PA7 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		spi-sdi-gpios = <&gpio4 RK_PA1 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		spi-cs-gpios = <&gpio4 RK_PA5 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		bus-format = <MEDIA_BUS_FMT_RGB666_1X18>;
		bpc = <6>;
		backlight = <&backlight>;
		power-supply = <&vcc_3v3>;
		status = "okay";

		display-timings {
			native-mode = <&timing0>;

			timing0: panel-timing {
				clock-frequency = <7000000>;
				hactive = <240>;
				vactive = <320>;
				hfront-porch = <38>;
				hback-porch = <10>;
				hsync-len = <10>;
				vfront-porch = <8>;
				vback-porch = <4>;
				vsync-len = <4>;
				hsync-active = <0>;
				vsync-active = <0>;
				de-active = <1>;
				pixelclk-active = <0>;
			};
		};

		port {
			panel_in_rgb: endpoint {
				remote-endpoint = <&rgb_out_panel>;
			};
		};
	};

	reserved-memory {
		#address-cells = <1>;
		#size-cells = <1>;
		ranges;

		drm_logo: drm-logo@00000000 {
			compatible = "rockchip,drm-logo";
			reg = <0x0 0x0>;
		};
	};
};

&display_subsystem {
	status = "okay";
	logo-memory-region = <&drm_logo>;

	/* U-Boot route node: 内核忽略此节点,仅 U-Boot 使用 */
	route {
		route_rgb: route-rgb {
			status = "okay";
			connect = <&vop_out_rgb>;
			logo,uboot = "logo.nologo";
			logo,kernel = "logo.bmp";
			logo,mode = "center";
			charge_logo,mode = "center";
		};
	};
};

&rgb {
	status = "okay";
	pinctrl-names = "default";
	pinctrl-0 = <&lcd_pins>;
	ports {
		rgb_out: port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			rgb_out_panel: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&panel_in_rgb>;
			};
		};
	};
};

&rgb_in_vop {
	status = "okay";
};

&route_rgb {
	status = "okay";
};
&vop {
	assigned-clocks = <&cru PLL_CPLL>;
	assigned-clock-rates = <216000000>;
	status = "okay";
};

路径:/u-boot/drivers/video/drm/panel-lh24030c50.c

// SPDX-License-Identifier: GPL-2.0+
/*
 * LH24030C50 RGB LCD panel driver based on the ST7789V controller.
 *
 * GPIO bitbang SPI for panel initialization, RGB interface for display data.
 */

#include <common.h>
#include <dm.h>
#include <errno.h>

#include <backlight.h>
#include <asm/gpio.h>
#include <power/regulator.h>
#include <linux/media-bus-format.h>
#include <drm_modes.h>

#include "rockchip_display.h"
#include "rockchip_panel.h"

struct lh24030c50_priv {
	struct gpio_desc reset;
	struct gpio_desc sclk;
	struct gpio_desc mosi;
	struct gpio_desc cs;
	struct udevice *power_supply;
	struct udevice *backlight;
	bool prepared;
	bool enabled;
};

/*
 * 9-bit SPI bitbang: DC bit + 8 data bits, MSB first.
 * CS active low, SCLK idles low, data clocked on rising edge.
 */
static void lh24030c50_spi_xfer(struct lh24030c50_priv *priv, int dc, u8 data)
{
	int i;

	dm_gpio_set_value(&priv->cs, 0);
	udelay(2);

	/* DC bit */
	dm_gpio_set_value(&priv->sclk, 0);
	dm_gpio_set_value(&priv->mosi, dc ? 1 : 0);
	udelay(2);
	dm_gpio_set_value(&priv->sclk, 1);
	udelay(2);

	/* 8 data bits, MSB first */
	for (i = 0; i < 8; i++) {
		dm_gpio_set_value(&priv->sclk, 0);
		dm_gpio_set_value(&priv->mosi, (data >> (7 - i)) & 1);
		udelay(2);
		dm_gpio_set_value(&priv->sclk, 1);
		udelay(2);
	}

	dm_gpio_set_value(&priv->sclk, 0);
	udelay(1);
	dm_gpio_set_value(&priv->cs, 1);
	udelay(1);
}

static void lh24030c50_write_cmd(struct lh24030c50_priv *priv, u8 cmd)
{
	lh24030c50_spi_xfer(priv, 0, cmd);
}

static void lh24030c50_write_data(struct lh24030c50_priv *priv, u8 data)
{
	lh24030c50_spi_xfer(priv, 1, data);
}

static void lh24030c50_spi_idle(struct lh24030c50_priv *priv)
{
	dm_gpio_set_value(&priv->cs, 1);
	dm_gpio_set_value(&priv->sclk, 0);
	dm_gpio_set_value(&priv->mosi, 0);
}

/*
 * ST7789V LCD initialization sequence.
 * This matches the kernel panel-lh24030c50.c LCD_Init().
 */
static void lh24030c50_lcd_init(struct lh24030c50_priv *priv)
{
	lh24030c50_spi_idle(priv);

	/* Software Reset */
	lh24030c50_write_cmd(priv, 0x01);
	mdelay(5);

	/* Sleep Out */
	lh24030c50_write_cmd(priv, 0x11);
	mdelay(120);

	/* MADCTL: RGB mode */
	lh24030c50_write_cmd(priv, 0x36); //控制 RGB/BGR、屏幕镜像、扫描方向
	lh24030c50_write_data(priv, 0x00);

	/* COLMOD: 18-bit/pixel (RGB666) */
	lh24030c50_write_cmd(priv, 0x3a);
	lh24030c50_write_data(priv, 0x06);

	/* RAMCTRL */
	lh24030c50_write_cmd(priv, 0xB0);
	lh24030c50_write_data(priv, 0x11); /* RM=1, DM=01 (RGB) */
	lh24030c50_write_data(priv, 0xC0); /* Required fixed bits, EPF=00 */

	/* Porch setting */
	lh24030c50_write_cmd(priv, 0xB1);
	lh24030c50_write_data(priv, 0x40);
	lh24030c50_write_data(priv, 0x04);
	lh24030c50_write_data(priv, 0x0a);

	/* Frame Rate Control */
	lh24030c50_write_cmd(priv, 0xB2);
	lh24030c50_write_data(priv, 0x0C);
	lh24030c50_write_data(priv, 0x0C);
	lh24030c50_write_data(priv, 0x00);
	lh24030c50_write_data(priv, 0x33);
	lh24030c50_write_data(priv, 0x33);

	/* Gate Control */
	lh24030c50_write_cmd(priv, 0xB7);
	lh24030c50_write_data(priv, 0x35);

	/* VCOM Setting */
	lh24030c50_write_cmd(priv, 0xBB);
	lh24030c50_write_data(priv, 0x2B);

	/* Power Control 1 */
	lh24030c50_write_cmd(priv, 0xC0);
	lh24030c50_write_data(priv, 0x2C);

	/* Power Control 2 */
	lh24030c50_write_cmd(priv, 0xC2);
	lh24030c50_write_data(priv, 0x01);

	/* Power Control 3 */
	lh24030c50_write_cmd(priv, 0xC3);
	lh24030c50_write_data(priv, 0x11);

	/* Power Control 4 */
	lh24030c50_write_cmd(priv, 0xC4);
	lh24030c50_write_data(priv, 0x20);

	/* VCOM Control 1 */
	lh24030c50_write_cmd(priv, 0xC6);
	lh24030c50_write_data(priv, 0x0F);

	/* Power Control A */
	lh24030c50_write_cmd(priv, 0xD0);
	lh24030c50_write_data(priv, 0xA4);
	lh24030c50_write_data(priv, 0xA1);

	/* Positive Gamma Correction */
	lh24030c50_write_cmd(priv, 0xE0);
	lh24030c50_write_data(priv, 0xD0);
	lh24030c50_write_data(priv, 0x00);
	lh24030c50_write_data(priv, 0x06);
	lh24030c50_write_data(priv, 0x09);
	lh24030c50_write_data(priv, 0x0B);
	lh24030c50_write_data(priv, 0x2A);
	lh24030c50_write_data(priv, 0x3C);
	lh24030c50_write_data(priv, 0x55);
	lh24030c50_write_data(priv, 0x4B);
	lh24030c50_write_data(priv, 0x08);
	lh24030c50_write_data(priv, 0x16);
	lh24030c50_write_data(priv, 0x14);
	lh24030c50_write_data(priv, 0x19);
	lh24030c50_write_data(priv, 0x20);

	/* Negative Gamma Correction */
	lh24030c50_write_cmd(priv, 0xE1);
	lh24030c50_write_data(priv, 0xD0);
	lh24030c50_write_data(priv, 0x00);
	lh24030c50_write_data(priv, 0x06);
	lh24030c50_write_data(priv, 0x09);
	lh24030c50_write_data(priv, 0x0B);
	lh24030c50_write_data(priv, 0x29);
	lh24030c50_write_data(priv, 0x36);
	lh24030c50_write_data(priv, 0x54);
	lh24030c50_write_data(priv, 0x4B);
	lh24030c50_write_data(priv, 0x0D);
	lh24030c50_write_data(priv, 0x16);
	lh24030c50_write_data(priv, 0x14);
	lh24030c50_write_data(priv, 0x21);
	lh24030c50_write_data(priv, 0x20);

	lh24030c50_write_cmd(priv, 0x21); // 开启像素反转,解决黑白颠倒
}

static void lh24030c50_prepare(struct rockchip_panel *panel)
{
	struct lh24030c50_priv *priv = dev_get_priv(panel->dev);

	if (priv->prepared)
		return;

	/* Enable power supply */
	if (priv->power_supply)
		regulator_set_enable(priv->power_supply, true);

	/* Reset sequence: assert for 10ms, then deassert */
	dm_gpio_set_value(&priv->reset, 1);
	mdelay(10);
	dm_gpio_set_value(&priv->reset, 0);
	mdelay(10);

	/* SPI idle then run LCD init */
	lh24030c50_spi_idle(priv);
	mdelay(5);

	lh24030c50_lcd_init(priv);

	priv->prepared = true;
}

static void lh24030c50_unprepare(struct rockchip_panel *panel)
{
	struct lh24030c50_priv *priv = dev_get_priv(panel->dev);

	if (!priv->prepared)
		return;

	/* Sleep In */
	lh24030c50_write_cmd(priv, 0x10);
	mdelay(120);

	dm_gpio_set_value(&priv->reset, 1);
	lh24030c50_spi_idle(priv);

	if (priv->power_supply)
		regulator_set_enable(priv->power_supply, false);

	priv->prepared = false;
}

static void lh24030c50_enable(struct rockchip_panel *panel)
{
	struct lh24030c50_priv *priv = dev_get_priv(panel->dev);

	if (priv->enabled)
		return;

	/* Turn display on */
	lh24030c50_write_cmd(priv, 0x29);

	/* Enable backlight */
	// if (priv->backlight)
	// 	backlight_enable(priv->backlight);

	priv->enabled = true;
}

static void lh24030c50_disable(struct rockchip_panel *panel)
{
	struct lh24030c50_priv *priv = dev_get_priv(panel->dev);

	if (!priv->enabled)
		return;

	/* Disable backlight */
	if (priv->backlight)
		backlight_disable(priv->backlight);

	/* Display Off */
	lh24030c50_write_cmd(priv, 0x28);

	priv->enabled = false;
}

static const struct rockchip_panel_funcs lh24030c50_funcs = {
	.prepare   = lh24030c50_prepare,
	.unprepare = lh24030c50_unprepare,
	.enable    = lh24030c50_enable,
	.disable   = lh24030c50_disable,
};

static int lh24030c50_probe(struct udevice *dev)
{
	struct lh24030c50_priv *priv = dev_get_priv(dev);
	struct rockchip_panel *panel;
	int ret;

	/* Reset GPIO (active low) */
	ret = gpio_request_by_name(dev, "reset-gpios", 0, &priv->reset,
				   GPIOD_IS_OUT);
	if (ret) {
		printf("%s: Cannot get reset GPIO: %d\n", __func__, ret);
		return ret;
	}

	/* SPI bitbang GPIOs */
	ret = gpio_request_by_name(dev, "spi-scl-gpios", 0, &priv->sclk,
				   GPIOD_IS_OUT);
	if (ret) {
		printf("%s: Cannot get SPI SCLK GPIO: %d\n", __func__, ret);
		return ret;
	}

	ret = gpio_request_by_name(dev, "spi-sdi-gpios", 0, &priv->mosi,
				   GPIOD_IS_OUT);
	if (ret) {
		printf("%s: Cannot get SPI SDI GPIO: %d\n", __func__, ret);
		return ret;
	}

	ret = gpio_request_by_name(dev, "spi-cs-gpios", 0, &priv->cs,
				   GPIOD_IS_OUT);
	if (ret) {
		printf("%s: Cannot get SPI CS GPIO: %d\n", __func__, ret);
		return ret;
	}

	/* Power supply (optional) */
	ret = uclass_get_device_by_phandle(UCLASS_REGULATOR, dev,
					   "power-supply", &priv->power_supply);
	if (ret && ret != -ENOENT && ret != -ENODEV) {
		printf("%s: Cannot get power supply: %d\n", __func__, ret);
		return ret;
	}

	/* Backlight (optional) */
	ret = uclass_get_device_by_phandle(UCLASS_PANEL_BACKLIGHT, dev,
					   "backlight", &priv->backlight);
	if (ret && ret != -ENOENT && ret != -ENODEV) {
		printf("%s: Cannot get backlight: %d\n", __func__, ret);
		return ret;
	}

	/* Set initial idle state */
	dm_gpio_set_value(&priv->cs, 1);
	dm_gpio_set_value(&priv->sclk, 0);
	dm_gpio_set_value(&priv->mosi, 0);
	dm_gpio_set_value(&priv->reset, 0);

	/* Allocate and register rockchip_panel */
	panel = calloc(1, sizeof(*panel));
	if (!panel)
		return -ENOMEM;

	dev->driver_data = (ulong)panel;
	panel->dev = dev;
	panel->bpc = 6; /* RGB666: 6 bits per component */
	panel->bus_format = MEDIA_BUS_FMT_RGB666_1X18;
	panel->funcs = &lh24030c50_funcs;

	return 0;
}

static const struct udevice_id lh24030c50_ids[] = {
	{ .compatible = "lh,lh24030c50" },
	{ }
};

U_BOOT_DRIVER(lh24030c50) = {
	.name	  = "lh24030c50",
	.id	  = UCLASS_PANEL,
	.of_match = lh24030c50_ids,
	.probe	  = lh24030c50_probe,
	.priv_auto_alloc_size = sizeof(struct lh24030c50_priv),
};

  • 图片修改
sysdrv/source/kernel/logo.bmp

内核执行打包成boot.img时会将图片打包到一起

  • 修改rk原有驱动

    uboot不加载logo,设备树设置成logo.nologo时uboot会不做屏幕初始化。所以我们要设置成没有logo也要初始化。

    路径:

    /uboot/u-boot/drivers/video/drm/rockchip_display.c
    

    修改:1350 - 1354添加

    list_for_each_entry(s, &rockchip_display_list, head) {
    	s->logo.mode = s->logo_mode;
    	if (load_bmp_logo(&s->logo, s->ulogo_name)) {
    		printf("failed to display uboot logo\n");
    +		/* Still init display for kernel logo */
    +		if (!display_init(s))
    +			display_enable(s);
    	} else {
    		ret = display_logo(s);
    		if (ret == -EAGAIN)
    			ms = s;
    

uboot的设备驱动在probe之后才会执行rk_board_late_init,所以能显示充电图片。

uboot配置开启功能

详细跳转:config

SDK的.BoardConfig.mk文件开启fragment

# Uboot defconfig fragment
export RK_UBOOT_DEFCONFIG_FRAGMENT=rv1106-lcd.config

设备树

由于uboot配置文件配置了,使用内核的设备树。这是RK平台的特性,FIT启用后支持这么玩。

CONFIG_USING_KERNEL_DTB_V2=y

由于设备树中RGB节点,需要匹配内核以及uboot的SPI初始化功能。

compatible = "lh,lh24030c50", "simple-panel";
顺序尝试匹配结果
1stU-Boot 搜 lh24030c50✅ 命中我们的驱动
2ndsimple-panel不再尝试,已被第一个拿走
  • rockchip_panel 驱动(匹配 simple-panel)根本看不到这个节点。

  • Kernel 侧同理:lh,lh24030c50 是 SPI 驱动(不匹配根节点下的 panel),跳过→ 回退到 simple-panel,命中 panel-simple。

驱动代码

  • 代码:跳转

  • Kconfig

    路径:/u-boot/drivers/video/drm/Kconfig

    config DRM_PANEL_LH24030C50
    	bool "LH24030C50 (ST7789V) 240x320 RGB panel"
    	depends on DRM_ROCKCHIP
    	help
    	  Say Y to enable support for the LH24030C50 / ST7789V-based
    	  240x320 RGB LCD panel. This driver uses GPIO bitbang SPI for
    	  panel initialization and parallel RGB for display data.
    
  • Makefile

    路径:/u-boot/drivers/video/drm/Makefile

    obj-$(CONFIG_DRM_PANEL_LH24030C50) += panel-lh24030c50.o
    

编译错误修改

1. rockchip_post_csc.c → 第 7 行加了一行 #include <common.h>

这文件原本缺这个头文件。编译时 rockchip_post_csc.h → edid.h → i2c.h 会用到 uchar 类型,而 uchar 在 <common.h> 里定义。不加就编译报错。

evb_rv1106.c里面配置了

static const char *charge_bmps[] = {
	"battery_0.bmp",
	"battery_1.bmp",
	"battery_2.bmp",
	"battery_3.bmp",
	"battery_4.bmp",
	"battery_5.bmp",
	// "battery_fail.bmp",
};

通过

rockchip_show_bmp(charge_bmps[0])

拿到boot.img里面的图片

只需要打包resource.img时放进去就好

本质上是resource 分区里通过文件名查索引、按偏移读数据,不依赖文件系统。所以只要 resource_tool 把 BMP 打包进去,U-Boot 就能按名字读到。

内核脚本修改 kernel/scripts/mkimg

#!/bin/bash
# SPDX-License-Identifier: GPL-2.0
# Copyright (c) 2019 Fuzhou Rockchip Electronics Co., Ltd.

set -e

usage() {
	cat >&2 << USAGE
usage: $0 [-h] --dtb DTB

optional arguments:
  -h, --help            show this help message and exit
  --dtb DTB             the dtb file name
USAGE
}

# Parse command-line arguments
while [ $# -gt 0 ]; do
	case $1 in
		--dtb)
			DTB=$2
			shift 2
			;;
		-h)
			usage
			exit 0
			;;
		--help)
			usage
			exit 0
			;;
		*)
			shift
			;;
        esac
done

srctree=${srctree-"."}
objtree=${objtree-"."}
if [ "${ARCH}" == "" ]; then
	if [ "$($srctree/scripts/config --state CONFIG_ARM)" == "y" ]; then
		ARCH=arm
	else
		ARCH=arm64
	fi
fi

LOGO_PATH=${srctree}/logo.bmp
[ -f ${LOGO_PATH} ] && LOGO=logo.bmp

LOGO_KERNEL_PATH=${srctree}/logo_kernel.bmp
[ -f ${LOGO_KERNEL_PATH} ] && LOGO_KERNEL=logo_kernel.bmp

# Battery charge animation BMPs
BATTERY_BMPS=""
for bmp in battery_0.bmp battery_1.bmp battery_2.bmp battery_3.bmp battery_4.bmp battery_5.bmp battery_fail.bmp; do
	[ -f ${srctree}/${bmp} ] && BATTERY_BMPS="${BATTERY_BMPS} ${bmp}"
done

KERNEL_IMAGE_PATH=${objtree}/arch/${ARCH}/boot/Image
KERNEL_IMAGE_ARG="--kernel ${KERNEL_IMAGE_PATH}"
if [ "${ARCH}" == "arm" ]; then
	DTB_PATH=${objtree}/arch/arm/boot/dts/${DTB}
	ZIMAGE=zImage
else
	DTB_PATH=${objtree}/arch/arm64/boot/dts/rockchip/${DTB}
	ZIMAGE=Image.lz4
fi
KERNEL_ZIMAGE_PATH=${objtree}/arch/${ARCH}/boot/${ZIMAGE}
KERNEL_ZIMAGE_ARG="--kernel ${KERNEL_ZIMAGE_PATH}"
if [ ! -f ${DTB_PATH} ]; then
	echo "No dtb" >&2
	usage
	exit 1
fi

OUT=out
ITB=${BOOT_IMG}
ITS=${OUT}/boot.its
MKIMAGE=${MKIMAGE-"mkimage"}
MKIMAGE_ARG="-E -p 0x800"

make_boot_img()
{
	RAMDISK_IMG_PATH=${objtree}/ramdisk.img
	[ -f ${RAMDISK_IMG_PATH} ] && RAMDISK_IMG=ramdisk.img && RAMDISK_ARG="--ramdisk ${RAMDISK_IMG_PATH}"

	${srctree}/scripts/mkbootimg \
		${KERNEL_IMAGE_ARG} \
		${RAMDISK_ARG} \
		--second resource.img \
		-o boot.img && \
	echo "  Image:  boot.img (with Image ${RAMDISK_IMG} resource.img) is ready";
	${srctree}/scripts/mkbootimg \
		${KERNEL_ZIMAGE_ARG} \
		${RAMDISK_ARG} \
		--second resource.img \
		-o zboot.img && \
	echo "  Image:  zboot.img (with ${ZIMAGE} ${RAMDISK_IMG} resource.img) is ready"
}

repack_boot_img()
{
	${srctree}/scripts/repack-bootimg \
		--boot_img ${BOOT_IMG} --out ${OUT} \
		${KERNEL_IMAGE_ARG} \
		--second resource.img \
		--dtb ${DTB_PATH} \
		-o boot.img &&
	echo "  Image:  boot.img (${BOOT_IMG} + Image) is ready";
	${srctree}/scripts/repack-bootimg \
		--boot_img ${BOOT_IMG} --out ${OUT} \
		${KERNEL_ZIMAGE_ARG} \
		--second resource.img \
		--dtb ${DTB_PATH} \
		-o zboot.img && \
	echo "  Image:  zboot.img (${BOOT_IMG} + ${ZIMAGE}) is ready"
}

check_mkimage()
{
	MKIMAGE=$(type -p ${MKIMAGE} || true)
	if [ -z "${MKIMAGE}" ]; then
		# Doesn't exist
		echo '"mkimage" command not found - U-Boot images will not be built' >&2
		exit 1;
	fi
}

unpack_itb()
{
	rm -rf ${OUT}
	mkdir -p ${OUT}

	for NAME in $(fdtget -l ${ITB} /images)
	do
		# generate image
		NODE="/images/${NAME}"
		OFFS=$(fdtget -ti ${ITB} ${NODE} data-position)
		SIZE=$(fdtget -ti ${ITB} ${NODE} data-size)
		if [ -z ${OFFS} ]; then
			continue;
		fi

		if [ ${SIZE} -ne 0 ]; then
			dd if=${ITB} of=${OUT}/${NAME} bs=${SIZE} count=1 skip=${OFFS} iflag=skip_bytes >/dev/null 2>&1
		else
			touch ${OUT}/${NAME}
		fi
	done

	[ ! -f ${OUT}/kernel ] && echo "FIT ${ITB} no kernel" >&2 && exit 1 || true
}

gen_its()
{
	TMP_ITB=${OUT}/boot.tmp

	# add placeholder
	cp ${ITB} ${TMP_ITB}
	for NAME in $(fdtget -l ${ITB} /images); do
		fdtput -t s ${TMP_ITB} /images/${NAME} data "/INCBIN/(${NAME})"
	done
	dtc -I dtb -O dts ${TMP_ITB} -o ${ITS} >/dev/null 2>&1
	rm -f ${TMP_ITB}

	# fixup placeholder: data = "/INCBIN/(...)"; -> data = /incbin/("...");
	sed -i "s/\"\/INCBIN\/(\(.*\))\"/\/incbin\/(\"\1\")/" ${ITS}

	# remove
	sed -i "/memreserve/d"		${ITS}
	sed -i "/timestamp/d"		${ITS}
	sed -i "/data-size/d"		${ITS}
	sed -i "/data-position/d"	${ITS}
	sed -i "/value/d"		${ITS}
	sed -i "/hashed-strings/d"	${ITS}
	sed -i "/hashed-nodes/d"	${ITS}
	sed -i "/signer-version/d"	${ITS}
	sed -i "/signer-name/d"		${ITS}
}

gen_itb()
{
	[ -f ${OUT}/fdt ] && cp -a ${DTB_PATH} ${OUT}/fdt && FDT=" + ${DTB}"
	[ -f ${OUT}/resource ] && cp -a resource.img ${OUT}/resource && RESOURCE=" + resource.img"
	COMP=$(fdtget ${ITB} /images/kernel compression)
	case "${COMP}" in
		gzip)	EXT=".gz";;
		lz4)	EXT=".lz4";;
		bzip2)	EXT=".bz2";;
		lzma)	EXT=".lzma";;
		lzo)	EXT=".lzo";;
	esac
	cp -a ${KERNEL_IMAGE_PATH}${EXT} ${OUT}/kernel && \
	${MKIMAGE} ${MKIMAGE_ARG} -f ${ITS} boot.img >/dev/null && \
	echo "  Image:  boot.img (FIT ${BOOT_IMG} + Image${EXT}${FDT}${RESOURCE}) is ready";
	if [ "${EXT}" == "" ] && [ -f ${KERNEL_ZIMAGE_PATH} ]; then
		cp -a ${KERNEL_ZIMAGE_PATH} ${OUT}/kernel && \
		${MKIMAGE} ${MKIMAGE_ARG} -f ${ITS} zboot.img >/dev/null && \
		echo "  Image:  zboot.img (FIT ${BOOT_IMG} + zImage${FDT}${RESOURCE}) is ready";
	fi
}

repack_itb()
{
	check_mkimage
	unpack_itb
	gen_its
	gen_itb
}

# Create U-Boot FIT Image use ${BOOT_ITS}
make_fit_boot_img()
{
	ITS=${OUT}/boot.its

	check_mkimage
	mkdir -p ${OUT}
	rm -f ${OUT}/fdt ${OUT}/kernel ${OUT}/resource ${ITS}

	cp -a ${BOOT_ITS} ${ITS}
	cp -a ${DTB_PATH} ${OUT}/fdt
	cp -a ${KERNEL_ZIMAGE_PATH} ${OUT}/kernel
	cp -a resource.img ${OUT}/resource

	if [ "${ARCH}" == "arm64" ]; then
		sed -i -e 's/arch = ""/arch = "arm64"/g' -e 's/compression = ""/compression = "lz4"/' ${ITS}
	else
		sed -i -e 's/arch = ""/arch = "arm"/g' -e 's/compression = ""/compression = "none"/' ${ITS}
	fi
	FIT_DESC=$(${MKIMAGE} ${MKIMAGE_ARG} -f ${ITS} boot.img | grep "FIT description" | sed 's/FIT description: //')
	echo "  Image:  boot.img (${FIT_DESC}) is ready";
}

if [ -x ${srctree}/scripts/bmpconvert ]; then
	if [ -f ${LOGO_PATH} ]; then
		${srctree}/scripts/bmpconvert ${LOGO_PATH};
	fi
	if [ -f ${LOGO_KERNEL_PATH} ]; then
		${srctree}/scripts/bmpconvert ${LOGO_KERNEL_PATH};
	fi
fi

if [ "${srctree}" != "${objtree}" ]; then
	if [ -f ${LOGO_PATH} ]; then
		cp -a ${LOGO_PATH} ${objtree}/;
	fi
	if [ -f ${LOGO_KERNEL_PATH} ]; then
		cp -a ${LOGO_KERNEL_PATH} ${objtree}/;
	fi
	for bmp in battery_0.bmp battery_1.bmp battery_2.bmp battery_3.bmp battery_4.bmp battery_5.bmp battery_fail.bmp; do
		BATTERY_PATH=${srctree}/${bmp}
		[ -f ${BATTERY_PATH} ] && cp -a ${BATTERY_PATH} ${objtree}/;
	done
fi
scripts/resource_tool ${DTB_PATH} ${LOGO} ${LOGO_KERNEL} ${BATTERY_BMPS} >/dev/null
echo "  Image:  resource.img (with ${DTB} ${LOGO} ${LOGO_KERNEL} ${BATTERY_BMPS}) is ready"

if [ -f "${BOOT_IMG}" ]; then
	if file -L -p -b ${BOOT_IMG} | grep -q 'Device Tree Blob' ; then
		repack_itb;
	elif [ -x ${srctree}/scripts/repack-bootimg ]; then
		repack_boot_img;
	fi
elif [ -f "${BOOT_ITS}" ]; then
	make_fit_boot_img;
elif [ -x ${srctree}/scripts/mkbootimg ]; then
	make_boot_img;
fi

02. 内核修改

.BoardConfig.mk

  • 内核设备树
# Kernel dts
export RK_KERNEL_DTS=jw_borad_v1.dts
  • 关闭原有的摄像头
#export RK_CAMERA_SENSOR_IQFILES="sc4336_OT01_40IRC_F16.bin sc3336_CMK-OT2119-PC1_30IRC-F16.bin sc530ai_CMK-OT2115-PC1_30IRC-F16.bin"
#export RK_CAMERA_SENSOR_CAC_BIN="CAC_sc4336_OT01_40IRC_F16 CAC_sc530ai_CMK-OT2115-PC1_30IRC-F16"
  • 关闭原有的一些功能
export RK_APP_IPCWEB_BACKEND=n    #web 功能 nginx
export RK_ENABLE_ROCKCHIP_TEST=n  #测试,基准测试,内存测试
export RK_ENABLE_WIFI=n           #wifi功能 会编译/sysdrv/drv_ko/wifi 下的模块
  • 配置overlay
export RK_POST_OVERLAY=jw #对应/project/cfg/BoardConfig_IPC/overlay/jw 文件目录

这个文件主要是开启一些调试等功能。

路径:/sysdrv/cfg/package.mk

ADB功能开启

CONFIG_SYSDRV_ENABLE_ADBD=y
$(eval $(call MACRO_CHECK_ENABLE_PKG, RK_ENABLE_ADBD))

热插拔usb

CONFIG_SYSDRV_ENABLE_EUDEV=y
$(eval $(call MACRO_CHECK_ENABLE_PKG, RK_ENABLE_EUDEV))

这些配置项对应的源码目录

/sysdrv/tools/board

/media/cfg/cfg.mk文件主要是配置摄像头相关参数

比如配置libv4l库,用于v4l2-ctl 调试摄像头

# Enable libv4l
export CONFIG_LIBV4L=y

修改这个图片

sysdrv/source/kernel/logo.bmp

build.sh构建内核就好

阶段问题描述根因分析解决方案当前状态
U-Boot 显示 logoU-Boot 阶段屏幕 logo 正常显示无异常-正常
内核启动 logo 消失切换内核瞬间开机 logo 黑屏消失U-Boot panel 驱动配置错误video,flags,参数配置为 CSYNC,正确应为 NHSYNC;导致内核rockchip_drm_logo.c屏幕模式匹配失败,CRTC 显示通道被关闭修改修复 U-Boot 驱动文件panel-lh24030c50.c内同步标志位配置已修复
内核持续显示 logo修复 U-Boot 驱动后,开机 logo 可从 U-Boot 阶段平滑过渡至内核阶段,无黑屏断层驱动同步参数修正后,内核rockchip_drm_show_logo()可正常无缝承接画面输出重新编译烧录 U-Boot 固件即可生效验证通过

文件路径:

rv1106/sysdrv/source/kernel/drivers/gpu/drm/rockchip/rockchip_drm_logo.c

匹配逻辑在 setup_initial_state() 函数,核心是 679-689 行:

679: list_for_each_entry(mode, &connector->modes, head) {
680:     if (mode->clock == set->clock &&                    // 7MHz
681:         mode->hdisplay == set->hdisplay &&              // 240
682:         mode->vdisplay == set->vdisplay &&              // 320
683:         mode->crtc_hsync_end == set->crtc_hsync_end &&  // 288
684:         mode->crtc_vsync_end == set->crtc_vsync_end &&  // 332
685:         drm_mode_vrefresh(mode) == set->vrefresh &&     // 70
689:         mode->flags == (set->flags & DRM_MODE_FLAG_ALL) // ← 这里
690:     ) {
691:         found = 1;
692:         match = 1;
693:         break;
694:     }
695: }
697: if (!found) {
698:     ret = -EINVAL;
700:     dev_err(..., "can't found any match mode\n");  // 不匹配 → CRTC 关闭
709:     goto error_conn;
710: }
  • mode = 内核面板模式(来自 DTS timer,flags=NHSYNC|NVSYNC=0x0A)
  • set = U-Boot 写入 route 节点的模式(flags=NVSYNC|CSYNC=0x48 → 不匹配)
  • 689 行 flag 严格相等比较失败 → found=0 → 697 行走 error 路径 → CRTC 被关闭、屏幕灭

需要修改

内核配置

CONFIG_FRAMEBUFFER_CONSOLE=y
CONFIG_VT=y

内核启动时会调用 rockchip_drm_show_logo() → 它从 U-Boot 继承了 456K 的启动 logo 数据,并通过 DRM atomic commit 把 CRTC 配置为扫描 logo 所在的 framebuffer(rockchip_drm_logo.c:897-1094)。 同时 rockchip_drm_fbdev_init() 创建了 /dev/fb0,分配了一个独立的 fbdev framebuffer(在 CMA 内存中)。 re005 打开 /dev/fb0 写入像素 → 写入的是 fbdev 的 framebuffer,但 CRTC 仍在扫描 logo 的 framebuffer,所以屏幕不更新。

logo已有,开机进度条放到(x1,y1,x2,y2)=(30,260,210,280)

  • lvgl最好使用DRM,因为内核DRM启动完成不到0.5s

  • 开机脚本init直接调用,或者在overlay中定义一个比较前的脚本,比如S01lvglstart

  • lvgl中处理开机进度条。

  • lvgl程序中通过获取/tmp/progress拿到进度条所需要的值,修改进度条。

  • /tmp/progress的值,通过现有脚本修改:

    • S10udev
    • S20linkmount
    • S20linkmount
    • S21appinit
    • S50usbdevice
    • S97powerkeyd
    • S98xxx

由于可以直接用simple_panel驱动,所以直接配置设备树就好了

VOP 显示输出模块 → rgb_in_vop 接口 → RGB控制器(rgb节点) → rgb_out_panel端点 → panel_in_rgb端点 → LCD屏panel

摄像头模块

# CONFIG_VIDEO_GC2053=m
# CONFIG_VIDEO_IMX415=m
# CONFIG_VIDEO_OS04A10=m
# CONFIG_VIDEO_SC200AI=m
# CONFIG_VIDEO_SC3336=m
# CONFIG_VIDEO_SC401AI=m
# CONFIG_VIDEO_SC4336=m
# CONFIG_VIDEO_SC450AI=m
# CONFIG_VIDEO_SC530AI=m

wifi子系统

# CONFIG_WIRELESS=y
# CONFIG_WIRELESS_EXT=y
# CONFIG_WIRELESS_WDS is not set

# CONFIG_CFG80211_*所有=m
# CONFIG_MAC80211_*所有=m
# CONFIG_WLAN_*所有=y
# CONFIG_WL_ROCKCHIP=m

# CONFIG_WIRELESS_EXT=y
# CONFIG_WEXT_CORE=y
# CONFIG_WEXT_PRIV=y
# CONFIG_WEXT_PROC=y
# 所有 WLAN_VENDOR_* 不需要

文件系统

# CONFIG_NFS_FS=y
# CONFIG_NFS_V2=y
# CONFIG_NFS_V3=y
# CONFIG_NFS_V3_ACL=y
# CONFIG_NFS_V4=y

# CONFIG_SUNRPC=y
# CONFIG_LOCKD=y

ipv6

# CONFIG_IPV6=m

连带 fragment 里几十行 IPV6 子选项一起清掉。

usb声卡,键盘鼠标

# CONFIG_SND_USB=y
# CONFIG_USB_U_AUDIO=y
# CONFIG_USB_CONFIGFS_F_UAC1=y
# CONFIG_USB_CONFIGFS_F_UAC2=y
# CONFIG_USB_F_UAC1=y
# CONFIG_USB_F_UAC2=y

# CONFIG_USB_CONFIGFS_F_HID=y 
# CONFIG_USB_F_HID=y

Debug调试

# CONFIG_BLK_DEBUG_FS=y
# CONFIG_DEBUG_INFO=y

hdmi

# CONFIG_DRM_SII902X=y

npu和安卓 开关

# CONFIG_ANDROID=y
# CONFIG_ROCKCHIP_RKNPU=m
# CONFIG_ROCKCHIP_RKNPU_PROC_FS=y

网口

# CONFIG_STMMAC_ETH=y
# CONFIG_RK630_PHY=y

Audio CODEC 音频

#CONFIG_SOUND=y               → n  ← 关这个,下面全自动关
  CONFIG_SND=y
  CONFIG_SND_SOC=y
  CONFIG_SND_SOC_ROCKCHIP=y
  CONFIG_SND_SOC_RV1106=y
  CONFIG_SND_SIMPLE_CARD=y
  CONFIG_SND_JACK_INPUT_DEV=y

多余算法

用不到 IPsec/WiFi/NFS 等,很多算法链不会被触发。保守起见可以不动,但以下明显不用:

CONFIG_CRYPTO_DEFLATE=y   → n(不是用 SquashFS 的 LZ4?)
CONFIG_CRYPTO_LZO=y       → n
CONFIG_CRYPTO_ZSTD=y      → n
CONFIG_CRYPTO_RSA=y       → n

uboot开启背光

evb_rv1106.c

#define GPIO1_BASE		0xff530000
#define GPIO1_B0_MASK		BIT(8)
/* 拉高 LCD 背光(GPIO1_B0)*/
static void backlight_init(void)
{
	u32 ddr = gpio_v2_read(GPIO1_BASE + GPIO0_DDR_L);
	ddr |= GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DDR_L, ddr);

	u32 dr = gpio_v2_read(GPIO1_BASE + GPIO0_DR_L);
	dr |= GPIO1_B0_MASK;
	gpio_v2_write(GPIO1_BASE + GPIO0_DR_L, dr);
	printf("[LCD] Backlight GPIO1_B0 on\n");
}

设备树修改

backlight: backlight {
	status = "okay";
	compatible = "pwm-backlight";
	pwms = <&pwm3 0 50000 0>;
	brightness-levels = <0 80 160 255>;
	default-brightness-level = <0>;
	power-supply = <&vcc_3v3>;
};
//背光控制不交给DRM
panel: panel {
	// backlight = <&backlight>;
}

SPI初始化驱动修改

uboot屏幕驱动250 - 254

	/* Backlight will be enabled by kernel drm logo */
	// if (priv->backlight)
	// 	backlight_enable(priv->backlight);

内核驱动修改

路径:

/kernel/drivers/gpu/drm/rockchip/rockchip_drm_logo.c

添加头文件

#include <linux/backlight.h>

添加1070 - 1080

drm_atomic_state_put(state);
+{
+	struct backlight_device *bd;

+	bd = backlight_device_get_by_type(BACKLIGHT_RAW);
+	if (bd) {
+		backlight_enable(bd);
+		put_device(&bd->dev);
+	}
+}

private->loader_protect = true;

调试

# 查看当前亮度
cat /sys/class/backlight/backlight/brightness
#返回0-3
# 设置亮度(0-3,对应 brightness-levels 的索引)
echo 3 > /sys/class/backlight/backlight/brightness   # 高档
echo 2 > /sys/class/backlight/backlight/brightness   # 中档
echo 1 > /sys/class/backlight/backlight/brightness   # 低档
echo 0 > /sys/class/backlight/backlight/brightness   # 关闭

03. 设备树修改

#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/pinctrl/rockchip.h>
#include <dt-bindings/display/media-bus-format.h>
#include <dt-bindings/clock/rv1106-cru.h>

/ {
	backlight: backlight {
		status = "okay";
		compatible = "gpio-backlight";
		gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_HIGH>;
		default-on;
		power-supply = <&vcc_3v3>;
	};

	panel: panel {
		compatible = "lh,lh24030c50", "simple-panel"; 
		reset-gpios = <&gpio1 RK_PB1 GPIO_ACTIVE_LOW>; //内核不用,uboot用到
		spi-scl-gpios = <&gpio4 RK_PA7 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		spi-sdi-gpios = <&gpio4 RK_PA1 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		spi-cs-gpios = <&gpio4 RK_PA5 GPIO_ACTIVE_HIGH>;//内核不用,uboot用到
		bus-format = <MEDIA_BUS_FMT_RGB666_1X18>;
		bpc = <6>;
		backlight = <&backlight>;
		power-supply = <&vcc_3v3>;
		status = "okay";

		display-timings {
			native-mode = <&timing0>;

			timing0: panel-timing {
				clock-frequency = <7000000>;
				hactive = <240>;
				vactive = <320>;
				hfront-porch = <38>;
				hback-porch = <10>;
				hsync-len = <10>;
				vfront-porch = <8>;
				vback-porch = <4>;
				vsync-len = <4>;
				hsync-active = <0>;
				vsync-active = <0>;
				de-active = <1>;
				pixelclk-active = <0>;
			};
		};

		port {
			panel_in_rgb: endpoint {
				remote-endpoint = <&rgb_out_panel>;
			};
		};
	};

	reserved-memory {
		#address-cells = <1>;
		#size-cells = <1>;
		ranges;

		drm_logo: drm-logo@00000000 {
			compatible = "rockchip,drm-logo";
			reg = <0x0 0x0>;
		};
	};
};

&display_subsystem {
	status = "okay";
	logo-memory-region = <&drm_logo>;

	/* U-Boot route node: 内核忽略此节点,仅 U-Boot 使用 */
	route {
		route_rgb: route-rgb {
			status = "okay";
			connect = <&vop_out_rgb>;
			logo,uboot = "logo.bmp";
			logo,kernel = "logo.bmp";
			logo,mode = "center";
			charge_logo,mode = "center";
		};
	};
};

&rgb {
	status = "okay";
	pinctrl-names = "default";
	pinctrl-0 = <&lcd_pins>;
	ports {
		rgb_out: port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			rgb_out_panel: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&panel_in_rgb>;
			};
		};
	};
};

&rgb_in_vop {
	status = "okay";
};

&route_rgb {
	status = "okay";
};
&vop {
	assigned-clocks = <&cru PLL_CPLL>;
	assigned-clock-rates = <216000000>;
	status = "okay";
};

04. 模块修改

内核模块

  • modules.order文件

    内核编译完成之后会在内核根目录下生成modules.order文件,记录了各个ko文件的路径。

  • modules.builtin文件

    modules.builtin文件记录了原来可以编译成模块的配置,现在编译进了内核。专门给外部用户态工具(如 modprobe、depmod)看的一份“已内置模块花名册”。它保证了即使你把驱动编进内核,用户态的热插拔和加载机制依然能“假装”找到了这个模块,从而顺畅执行后续流程。

  • ko文件生成和复制流程

    • 内核配置文件将一个配置设置成模块

    • 在驱动文件对应的目录生成ko文件

      比如:

      /kernel/drivers/media/i2c/gc2145.c
      /kernel/drivers/media/i2c/gc2145.ko
      

      如果配置文件的配置是y,那么生成的就是.o文件用于链接

    • sysdrv/Makefile中ko文件复制

      /sysdrv/out
      	↓
      /output/out/sysdrv_out/kernel_drv_ko
      	↓
      /output/out/oem/usr/ko
      	↓
      /oem/usr/ko/     ← 最终部署位置
      
    • insmod_ko.sh脚本

      `drv_ko/insmod_ko.sh`
      	↓
      `drv_ko/out/`
      	↓
      `output/out/sysdrv_out/kernel_drv_ko/`
      	↓
      `oem/usr/ko/` 
      	↓
      板子 `/oem/usr/ko/insmod_ko.sh`
      

      执行时机:

      开机 → S20linkmount (挂载 oem 到 /oem)
           → S21appinit → RkLunch.sh
               → cd /oem/usr/ko && sh insmod_ko.sh
      

外部模块

  • 编译受到配置文件的影响
/sysdrv/cfg/package.mk

比如:CONFIG_SYSDRV_ENABLE_WIFI=n就不会编译wifi模块

  • 编译流程
/sysdrv/drv_ko/xxx模块/Makefile
	↓
/sysdrv/drv_ko/xxx模块/out/xxx.ko
	↓ 复制
/sysdrv/drv_ko/out
	↓ 复制
/output/out/sysdrv_out/kernel_drv_ko
	↓ 复制
/output/out/oem/usr/ko
	↓ 复制
/oem/usr/ko/     ← 最终部署位置
  • insmod_ko.sh脚本
`drv_ko/insmod_ko.sh`
	↓
`drv_ko/out/`
	↓
`output/out/sysdrv_out/kernel_drv_ko/`
	↓
`oem/usr/ko/` 
	↓
板子 `/oem/usr/ko/insmod_ko.sh`
  • 执行时机:
开机 → S20linkmount (挂载 oem 到 /oem)
     → S21appinit → RkLunch.sh
         → cd /oem/usr/ko && sh insmod_ko.sh
项完成情况
ADC按钮1
开关机按钮1
usb摄像头还差摄像头控制器功能还没移植
RGB屏幕1,在内核
sd卡1
mipi-csi摄像头
充电管理,电量管理充电管理引脚一直不变
ADB功能切换1

Rv1106 ipc linux sdk(测试)

build_all() 完整编译流程

build_all()
  ├── [可选] build_recovery     ← 只有 RK_ENABLE_RECOVERY=y 才执行
  ├── build_sysdrv              ← uboot + 内核 + 根文件系统 + busybox + 驱动模块
  ├── build_media               ← Rockchip 媒体库(rockit、mpi、isp、rga 等)
  ├── build_app                 ← 用户态应用程序(rkipc 等)
  ├── build_firmware            ← 打包所有镜像
  └── finish_build

第一步:build_sysdrv — 系统驱动层

# build.sh 第 541 行
function build_sysdrv(){
    make -C ${SDK_SYSDRV_DIR}    # → sysdrv/Makefile 的 all 目标
}

sysdrv/ 的 Makefile all 目标:

all: uboot kernel rootfs env

它依次调用 4 个子步骤:

1.1 make uboot — 编译 uboot

# sysdrv/Makefile 第 82-92 行
uboot: prepare
    make -C u-boot rv1106_defconfig          # 配置 uboot
    ./u-boot/make.sh --spl-new               # 编译 uboot + SPL
    cp uboot.img  output/image/
    cp idblock.img output/image/             # 一级引导
    cp download.bin output/image/            # 下载模式固件

产出文件:

output/out/sysdrv_out/ → 最终复制到 output/image/
  ├── uboot.img          ← uboot 主镜像
  ├── idblock.img        ← 一级引导(SPL + ddr init + 安全启动)
  └── download.bin       ← 烧录模式固件(loader)

1.2 make kernel — 编译内核

# sysdrv/Makefile 第 100-140 行
kernel: prepare
    make -C kernel ARCH=arm rv1106_defconfig     # 配置内核(含 fragment 合并)
    make -C kernel ARCH=arm zImage dtbs -jN      # 编译内核本身
    make -C kernel ARCH=arm boot.img             # 打包 boot.img(含内核 + dtb + resource)
    cp boot.img output/image/
    cp vmlinux   output/bin/                     # 带调试信息的完整内核
    cp *.dtb     output/bin/

关键点: 这里用的就是 make 命令,但通过 .config + rv1106-evb.config(fragment)合并生成最终配置。它不只是简单地执行 make,还做了:

  • fragment 合并:rv1106_defconfig + rv1106-evb.config → 最终 .config
  • 编译驱动模块(make modules)
  • 调用 update_dtb_bootargs.sh 修改设备树中的启动参数(root=/dev/xxx)
  • 打包 FIT 格式的 boot.img

产出文件:

output/image/
  ├── boot.img          ← FIT 格式,内含 zImage + dtb + resource
output/out/sysdrv_out/
  ├── vmlinux           ← 完整内核(带调试符号)
  ├── rv1106g-evb1-v11.dtb
  └── kernel_drv_ko/    ← 内核驱动模块 .ko 文件

1.3 make rootfs — 构建根文件系统

# 这步最复杂,依赖链:rootfs_prepare → pctools → busybox → boardtools → drv → strip
rootfs: rootfs_prepare pctools busybox boardtools drv

子步骤详情:

子步骤做的事产出
rootfs_prepare解压 rootfs 脚本模板 + 拷贝工具链运行时库(libc, libm, libpthread 等) 到 rootfs_*/基础根文件系统骨架
pctools编译 PC 端工具(mkenvimage、mk-fitimage.sh、mkfs.ubifs 等)output/out/sysdrv_out/pc/
busybox编译 busybox(busybox-1.27.2),生成 _install/bin/busybox 及所有 symlink提供 sh, ls, cp, mv 等基础命令
boardtools编译板端工具(来自 sysdrv/tools/board/)一些板级小工具
drvmake modules_install → 提取所有 .ko 到 kernel_drv_ko/内核模块拷贝
strip如果是 RELEASE 模式,strip 掉调试符号缩小根文件系统体积

最后根据存储介质打包根文件系统镜像:

  • spi_nand → rootfs_ubi → mkfs_ubi.sh → rootfs_base.img(UBIFS 格式)
  • emmc → rootfs_ext4 → mkfs_ext4.sh → rootfs_base.img(ext4 格式)
  • spi_nor → rootfs_jffs2 → mkfs_jffs2.sh → rootfs_base.img(JFFS2 格式)

产出文件:

output/out/sysdrv_out/
  ├── rootfs_glibc_rv1106/     ← 根文件系统目录(未打包)
  ├── rootfs_glibc_rv1106.tar  ← 根文件系统 tar 包
  ├── pc/                      ← PC 端工具
  ├── bin/                     ← 板端工具 + 调试文件
  └── kernel_drv_ko/           ← 内核驱动模块

第二步:build_media — 媒体库

# build.sh 第 523 行
function build_media(){
    make -C ${SDK_MEDIA_DIR}    # → media/Makefile
}
# media/Makefile
all: media_libs
    make -C ./samples                    # 编译 media 示例程序
    cp -rfa ... 到 output/out/media_out/ # 拷贝全部产出

media_libs:
    # 遍历所有子目录编译:
    # rockit/     — Rockchip 多媒体处理框架
    # isp3.x/     — 图像信号处理库
    # rv1106/     — 芯片级库(mpp、rga 等)
    # 等等

产出文件(到 output/out/media_out/):

media_out/
  ├── lib/              ← 所有 .so(librockit.so, libmpi.so, libisp.so, librga.so, libmpp.so...)
  ├── include/          ← 头文件(rockit 等 API 头)
  ├── bin/              ← 示例程序
  ├── share/isp_iqfiles/ ← 各传感器 IQ 调优文件(*.bin)
  ├── usr/              ← 额外资源
  └── root/             ← 需要放到根文件系统 / 下的文件

这是给 app 层提供依赖 — build_app 会链接 media_out 下的 .so 和头文件。


第三步:build_app — 用户态应用程序

# build.sh 第 461 行
function build_app(){
    check_config RK_APP_TYPE || return 0         # RK_APP_TYPE 为空则跳过
    build_meta --export --media_dir ...           # 导出 meta 头文件
    make -C ${SDK_APP_DIR}                       # → project/app/Makefile
}

会被跳过的条件: RK_APP_TYPE 未设置或为空 → 不编译

# project/app/Makefile
all:
    # 遍历所有子目录(rkipc/、ipcweb/、uvc_app_tiny/ 等)
    # 每个子目录根据 RK_APP_TYPE 决定是否编译
    make -C component/rkadk/     # 条件编译
    make -C component/lvgl/      # 条件编译
    make -C rkipc/               # RK_APP_TYPE=RKIPC_RV1106 时编译
    make -C uvc_app_tiny/        # RK_APP_TYPE=UVC_TINY 时编译
    # ...
    # 最后把所有产出拷贝到 out/
    MAROC_COPY_PKG_TO_APP_OUTPUT  # → project/app/out/

产出文件(到 project/app/out/):

app/out/
  ├── bin/rkipc         ← 主程序(IP Camera 守护进程)
  ├── lib/              ← .so(librkfsmk.so, libwpa_client.so 以及第三方库)
  └── share/            ← 配置文件(*.ini)、字体、测试音频

第四步:build_firmware — 打包固件镜像

# build.sh 第 1943 行
function build_firmware(){
    build_env                    # 生成 env.img(uboot 环境变量分区)
    build_meta                   # 生成 meta 分区(摄像头参数、IQ 文件等)

    __PACKAGE_ROOTFS             # 解压 rootfs.tar → 合并 app_out/media_out 内容
    __PACKAGE_OEM                # 打包 OEM 分区(/oem,放 app 和 media 的 bin/lib/share)
    __PACKAGE_USERDATA           # 打包空 userdata 分区

    build_mkimg rootfs ...       # 制作 rootfs 分区镜像(UBIFS/ext4/JFFS2)
    build_mkimg oem ...          # 制作 OEM 分区镜像
    build_mkimg userdata ...     # 制作 userdata 分区镜像

    build_updateimg              # 打包 update.img(统一烧录镜像)
}

最终固件产出(到 output/image/):

output/image/
  ├── uboot.img          ← uboot 镜像
  ├── idblock.img        ← 一级引导
  ├── download.bin       ← 烧录 loader
  ├── boot.img           ← 内核 + dtb + resource
  ├── rootfs.img         ← 根文件系统(UBIFS/ext4/JFFS2)
  ├── oem.img            ← OEM 分区(app + media 的 bin/lib/share + IQ 文件)
  ├── userdata.img       ← 空 userdata 分区
  ├── env.img            ← uboot 环境变量(分区表、启动参数)
  ├── misc.img           ← misc 分区(恢复模式标记)
  └── update.img         ← 统一烧录包(包含以上所有)

编译流程全局图

                 .BoardConfig.mk  ← 您在这里配置芯片、分区、APP_TYPE 等
                        │
                  build.sh all
                        │
         ┌──────────────┼──────────────┐
         │              │              │
    build_sysdrv   build_media    build_app
         │              │              │
    ┌────┼────┐    media/ 下     app/ 下各子目录
    │    │    │    各库源码      根据 RK_APP_TYPE 选择编译
   uboot kernel rootfs
    │    │    │         │              │
    │    │    │    media_out/     app/out/
    │    │    │    (.so + .h)    (rkipc + .so + .ini)
    │    │    │         │              │
    └────┼────┼─────────┼──────────────┘
         │    │         │              │
         │    │    build_firmware      │
         │    │    __PACKAGE_ROOTFS ◄──┘
         │    │    __PACKAGE_OEM    ◄──┘(合并到 oem 分区)
         │    │         │
         │    │    output/image/
         │    │    ├── boot.img
         │    │    ├── rootfs.img
         │    │    ├── oem.img
         │    │    └── update.img
         │    │
    ┌────┘    └──────────────────────────┐
    │                                    │
  uboot 阶段编译              kernel 阶段编译
  只是 make + 脚本拷贝       不只是 make:
                              - fragment 合并配置
                              - 修改 dtb 启动参数
                              - 打包 FIT boot.img
                              - 编译驱动模块
  • 关闭内核模块编译

    • 所有模块都放在根目录下的 /sysdrv/drv_ko

    • 编译内核时编译这些模块生成 ko 文件

    • 打包时将 ko 文件复制到文件系统

    - 去除模块

    内核 config 关掉不用的模块就好了

    #比如
    CONFIG_ROCKCHIP_RKNPU=m
    #设置成
    # CONFIG_ROCKCHIP_RKNPU is not set
    
  • 关闭其他服务

    **方式1:**修改./build.sh文件

    build_all()函数下注释掉build_app

    方式2:

    .BoardConfig.mk下注释:export RK_APP_TYPE=RKIPC_RV1106

    #web 服务
    export RK_APP_IPCWEB_BACKEND=n
    # wifi 功能
    export RK_ENABLE_WIFI=n
    export RK_ENABLE_WIFI_CHIP=RTL8189FS
    # rockchip 测试,会在/根目录下生成 rockchip_test 文件用于测试
    export RK_ENABLE_ROCKCHIP_TEST=n
    #摄像头
    export RK_CAMERA_SENSOR_IQFILES="sc4336_OT01_40IRC_F16.bin sc3336_CMK-OT2119-PC1_30IRC-F16.bin sc530ai_CMK-OT2115-PC1_30IRC-F16.bin"
    export RK_CAMERA_SENSOR_CAC_BIN="CAC_sc4336_OT01_40IRC_F16 CAC_sc530ai_CMK-OT2115-PC1_30IRC-F16"
    

    /sysdrv/cfg/package.mk

    #GDB调试
    CONFIG_SYSDRV_ENABLE_GDB=n
    
  • 关闭文件系统配置

    /sysdrv/tools/board/busybox/config_normal
    
    CONFIG_UDHCPC=y
    #改成
    #CONFIG_UDHCPC is not set
    
  • 开启v4l2功能

    路径:/media/cfg/cfg.mk

    # Enable libv4l
    export CONFIG_LIBV4L=y
    

DTS — 去掉 CSI 摄像头节点(最重要)

# /root/sdk/rv1106/sysdrv/source/kernel/arch/arm/boot/dts/rv1106g-evb1-v10.dts

第 10 行改成注释:

// #include "rv1106-evb-cam.dtsi"

这行删掉后,MIPI CSI 的管线、ISP、I2C4 上的 9 个传感器全部不会初始化。内核完全不会再碰 CSI 接口。

配置adb功能

package.mk:8         CONFIG_SYSDRV_ENABLE_ADBD=y
     │
     │ MACRO_CHECK_ENABLE_PKG(RK_ENABLE_ADBD)
     ▼
Makefile.param       ENABLE_ADBD=y
     │
     │ 传到 tools/board/
     ▼
Makefile.tools.board.mk   board-build-adbd:
                           $(MAKE) -C .../android-tools
     │
     ▼
android-tools/Makefile    编译 adbd(开源 Android adbd 源码)
                          复制 adbd → out/usr/bin/adbd
                          复制 S50usbdevice → out/etc/init.d/S50usbdevice
     │
     ▼
MAROC_COPY_PKG_TO_SYSDRV_OUTPUT
     │
     ▼
根文件系统打包 → /usr/bin/adbd + /etc/init.d/S50usbdevice
     │
     ▼
开机 rcS → S50usbdevice start
             → 挂载 configfs
             → 配置 ADB/UVC 等 gadget 函数
             → echo UDC > /sys/kernel/config/usb_gadget/rockchip/UDC
             → start-stop-daemon ... /usr/bin/adbd

关闭adb自启

  • 直接关
killall adbd
/usr/bin/adbd &  #启动
  • 程序里关
system("killall adbd");
  • 进制开机自启

    S50usbdevice文件删除或者不给执行权限

修改设备树支持usb

# /delete-node/ vcc5v0-usb; 注释这个 “删除节点”
#/delete-node/ usb;
# 恢复 USB 引脚控制
&pinctrl {
	usb {
		usb_pwren: usb-pwren {
			rockchip,pins = <0 RK_PA2 RK_FUNC_GPIO &pcfg_pull_none>;
		};
	};
};
#恢复 VBUS 供电到 USB PHY
&u2phy_otg {
	vbus-supply = <&vcc5v0_usb>;
	status = "okay";
};
#USB 为 otg 模式
&usbdrd_dwc3 {
	dr_mode = "otg";
};

内核-开启UVC驱动支持

# 开启 USB Video Class 驱动(连接 UVC 摄像头)
CONFIG_MEDIA_SUPPORT=y
CONFIG_MEDIA_USB_SUPPORT=y
CONFIG_USB_VIDEO_CLASS=y
CONFIG_V4L_PLATFORM_DRIVERS=y

# 如果需要其他 USB HID 设备(键盘鼠标)
CONFIG_USB_HID=y
CONFIG_HID_GENERIC=y

查看测试

由于外接了usb切换芯片,一路usb转成两路。

引脚:GPIO0_A0_z

echo 0 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio0/direction
echo 1 > /sys/class/gpio/gpio0/value
  • usb移除测试
# ls /sys/bus/usb/devices/udhcpc: sending discover
2-0:1.0  1-1:1.0  usb2     1-0:1.0  1-1      1-1:1.1  usb1

# echo 0 > /sys/class/gpio/gpio0/value
# [  284.664998] usb 1-1: USB disconnect, device number 2

  • 查看v4l2设备
# 列出所有 video 设备
ls -la /dev/video*

# 查看 v4l2 设备
cat /sys/class/video4linux/*/name
# ls -la /dev/video*
crw-rw----    1 root     video      81,  24 /dev/video21
crw-rw----    1 root     video      81,  20 /dev/video20

# cat /sys/class/video4linux/*/name
HikCamera: UVC Camera
HikCamera: UVC Camera
  • 查看连接的usb设备
# ls /sys/bus/usb/devices
2-0:1.0  1-1:1.0  usb2     1-0:1.0  1-1      1-1:1.1  usb1

USB模式切换

写的内容实际模式
hostHost(主机)
deviceDevice/Peripheral(设备)
otgOTG(自动识别)
# find /sys -name "mode" -path "*usb*" 2>/dev/null
/sys/kernel/debug/usb/ffb00000.usb/mode
echo device /sys/kernel/debug/usb/ffb00000.usb/mode
echo host /sys/kernel/debug/usb/ffb00000.usb/mode

切换为host模式(主机模式)

  • 方式一:lvgl程序自动切换

  • 方式二:

    # 1. 导出GPIO 0
    echo 0 > /sys/class/gpio/export
    # 2. 设置GPIO方向为输出
    echo out > /sys/class/gpio/gpio0/direction
    
    # 正确的 USB → Host 切换顺序:
    # ★ 先切 PHY # 再切 DWC3  # 最后切路由
    echo host > /sys/devices/platform/ff3e0000.usb2-phy/otg_mode
    echo host > /sys/kernel/debug/usb/ffb00000.usb/mode
    echo 0 > /sys/class/gpio/export
    echo out > /sys/class/gpio/gpio0/direction
    echo 1 > /sys/class/gpio/gpio0/value
    
    # 切换摄像头(可以同步执行)
    echo 1 > /sys/class/gpio/gpio0/value
    echo host > /sys/devices/platform/ff3e0000.usb2-phy/otg_mode
    echo host > /sys/kernel/debug/usb/ffb00000.usb/mode
    

切换为device模式(设备模式)

  • 方式一:lvgl程序自动切换

  • 方式二:

    # 1. 导出GPIO 0
    echo 0 > /sys/class/gpio/export
    # 2. 设置GPIO方向为输出
    echo out > /sys/class/gpio/gpio0/direction
    
    #切换adb功能(分开下执行,要一定间隔)
    echo 0 > /sys/class/gpio/gpio0/value
     #分开执行
    echo device > /sys/kernel/debug/usb/ffb00000.usb/mode
    #分开执行
    echo "" > /sys/kernel/config/usb_gadget/rockchip/UDC 2>/dev/null
    UDC=$(ls /sys/class/udc/)
    echo $UDC > /sys/kernel/config/usb_gadget/rockchip/UDC
    
    
  • 方式三:

    使用 S50usbdevice脚本(sdk的adb功能开启后自带)

    #usb hub切换到外置usb
    echo 0 > /sys/class/gpio/gpio0/value
    #模式切换
    echo device > /sys/kernel/debug/usb/ffb00000.usb/mode
    /etc/init.d/S50usbdevice stop     #报错也没事,甚至可以
    /etc/init.d/S50usbdevice start
    
    
    echo 0 > /sys/class/gpio/gpio0/value && echo device > /sys/kernel/debug/usb/ffb00000.usb/mode && /etc/init.d/S50usbdevice stop && /etc/init.d/S50usbdevice start
    

S50脚本改版后

echo "usb_adb_en" >> /tmp/.usb_config

echo 0 > /sys/class/gpio/gpio0/value

echo "usb_ums_en" >> /tmp/.usb_config
/etc/init.d/S50usbdevice restart

UMS模式

# 1. 停止自动恢复服务
killall usbd 2>/dev/null
killall adbd 2>/dev/null

# 2. 切 Device 模式
echo device > /sys/kernel/debug/usb/ffb00000.usb/mode

# 3. 解绑当前 Gadget
echo "" > /sys/kernel/config/usb_gadget/rockchip/UDC

# 4. 清理旧 Function
rm -f /sys/kernel/config/usb_gadget/rockchip/configs/c.1/f1
rm -f /sys/kernel/config/usb_gadget/rockchip/configs/c.1/f2

# 5. 创建 mass_storage 目录
mkdir -p /sys/kernel/config/usb_gadget/rockchip/functions/mass_storage.0/lun.0

# 6. 关键:先清空旧绑定(解决 Device or resource busy)
echo "" > /sys/kernel/config/usb_gadget/rockchip/functions/mass_storage.0/lun.0/file

# 7. 绑定 SD 卡
echo /dev/mmcblk1p1 > /sys/kernel/config/usb_gadget/rockchip/functions/mass_storage.0/lun.0/file

# 8. 设置可读写
echo 0 > /sys/kernel/config/usb_gadget/rockchip/functions/mass_storage.0/lun.0/ro

# 9. 链接到配置
ln -s /sys/kernel/config/usb_gadget/rockchip/functions/mass_storage.0 /sys/kernel/config/usb_gadget/rockchip/configs/c.1/f1

# 10. 激活(ffb00000.usb 从日志确认)
echo ffb00000.usb > /sys/kernel/config/usb_gadget/rockchip/UDC
# 查看 MCLK 时钟树状态
cat /sys/kernel/debug/clk/mclk_ref_mipi0/clk_rate 2>/dev/null
cat /sys/kernel/debug/clk/mclk_ref_mipi0/clk_enabled 2>/dev/null
cat /sys/kernel/debug/clk/clk_summary 2>/dev/null | grep -i mipi

# 看 GPIO 更全的输出
cat /sys/kernel/debug/gpio

# 看看 mipi_refclk_out0 引脚当前是什么功能
cat /sys/kernel/debug/pinctrl/*/pinmux-pins 2>/dev/null | grep -E "PC4|PC5|PC6"
#测试iic获取地址
i2cdetect -y 4 #iic 4

测试

读一帧数据

#设备支持的分辨率
v4l2-ctl -d /dev/video0 --list-formats-ext
#读一帧数据
v4l2-ctl -d /dev/video0 --set-fmt-video=width=800,height=600,pixelformat=YUYV --stream-mmap --stream-count=1 --stream-to=frame.raw

帧率测试

v4l2-ctl -d /dev/video0 --stream-mmap=4 --verbose
v4l2-ctl -d /dev/video0 \
  --set-fmt-video=width=800,height=600,pixelformat=YUYV \
  --stream-mmap=4 \
  --verbose

头注释(1-7行)

通路: GC2145 → csi2-dphy2 → mipi1-csi2 → rkcif-mipi-lvds1

摄像头的完整数据流:传感器 → 物理层(DPHY) → 协议解析(CSI2) → 采集(CIF)

2. I2C4 节点(9-41行)

&i2c4 {
    status = "okay";
    clock-frequency = <400000>;          // 400kHz
    pinctrl-0 = <&i2c4m2_xfer>;         // Pin5=SCL, Pin7=SDA
}
  • 开启 I2C4 总线,用 M2 复用(GPIO3_C7=SCL, GPIO3_D0=SDA)
  • 400kHz 是标准快模式

3. GC2145 传感器节点(16-40行)

gc2145@3c {
    compatible = "galaxycore,gc2145";    // 匹配驱动
    reg = <0x3c>;                        // I2C 地址
    clocks = <&cru MCLK_REF_MIPI0>;      // 24MHz 时钟源
    reset-gpios = <&gpio3 RK_PC5 GPIO_ACTIVE_LOW>;  // Pin3 复位
    pinctrl-0 = <&mipi_refclk_out0>;     // Pin2 输出24MHz时钟
    rockchip,camera-module-index = <0>;  // 第0个摄像头
    rockchip,camera-module-facing = "back";  // 后置
}

端口连接:

port {
    gc2145_out: endpoint {
        remote-endpoint = <&csi_dphy2_input>;  // 连到 dphy2
        data-lanes = <1 2>;                     // 2-lane MIPI
    };
};

4. CSI DPHY2 物理层(43-78行)

&csi2_dphy_hw { status = "okay"; };      // DPHY 硬件模块

&csi2_dphy2 {                            // 对应 PHY1 (CK1+D2+D3)
    status = "okay";
    port@0 {  // 入口
        csi_dphy2_input: endpoint@2 {    // 来自 GC2145 传感器
            reg = <2>;
            remote-endpoint = <&gc2145_out>;
            data-lanes = <1 2>;
        };
    };
    port@1 {  // 出口
        csi_dphy2_output: endpoint@0 {   // 传给 mipi1_csi2
            remote-endpoint = <&mipi1_csi2_input>;
        };
    };
};
  • 这是 MIPI D-PHY 物理层,把串行数据转成并行
  • DPHY2 对应硬件 PHY1(CK1 + D2/D3 = 引脚 117-122)
  • endpoint@2 的 reg = <2> 是 SoC 固定的硬件映射

5. MIPI CSI-2 协议层(80-110行)

&mipi1_csi2 {
    status = "okay";
    port@0 {  // 入口
        mipi1_csi2_input: endpoint@1 {
            remote-endpoint = <&csi_dphy2_output>;  // 来自 dphy2
        };
    };
    port@1 {  // 出口
        mipi1_csi2_output: endpoint@0 {
            remote-endpoint = <&cif_mipi_in>;       // 传给 CIF
        };
    };
};
  • 解析 MIPI CSI-2 协议(数据包拆分、VC 解复用)
  • 属于 mipi1(RV1106 有 mipi0 和 mipi1,我们接在 mipi1 上)

6. CIF 采集(112-126行)

&rkcif {
    status = "okay";
    pinctrl-0 = <&mipi_pins>;  // 设置所有 MIPI 引脚功能
};

&rkcif_mipi_lvds1 {
    status = "okay";
    port {
        cif_mipi_in: endpoint {
            remote-endpoint = <&mipi1_csi2_output>;  // 来自 mipi1_csi2
        };
    };
};
  • rkcif = CIF 硬件模块,pinctrl 配置全部 MIPI 引脚为功能 2
  • rkcif_mipi_lvds1 = 第二个 CIF 采集接口,接收 mipi1_csi2 解析后的数据

完整数据流

GC2145 传感器
  → 串行 MIPI 信号 (D2/D3 + CK1, 引脚117-122)
  → csi2_dphy2 (物理层, 串行转并行)
  → mipi1_csi2 (协议解析, 拆包)
  → rkcif_mipi_lvds1 (CIF 采集, DMA到内存)
  → /dev/video0

由于 GC2145.c 只用一根 lane(D2) ,所以设备树也配置为只用一根lane(D2也就是1)

/root/sdk/rv1106/sysdrv/source/kernel/drivers/media/i2c/gc2145.c

修改驱动

gc2145_mipi_svga_30fps,30帧移到前面

static const struct gc2145_framesize gc2145_mipi_framesizes[] = {
	{ /* SVGA */
		.width		= 800,   
		.height		= 600,
		.max_fps = {
			.numerator = 10000,
			.denominator = 300000,
		},
		.regs		= gc2145_mipi_svga_30fps,
	},{ /* SVGA */
		.width		= 800,
		.height		= 600,
		.max_fps = {
			.numerator = 10000,
			.denominator = 160000,
		},
		.regs		= gc2145_mipi_svga_20fps,
	}, { /* FULL */
		.width		= 1600,
		.height		= 1200,
		.max_fps = {
			.numerator = 10000,
			.denominator = 160000,
		},
		.regs		= gc2145_mipi_full,
	}
};

注释现有的摄像头模块

CONFIG_VIDEO_GC2053=m
CONFIG_VIDEO_IMX415=m
CONFIG_VIDEO_OS04A10=m
CONFIG_VIDEO_SC200AI=m
CONFIG_VIDEO_SC3336=m
CONFIG_VIDEO_SC401AI=m
CONFIG_VIDEO_SC4336=m
CONFIG_VIDEO_SC450AI=m
CONFIG_VIDEO_SC530AI=m
#CONFIG_VIDEO_GC2053 is not set
#CONFIG_VIDEO_IMX415 is not set
#CONFIG_VIDEO_OS04A10= is not set
#CONFIG_VIDEO_SC200AI= is not set
#CONFIG_VIDEO_SC3336= is not set
#CONFIG_VIDEO_SC401AI= is not set
#CONFIG_VIDEO_SC4336= is not set
#CONFIG_VIDEO_SC450AI= is not set
#CONFIG_VIDEO_SC530AI= is not set

启用GC2145模块

内核配置文件,rv1106-evb.config(推荐)或者rv1106_defconfig加上

CONFIG_VIDEO_GC2145=m
配置状态作用
CONFIG_VIDEO_GC2145=m✅GC2145 传感器
CONFIG_VIDEO_ROCKCHIP_CIF=m✅CIF 视频采集
CONFIG_VIDEO_ROCKCHIP_ISP=m✅ISP 图像处理
CONFIG_ROCKCHIP_DVBM=m✅ISP 依赖的缓冲管理
CONFIG_PHY_ROCKCHIP_CSI2_DPHY=m✅MIPI DPHY 硬件驱动

开机自动挂载GC2145

/sysdrv/drv_ko/insmod_ko.sh
#__insmod imx415.ko
#__insmod os04a10.ko
#__insmod sc4336.ko
#__insmod sc3336.ko
#__insmod sc530ai.ko
#__insmod gc2053.ko
#__insmod sc200ai.ko
#__insmod sc401ai.ko
#__insmod sc450ai.ko

__insmod gc2145.ko

配置GC2145设备树

jw_rv1106-mipi-csi.dtsi

// SPDX-License-Identifier: (GPL-2.0+ OR MIT)
/*
 * GC2145 MIPI CSI Camera on RV1106 — 完全对照可用 DTB
 *
 * 通路: GC2145 → csi2-dphy2 → mipi1-csi2 → rkcif-mipi-lvds1
 * GC2145 输出 UYVY, 不走 ISP
 */

/* I2C4 M2:SCL=GPIO3_C7(Pin5), SDA=GPIO3_D0(Pin7) */
&i2c4 {
	status = "okay";
	clock-frequency = <400000>;
	pinctrl-names = "default";
	pinctrl-0 = <&i2c4m2_xfer>;

	gc2145: gc2145@3c {
		compatible = "galaxycore,gc2145";
		status = "okay";
		reg = <0x3c>;
		orientation=<90>;
		clocks = <&cru MCLK_REF_MIPI0>;
		clock-names = "xvclk";

		reset-gpios = <&gpio3 RK_PC5 GPIO_ACTIVE_LOW>;

		pinctrl-names = "default";
		pinctrl-0 = <&mipi_refclk_out0>;

		rockchip,camera-module-index = <0>;
		rockchip,camera-module-facing = "back";
		rockchip,camera-module-name = "CMK-OT2115-PC1";
		rockchip,camera-module-lens-name = "30IRC-F16";

		port {
			gc2145_out: endpoint {
				remote-endpoint = <&csi_dphy2_input>;
				data-lanes = <1>;
			};
		};
	};
};

/* csi2-dphy2 — 对照可用 DTB */
&csi2_dphy_hw {
	status = "okay";
};

&csi2_dphy2 {
	status = "okay";

	ports {
		#address-cells = <1>;
		#size-cells = <0>;

		port@0 {
			reg = <0>;
			#address-cells = <1>;
			#size-cells = <0>;

			csi_dphy2_input: endpoint@2 {
				reg = <2>;
				remote-endpoint = <&gc2145_out>;
				data-lanes = <1>;
			};
		};

		port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			csi_dphy2_output: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&mipi1_csi2_input>;
			};
		};
	};
};

/* mipi1-csi2 — 对照可用 DTB */
&mipi1_csi2 {
	status = "okay";

	ports {
		#address-cells = <1>;
		#size-cells = <0>;

		port@0 {
			reg = <0>;
			#address-cells = <1>;
			#size-cells = <0>;

			mipi1_csi2_input: endpoint@1 {
				reg = <1>;
				remote-endpoint = <&csi_dphy2_output>;
			};
		};

		port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			mipi1_csi2_output: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&cif_mipi_in>;
			};
		};
	};
};

&rkcif {
	status = "okay";
	pinctrl-names = "default";
	pinctrl-0 = <&mipi_pins>;
};

&rkcif_mipi_lvds1 {
	status = "okay";

	port {
		cif_mipi_in: endpoint {
			remote-endpoint = <&mipi1_csi2_output>;
		};
	};
};

测试

读一帧数据

#设备支持的分辨率
v4l2-ctl -d /dev/video0 --list-formats-ext
#读一帧数据
v4l2-ctl -d /dev/video0 --set-fmt-video=width=800,height=600,pixelformat=YUYV --stream-mmap --stream-count=1 --stream-to=frame.raw

GC2145 IQ 文件配置

GC2145 → MIPI CSI → rkcif(采集) → rkisp(ISP处理) → /dev/video0 (YUV)
                         ↓ 不经过 ISP
                    /dev/video0 (RAW Bayer)
  • 路径一:走 ISP → YUV 输出(当前 DTSI 的配置)

    GC2145 → ... → rkcif → rkisp → rkisp_vir0 → /dev/video0
                                                  YUYV 格式
    
    • ✅ 自动做 AWB/AE/去马赛克/降噪
    • ✅ 应用层直接读 YUV 画质可用
    • 需要 IQ 文件,否则颜色偏绿/偏紫、曝光不对
    • 需要 build_media(AIQ 用户态库负责加载 IQ 文件并控制 ISP 参数)

    路径二:不经过 ISP → RAW 输出(不需要任何 IQ)

    GC2145 → ... → rkcif → /dev/video0
                             RAW Bayer (GBRG)
    
    • ✅ 不需要 IQ 文件
    • ✅ 不需要 build_media(完全在内核里完成)
    • 拿到的是 RAW Bayer 数据,需要自己在 app 里做去马赛克、白平衡
    • 如果要显示彩色图像,应用层工作量比较大

SDK 自带了文件系统覆盖机制

build_firmware 最后会调用 post_overlay()(build.sh 第 1922-1932 行):

function post_overlay() {
    # 读取 RK_POST_OVERLAY 变量
    # 把 project/cfg/overlay/<RK_POST_OVERLAY>/* rsync 到根文件系统
    rsync -a ... $tmp_path/overlay/$RK_POST_OVERLAY/* $RK_PROJECT_PACKAGE_ROOTFS_DIR/
}

1.在根目录下创overlay文件夹

  • 创建my_rootfs
7.png

2.配置.BoardConfig.mk

#新增
export RK_POST_OVERLAY=my_rootfs #my_rootfs必须与前面创建的对应

3.直接把文件丢进去overlay就好

4.开机自启脚本

  • overlay/my_rootfs/etc/init.d文件夹下存放Sxxx

  • 直接在 overlay 文件夹里用 chmod 设置就行,rsync 会保留权限。

  • 嵌入式 Linux 的 BusyBox init 系统(包括 RV1106 这种方案)中,/etc/init.d/ 目录下 以 S 开头的脚本都会在开机时自启。

命名规则

前缀含义
S + 数字 + 名称开机自启,数字小 → 先执行(rcS调用)
K + 数字 + 名称关机/重启时执行(rcK 调用)
其他不会被自动执行

数字越小越早执行,建议:

脚本用途理由
S10udevudev 设备管理必须先启动,否则没有 /dev/video0
S20urandom随机数种子系统基础
S50usbdeviceUSB 配置gadget 相关
S99myapp自己 app等所有服务就绪再启动

原来的脚本

看构建流程:

sysdrv/tools/board/eudev/
├── S10udev                    ──► 复制到 /etc/init.d/S10udev
├── rules/
│   ├── 61-sd-cards-auto-mount.rules  ──► 复制到 /lib/udev/rules.d/
│   └── 61-usbdevice.rules            ──► 复制到 /lib/udev/rules.d/
└── eudev-3.2.7/  (源码)

SDK 的构建脚本在编译 rootfs 时,通过某个 Makefile 把这些文件拷贝到 rootfs 目录下(output/out/rootfs_uclibc_rv1106/),然后打包成文件系统镜像。

DRM驱动导致的加载慢

在 VOP bridge bind 时立即触发 modeset
drivers/gpu/drm/rockchip/rockchip_drm_drv.c 的 rockchip_drm_bind() 末尾主动调用一次 drm_atomic_helper_connector_hotplug():
	/*
	 * ★ 立即触发一次 hotplug → modeset,避免等待默认 ~10s 的 poll interval。
	 * 用户态 app(lvgl_demo)在 ~4s 时会通过 SET_MASTER 夺走 DRM 主控权,
	 * 此后 poll worker 被阻塞无法完成 modeset → CRTC 永不激活 → 黑屏。
	 * drm_fb_helper_hotplug_event() 会同步 probe connector +
	 * 执行 drm_client_modeset_commit() → vop_crtc_atomic_enable()
	 */
	drm_fb_helper_hotplug_event(private->fbdev_helper);

电源按钮

内核支持 rv1106-evb.config

CONFIG_KEYBOARD_GPIO=y

4个按钮用的是ADC功能来判断按下状态的

4个按钮通过串联分压连接到ADC接口,按下就分压。

ADC接口:GPIO4_C0_Z

引脚序号复用备注说明
23SARADC_IN0/GPIO4_C0_zADC判断4个按钮按下状态

设备树

只测试ADC接口

#include <dt-bindings/input/input.h>
/ {
	/* 覆盖掉 rv1106-evb.dtsi 继承来的 2 键 adc-keys */
	/delete-node/ adc-keys;

	adc-keys {
		compatible = "adc-keys";
		io-channels = <&saradc 0>;
		io-channel-names = "buttons";
		poll-interval = <100>;
		keyup-threshold-microvolt = <450000>;
	}
};

&saradc {
	status = "okay";
	vref-supply = <&vcc_1v8>;
};

测试ADC接口

# 确认 IIO 设备
ls /sys/bus/iio/devices/
# → iio:device0

# 读取原始值(比如不按、分别按 4 个键,各记一次)
cat /sys/bus/iio/devices/iio:device0/in_voltage0_raw
# → 输出 0~4096 的整数

# 换算电压(微伏)
# microvolt = raw / 4096 * 1800000
# 例如 raw=2048 → 2048/4096*1800000 = 900000 uV

设备树完善

// SPDX-License-Identifier: (GPL-2.0+ OR MIT)
/*
 * 4 个按键 — 串联分压接入 SARADC_IN0 (GPIO4_C0_Z)
 *
 * 引脚#23: SARADC_IN0
 * 分压参考电压 1.8V
 *
 * 实测原始值(raw): 最大1024
 *   no press = 1020 (keyup) 
 *   KEY_UP   = 10 (0%)
 *   KEY_DOWN = 236 (25%)
 *   KEY_LEFT = 505 (50%)
 *   KEY_?    = 701 (75%)
 *
 */

#include <dt-bindings/input/input.h>
/ {
	gpio-keys {
		compatible = "gpio-keys";
		pinctrl-names = "default";
		pinctrl-0 = <&soc_gpio_bell>;

		bell-key {
			label = "SOC_GPIO_BELL"; //power-key 开机按钮
			linux,code = <KEY_POWER>;
			gpios = <&gpio0 RK_PA3 GPIO_ACTIVE_HIGH>;
			debounce-interval = <10>;
			wakeup-source;
		};
	};
	adc-keys {
		compatible = "adc-keys";
		io-channels = <&saradc 0>;
		io-channel-names = "buttons";
		poll-interval = <100>;
		keyup-threshold-microvolt = <1800000>; //1.8v
        //烧录功能!不要改rk官方的
		key_volumeup-key {
			label = "key_volumeup";
			linux,code = <KEY_VOLUMEUP>;
			press-threshold-microvolt = <0>;
		};
		key_volumedown-key {
			label = "key_volumedown";
			linux,code = <KEY_VOLUMEDOWN>;
			press-threshold-microvolt = <400781>;
		};
		key-left {
			label = "KEY_LEFT";
			linux,code = <KEY_LEFT>;
			press-threshold-microvolt = <900000>;
		};
		key-camera {
			label = "KEY_CAMERA";
			linux,code = <KEY_CAMERA>;
			press-threshold-microvolt = <1350000>;
		};
	};

	restart-poweroff {
		compatible = "restart-poweroff";
	};
	
};

&saradc {
	status = "okay";
	vref-supply = <&vcc_1v8>;
};

&pinctrl {
	bell {
		soc_gpio_bell: soc-gpio-bell {
			rockchip,pins = <0 RK_PA3 RK_FUNC_GPIO &pcfg_pull_up>;
		};
	};
};

测试

# hexdump -e '16/1 "%02x " "\n"' /dev/input/event0
d4 a8 ef 5f 77 b1 09 00 01 00 72 00 01 00 00 00
d4 a8 ef 5f 77 b1 09 00 00 00 00 00 00 00 00 00
d4 a8 ef 5f e4 5a 0d 00 01 00 72 00 00 00 00 00
d4 a8 ef 5f e4 5a 0d 00 00 00 00 00 00 00 00 00
d6 a8 ef 5f a4 fa 02 00 01 00 73 00 01 00 00 00
d6 a8 ef 5f a4 fa 02 00 00 00 00 00 00 00 00 00
d6 a8 ef 5f 54 22 0c 00 01 00 73 00 00 00 00 00
d6 a8 ef 5f 54 22 0c 00 00 00 00 00 00 00 00 00

要做

官方提供的案例引脚冲突!!

vccio_sd: vccio-sd {
	compatible = "regulator-gpio";
	regulator-name = "vccio_sd";
	regulator-min-microvolt = <1800000>;
	regulator-max-microvolt = <3300000>;
	gpios = <&gpio0 RK_PA3 GPIO_ACTIVE_HIGH>; #与电源开关检测引脚冲突
	states = <3300000 1
			  1800000 0>;
	pinctrl-names = "default";
	pinctrl-0 = <&sdmmc_volt>;
};

新增配置

&vccio_sd {
	status = "disabled";//与电源检测口冲突,禁用
};

&sdmmc {
	/delete-property/ vqmmc-supply;//关闭高速模式供电
};

识别到卡没有文件系统,需要弹出提示,是否格式化卡。然后执行mkfs.ext4 -F /dev/mmcblk1p1格式化卡

引入的#include "rv1106g-evb1-v10.dts"包含了TF卡相关的内容

所以只需要知道怎么个事就好。

sd卡作为u盘测试

  • USB Gadget支持(内核配置)

    CONFIG_USB_GADGET=y
    CONFIG_USB_CONFIGFS=y
    CONFIG_USB_CONFIGFS_MASS_STORAGE=y  #(UMS功能)
    
  • 修改usb脚本相关东西。

    echo usb_ums_en > /tmp/.usb_config
    echo ums_block=/dev/mmcblk1p1 >> /tmp/.usb_config #根据sd卡的块mmcblk1p1
    /etc/init.d/S50usbdevice restart #重启脚本
    
  • 回到adb模式

    echo usb_adb_en > /tmp/.usb_config
    /etc/init.d/S50usbdevice restart
    

1. S10udev → 启动 udev 守护进程

/root/sdk/rv1106/sysdrv/tools/board/eudev/S10udev

这个脚本启动 udevd,让内核的 hotplug 事件能被用户态处理。

2. 61-sd-cards-auto-mount.rules → 自动挂载规则

/root/sdk/rv1106/sysdrv/tools/board/eudev/rules/61-sd-cards-auto-mount.rules

规则逻辑:

插卡 → 内核发出 ADD 事件 → udev 检测到 mmcblk* 
    → blkid 识别文件系统类型 (vfat/ext4/ntfs...)
    → mount 到 /mnt/sdcard
拔卡 → 自动 umount /mnt/sdcard

位置: /lib/udev/rules.d/61-sd-cards-auto-mount.rules

执行时机: 每次插拔 SD 卡时,由 udevd 守护进程触发。

完整流程:

系统启动
  └─ S10udev 启动 udevd 守护进程(常驻后台)
        │
插卡 ──► 内核检测到新 mmcblk 设备
        │
        ▼ 发送 uevent 通知 udevd
        │
        ▼ udevd 按顺序扫描 /lib/udev/rules.d/*.rules
        │
        ▼ 匹配到 61-sd-cards-auto-mount.rules
        │   KERNEL=="mmcblk*[0-9]" → 是分区
        │   SUBSYSTEM=="block"     → 是块设备
        │   ATTRS{type}=="SD"      → 是 SD 卡
        │   └─ blkid 识别文件系统 → mount /dev/mmcblk1p1 /mnt/sdcard
        │
拔卡 ──► 内核检测到移除 → udev 匹配 ACTION=="remove"
        └─ umount /mnt/sdcard

如果要改挂载路径或参数,直接编辑 61-sd-cards-auto-mount.rules 里的 mount_options_* 和挂载点 /mnt/sdcard 就行。

相关芯片

功能IC型号特点
充电管理SLM6300
电量计CW2015CHBD
电源管理EA3036CQBR

电源开关检测按钮:62 ,SOC_GPIO_BELL/GPIO0_A3

电源按键管理

内核配置

只开启中断触发就好

CONFIG_KEYBOARD_GPIO=y #中断触发
CONFIG_KEYBOARD_GPIO_POLLED=y #轮询触发

设备树加上这个按钮

gpio-keys {
    compatible = "gpio-keys";         // 绑定内核 drivers/input/keyboard/gpio_keys.c
    pinctrl-names = "default";        // 使用 default 状态的 pinmux 配置
    pinctrl-0 = <&soc_gpio_bell>;     // 引用下面的 pinmux 定义

    bell-key {
        label = "SOC_GPIO_BELL";       // 名字,debug 时能看到
        linux,code = <KEY_POWER>;      // 按键码 116,上报给 input 子系统
        gpios = <&gpio0 RK_PA3 GPIO_ACTIVE_LOW>;  // GPIO0_A3,低电平触发
        debounce-interval = <10>;      // 10ms 消抖
        wakeup-source;                 // 休眠时这个键能唤醒 SoC
    };
};


&pinctrl {
    bell {                              // 分组名,方便管理
        soc_gpio_bell: soc-gpio-bell {  // 标签 + 节点名
            rockchip,pins = <0 RK_PA3 RK_FUNC_GPIO &pcfg_pull_up>;
        };
    };
};

电源保持

当内核启动时需要拉高power-hold引脚来给soc持续供电,拉低这个引脚关机

名称引脚号复用关系
power_hold60PWM3_IR_M0/GPIO0_A2_D

由于power-hold引脚和usb供电引脚一起。

  • 方式1:

    插上电usb供电,能保持供电开机。

  • 方式2:

    电池供电,如果usb配置了host模式(主机模式对外供电),就能保持一直供电开机。

  • 方式3:

    手动拉高保持power_hold引脚。保持一直供电开机。

    /* POW_HOLD: 内核启动后拉高 GPIO0_A2 持续供电 */
    gpio-poweroff {
    	compatible = "regulator-fixed";
    	regulator-name = "gpio-poweroff";
    	regulator-always-on;
    	regulator-boot-on;
    	enable-active-high;
    	gpio = <&gpio0 RK_PA2 GPIO_ACTIVE_HIGH>;
    	pinctrl-names = "default";
    	pinctrl-0 = <&power_hold>;
    };
    &pinctrl {
    	power_hold: power-hold {
    		rockchip,pins = <0 RK_PA2 RK_FUNC_GPIO &pcfg_output_high>;
    	};
    };
    

充电管理

引脚关系:查看

  • 内核驱动地址

    /kernel/drivers/power/supply/gpio-charger.c
    
  • 内核配置

    CONFIG_CHARGER_GPIO=y
    
  • 设备树配置

    charger: charger {
    	compatible = "gpio-charger";
    	charger-type = "mains";
    	gpios = <&gpio3 RK_PB6 GPIO_ACTIVE_LOW>;
    	pinctrl-names = "default";
    	pinctrl-0 = <&charger_pins>;
    };
    &pinctrl {
    	charger {
            charger_pins: charger-pins {
                rockchip,pins = <3 RK_PB6 RK_FUNC_GPIO &pcfg_pull_up>;
            };
        };
    };
    
  • 命令行测试

    # 无报错
    dmesg | grep cw2015
    
    # 充电器状态
    cat /sys/class/power_supply/charger/online
    

电量管理

引脚关系:查看

  • 内核已经有这个驱动了:
/root/sdk/rv1106/sysdrv/source/kernel/drivers/power/supply/cw2015_battery.c
  • 内核开启配置
CONFIG_BATTERY_CW2015=y
  • 直接在设备树增加配置
&i2c0 {
	status = "okay";

	cw2015@62 {
		compatible = "cellwise,cw2015";
		reg = <0x62>;

		/*
		 * 电池 profile(64 字节)由 CellWise 给出。
		 * 以下是一个典型 3.7V Li-Po / 18650 的参考值,
		 * 实测后需根据你们实际电芯校准。
		 */
		cellwise,battery-profile = /bits/ 8 <
			0x17 0x67 0x80 0x73 0x6E 0x6C 0x6B 0x63
			0x77 0x51 0x5C 0x58 0x50 0x4C 0x48 0x36
			0x15 0x0C 0x0C 0x19 0x5B 0x7D 0x6F 0x69
			0x69 0x5B 0x0C 0x29 0x20 0x40 0x52 0x59
			0x57 0x56 0x54 0x4F 0x3B 0x1F 0x7F 0x17
			0x06 0x1A 0x30 0x5A 0x85 0x93 0x96 0x2D
			0x48 0x77 0x9C 0xB3 0x80 0x52 0x94 0xCB
			0x2F 0x00 0x64 0xA5 0xB5 0x11 0xF0 0x11
		>;
		power-supplies = <&charger>;//与充电管理对应
		cellwise,monitor-interval-ms = <5000>;
	};
};
  • 电量管理充电检测日志

    /kernel/drivers/power/supply/cw2015_battery.c 第350行

    注释掉就不会报日志

ret = power_supply_am_i_supplied(cw_bat->rk_bat);
if (ret < 0) {
	// dev_warn(cw_bat->dev, "Failed to get supply state: %d\n", ret);
}
  • 命令行测试
# 电量百分比
cat /sys/class/power_supply/cw2015-battery/capacity

# 当前电压(微伏,除以 1000000 得伏特)
cat /sys/class/power_supply/cw2015-battery/voltage_now

# 充放电状态
cat /sys/class/power_supply/cw2015-battery/status

设备树开启即可

&rtc {
	status = "okay";
};

注意:没有必要直接操作/dev/rtc0这个节点

测试命令

  • 查看

    hwclock -r -f /dev/rtc0
    
  • 设置(不设置初始值,rtc不会自动跳动)

    date -s "2026-07-04 12:00:00"
    
  • 把系统时间写入 RTC

    hwclock -w -f /dev/rtc0
    
  • 把 RTC 时间读到系统

    hwclock -s -f /dev/rtc0
    
  • 直接读时间字符串

    cat /sys/class/rtc/rtc0/date  # 2026-07-06
    cat /sys/class/rtc/rtc0/time  # 04:30:53
    
  • 查看时间戳

    cat /sys/class/rtc/rtc0/since_epoch
    

用户usb_hub切换

电源保持io口拉高拉低。

SD卡检测。

改动:内核 —— drivers/usb/gadget/udc/core.c

usb_gadget_remove_driver() 中:

把 udc->driver->unbind() 提前到 usb_gadget_disconnect() 之前(原顺序互换):

  • 原版(先停控制器,再解绑 —— 主机活跃传输时挂死)
usb_gadget_disconnect(udc->gadget);        // → pullup(0) → soft_disconnect → RUN_STOP=0
usb_gadget_disable_async_callbacks(udc);
if (udc->gadget->irq) synchronize_irq(udc->gadget->irq);
udc->driver->unbind(udc->gadget);          // → composite_unbind → fsg_unbind
usb_gadget_udc_stop(udc);
  • 修复(先解绑让传输停止,再停控制器)
udc->driver->unbind(udc->gadget);          // → fsg_unbind:等 fsg 线程完成当前命令、空闲端点
usb_gadget_disconnect(udc->gadget);        // → RUN_STOP=0(控制器已完全空闲 → 安全)
usb_gadget_disable_async_callbacks(udc);
if (udc->gadget->irq) synchronize_irq(udc->gadget->irq);
usb_gadget_udc_stop(udc);

原理:

  • configfs 解绑走 configfs_composite_unbind → purge_configs_funcs → fsg_unbind,

  • 而 fsg_unbind 里已有 wait_event(common->fsg_wait, common->fsg != fsg)——它发 CONFIG_CHANGE 异常并等待 fsg 线程。

  • fsg 线程在主循环的命令边界处理异常(do_set_interface(NULL)):先自然完成当前 SCSI 命令(传输正常结束)→ 在无活跃传输时禁用端点 → 置 fsg=NULL 唤醒。等这一步完成,控制器已完全空闲,随后的 RUN_STOP=0 就不会挂死。全程没有任何寄存器操作发生在活跃传输上。

背光问题

路径:kernel/drivers/gpu/drm/rockchip/rockchip_rgb.c

在 static int rockchip_rgb_encoder_loader_protect(struct drm_encoder *encoder,bool on)函数

return 0;之前加上这两句代码

if (on && rgb->panel)
    return drm_panel_enable(rgb->panel);

问题现象

启动时 U-Boot Logo 可以显示,但从 U-Boot 切换到 Linux Kernel Logo 的瞬间会出现两条横向花带或撕裂痕迹。即使 U-Boot Logo 和 Kernel Logo 使用同一张图片,画面仍然不连续。

1 Kernel 启动时 CPLL 被连续重配两次

SoC 公共设备树:

sysdrv/source/kernel/arch/arm/boot/dts/rv1106.dtsi:701-714

其 assigned-clocks 中第二个时钟是 CPLL,而对应的第二个默认频率是 1 GHz:

assigned-clocks =
        <&cru PLL_GPLL>, <&cru PLL_CPLL>,
        ...;

assigned-clock-rates =
        <1188000000>, <1000000000>,
        ...;

板级 LCD (jw_rv1106-lcd.dtsi)配置原本又在 VOP 节点要求:

&vop {
        assigned-clocks = <&cru PLL_CPLL>;
        assigned-clock-rates = <216000000>;
};
  1. U-Boot 显示 Logo 时,显示链路最终使用 CPLL 216 MHz。
  2. Linux 初始化 CRU 时,根据公共 rv1106.dtsi 把 CPLL 从 216 MHz 改成 1 GHz。
  3. Linux 随后初始化 VOP 时,根据板级 VOP 配置又把 CPLL从 1 GHz 改回 216 MHz。

Kernel 的 RK3036/RK3328 类 PLL 参数更新过程位于:

sysdrv/source/kernel/drivers/clk/rockchip/clk-pll.c:581-586

if (!(pll->flags & ROCKCHIP_PLL_FIXED_MODE)) {
        cur_parent = pll_mux_ops->get_parent(&pll_mux->hw);
        if (cur_parent == PLL_MODE_NORM) {
                pll_mux_ops->set_parent(&pll_mux->hw, PLL_MODE_SLOW);
                rate_change_remuxed = 1;
        }
}

每次 PLL 改频都会先切到 slow mode,再更新 PLL 参数并等待锁定。显示控制器仍在扫描屏幕时,DCLK 因父 PLL 切换而短暂异常,因此一次改频可能污染当前帧的一部分。连续两次改频与观察到的两条横向花带相符。

2 U-Boot DCLK 分频值超过硬件 5 位范围

寄存器定义位于:

sysdrv/source/uboot/u-boot/arch/arm/include/asm/arch-rockchip/cru_rv1106.h:175-181

DCLK_VOP_SEL_SHIFT = 8,
DCLK_VOP_SEL_MASK  = 0x1 << DCLK_VOP_SEL_SHIFT,
DCLK_VOP_DIV_SHIFT = 3,
DCLK_VOP_DIV_MASK  = 0x1f << DCLK_VOP_DIV_SHIFT,

DCLK_VOP_DIV_MASK 只有 5 位,因此:

  • 寄存器编码范围:0~31。
  • 实际分频范围:1~32。
  • 大于 32 的分频值不能写入该字段。

旧代码在 CPLL 不能整除请求频率时直接选择 GPLL:

if ((priv->cpll_hz % rate) == 0) {
        sel = DCLK_VOP_SEL_CPLL;
        div = DIV_ROUND_UP(priv->cpll_hz, rate);
} else {
        sel = DCLK_VOP_SEL_GPLL;
        div = DIV_ROUND_UP(priv->gpll_hz, rate);
}

rk_clrsetreg(...,
             sel << DCLK_VOP_SEL_SHIFT |
             (div - 1) << DCLK_VOP_DIV_SHIFT);

7 MHz 请求对应的 GPLL 分频为:

ceil(1188 MHz / 7 MHz) = 170

170 明显超过硬件最大分频 32。旧代码写入的是:

(div - 1) << 3
= 169 << 3
= 0x548

写寄存器时的有效更新掩码包含 DCLK 分频位 3~7 和父时钟选择位 8。0x548 在这些位上的有效部分为 0x148:

  • 分频字段最终为 9,即硬件实际除以 10。
  • 溢出的位同时把 bit 8 置 1,错误地选择 CPLL。
  • 如果 CPLL 已经是 216 MHz,最终 DCLK 可能变成 216 / 10 = 21.6 MHz。

这远高于屏幕可接受的 7 MHz,存在花屏风险。

3 U-Boot 固定 get_mode 绕过了 DTS 像素时钟边沿

U-Boot 显示框架的选择顺序位于:

sysdrv/source/uboot/u-boot/drivers/video/drm/rockchip_display.c:536-550

if (panel->funcs->get_mode)
        return panel->funcs->get_mode(panel, mode);

if (dev_of_valid(panel->dev) &&
    !display_get_timing_from_dts(panel, mode, &conn_state->bus_flags)) {
        ...
}

只要面板驱动实现 get_mode,框架就立即返回,不再读取 DTS,也不会读取 DTS 中的 pixelclk-active。

板级 DTS 当前配置位于:

sysdrv/source/kernel/arch/arm/boot/dts/jw_rv1106-lcd.dtsi:31-44

clock-frequency = <7000000>;
...
pixelclk-active = <0>;

旧的固定 get_mode 只填写分辨率、porch 和同步极性,没有填写 conn_state->bus_flags。这样 U-Boot 与 Kernel 对像素时钟边沿的解释不同,交接时 VOP 会改变 DCLK 极性,可能造成瞬间错采样。

4 Kernel 用错误单位比较当前 DCLK

旧代码把 DRM 模式中的 kHz 数值乘以 100,再与 clk_get_rate() 返回的 Hz 比较:

u32 crtc_clock = adjusted_mode->crtc_clock * 100;
...
crtc_clock != clk_get_rate(vop->dclk)

对于本屏幕:

adjusted_mode->crtc_clock 约为 6968 kHz
旧比较值约为              696800
实际 clk_get_rate() 约为   6967741 Hz

两者必然不等,导致 Kernel 总是认为显示模式发生变化。

误判后的调用路径位于:

sysdrv/source/kernel/drivers/gpu/drm/rockchip/rockchip_drm_vop.c:3295-3297

s->mode_update = vop_crtc_mode_update(crtc);
if (s->mode_update)
        vop_disable_all_planes(vop);

这会关闭 U-Boot 保留下来的显示平面并重新配置显示,破坏 Loader Logo 到 Kernel Logo 的连续性。

修改文件总览

序号文件当前行号修改内容
1sysdrv/source/kernel/arch/arm/boot/dts/jw_rv1106-lcd.dtsi109-122在板级 CRU 节点覆盖完整默认频率列表,把 CPLL 保持为 216 MHz
2同上124-128保留 VOP 对 CPLL 216 MHz 的要求,确保显示初始化目标明确
3sysdrv/source/uboot/u-boot/drivers/clk/rockchip/clk_rv1106.c35增加 216 MHz PLL 参数表项
4同上962-1013重写 DCLK 父时钟和分频选择,限制分频为 1~32并对写入值做掩码
5同上1182-1193PLL 改频成功后同步 CPLL/GPLL 软件缓存
6sysdrv/source/uboot/u-boot/drivers/video/drm/panel-lh24030c50.c275-280删除固定 get_mode 回调,让框架读取 DTS timing 和 bus_flags
7sysdrv/source/kernel/drivers/gpu/drm/rockchip/rockchip_drm_vop.c3221-3227按与 mode_fixup 相同的 kHz 舍入规则比较 DCLK

每一处修改的详细记录

1 板级 DTS 固定 Kernel 初始化阶段的 CPLL

文件:

sysdrv/source/kernel/arch/arm/boot/dts/jw_rv1106-lcd.dtsi

新增位置:

109-122 行

新增内容:

&cru {
        /*
         * Keep CPLL at the loader rate during kernel clock initialization.
         * Reprogramming CPLL switches its output to 24 MHz slow mode and
         * visibly corrupts the loader-logo handoff.
         */
        assigned-clock-rates =
                <1188000000>, <216000000>,
                <1104000000>,
                <400000000>, <200000000>,
                <100000000>, <300000000>,
                <100000000>, <100000000>,
                <200000000>;
};

修改原因:

  • 设备树属性覆盖是整项替换,不能只写第二个 CPLL 数值。
  • 必须保持与公共 DTS 的 assigned-clocks 数量和顺序一致。
  • 列表第二项从公共默认的 1000000000 改为 216000000。
  • 其他 GPLL、ARMCLK 和总线时钟频率保持原值,避免影响无关模块。
  • CPLL 最终状态本来就会被 VOP 设置为 216 MHz;该修改没有改变系统最终频率,只是消除了 Linux 启动中间的 1 GHz 过渡。

保留位置:

124-128 行

&vop {
        loader_protect;
        assigned-clocks = <&cru PLL_CPLL>;
        assigned-clock-rates = <216000000>;
        status = "okay";
};

保留它的原因:

  • 明确 VOP 显示链路要求 CPLL 216 MHz。
  • 对 U-Boot/Kernel 不同设备探测顺序提供冗余保证。
  • 当前 CPLL 已经是 216 MHz 时,时钟框架不会再次执行实质性的 PLL 参数变更。

2 U-Boot 增加 216 MHz PLL 参数表项

文件:

sysdrv/source/uboot/u-boot/drivers/clk/rockchip/clk_rv1106.c

新增位置:

35 行

RK3036_PLL_RATE(216000000, 1, 72, 4, 2, 1, 0),

参数计算:

24 MHz × fbdiv 72 / refdiv 1 / postdiv1 4 / postdiv2 2
= 216 MHz

修改原因:

  • 板级 DTS 明确请求 CPLL 216 MHz。
  • U-Boot 通用 PLL 代码虽然支持自动计算部分频率,但显式表项更加确定,并与 Kernel RV1106 PLL 表中的 216 MHz 参数一致。
  • 避免不同 U-Boot 配置或自动计算路径对该关键显示父时钟产生差异。

3 U-Boot 在 PLL 改频后同步软件缓存

文件:

sysdrv/source/uboot/u-boot/drivers/clk/rockchip/clk_rv1106.c

修改位置:

  • CPLL:1182-1187 行
  • GPLL:1188-1193 行

当前代码:

case PLL_CPLL:
        ret = rockchip_pll_set_rate(&rv1106_pll_clks[CPLL], priv->cru,
                                    CPLL, rate);
        if (!ret)
                priv->cpll_hz = rate;
        break;
case PLL_GPLL:
        ret = rockchip_pll_set_rate(&rv1106_pll_clks[GPLL], priv->cru,
                                    GPLL, rate);
        if (!ret)
                priv->gpll_hz = rate;
        break;

修改前的问题:

  • rockchip_pll_set_rate() 已经改变硬件 PLL。
  • priv->cpll_hz 和 priv->gpll_hz 却仍保存初始化时的旧值。
  • 板级 DTS 把 CPLL 改为 216 MHz 后,DCLK 计算仍可能把 CPLL 当作 1 GHz。
  • 后续分频和父时钟选择基于错误输入,最终寄存器设置也会错误。

修改后的行为:

  • 只有硬件 PLL 设置成功时才更新缓存。
  • DCLK 计算使用与硬件一致的 216 MHz CPLL。
  • GPLL 同样修复,避免以后其他 assigned-clock-rates 修改 GPLL 时出现同类问题。

4 U-Boot 重写 VOP DCLK 分频选择

文件:

sysdrv/source/uboot/u-boot/drivers/clk/rockchip/clk_rv1106.c

函数:

rv1106_vop_set_clk(),当前 958-1020 行

4.1 从寄存器掩码计算最大分频

位置:

962-966 行

ulong best_rate = 0, candidate_rate;
u32 best_div = 0, div;
const u32 max_div =
        (DCLK_VOP_DIV_MASK >> DCLK_VOP_DIV_SHIFT) + 1;
int sel = DCLK_VOP_SEL_CPLL;

结果:

DCLK_VOP_DIV_MASK = 0x1f << 3
字段最大编码       = 0x1f = 31
实际最大分频       = 31 + 1 = 32

不再使用没有边界检查的任意整数分频。

4.2 拒绝 0 Hz 请求

位置:

985-986 行

if (!rate)
        return -EINVAL;

防止 DIV_ROUND_UP() 除以 0。

4.3 分别评估 CPLL 和 GPLL

位置:

  • CPLL:988-992 行
  • GPLL:994-1002 行
  • 无合法组合:1004-1005 行
div = DIV_ROUND_UP(priv->cpll_hz, rate);
if (div && div <= max_div) {
        best_div = div;
        best_rate = priv->cpll_hz / div;
}

div = DIV_ROUND_UP(priv->gpll_hz, rate);
if (div && div <= max_div) {
        candidate_rate = priv->gpll_hz / div;
        if (candidate_rate > best_rate) {
                sel = DCLK_VOP_SEL_GPLL;
                best_div = div;
                best_rate = candidate_rate;
        }
}

if (!best_div)
        return -EINVAL;

选择规则:

  1. 使用 DIV_ROUND_UP,保证实际输出不高于请求值。
  2. 只接受 1~32 的硬件合法分频。
  3. 如果两个父时钟都合法,选择实际输出最高、即最接近请求值的组合。
  4. 相同结果时保留 CPLL,减少无意义的父时钟切换。

4.4 7 MHz 请求的最终结果

父时钟计算分频是否合法实际 DCLK
CPLL 216 MHzceil(216 / 7) = 31是,31 ≤ 32216 / 31 = 6.967741935 MHz
GPLL 1188 MHzceil(1188 / 7) = 170否,170 > 32不使用

最终选择:

父时钟:CPLL 216 MHz
实际分频:31
寄存器分频编码:31 - 1 = 30
实际像素时钟:约 6.967742 MHz

该结果低于屏幕的 7 MHz 上限。

按照当前 porch:

HTOTAL = 240 + 38 + 10 + 10 = 298
VTOTAL = 320 + 8 + 4 + 4 = 336
刷新率约为 6,967,742 / 298 / 336 = 69.59 Hz

4.5 对寄存器写入值显式做掩码

位置:

1007-1013 行

rk_clrsetreg(&cru->clksel_con[23],
             DCLK_VOP_SEL_MASK |
             DCLK_VOP_DIV_MASK,
             ((sel << DCLK_VOP_SEL_SHIFT) &
              DCLK_VOP_SEL_MASK) |
             (((best_div - 1) << DCLK_VOP_DIV_SHIFT) &
              DCLK_VOP_DIV_MASK));

修改原因:

  • 即使上层以后再次出现错误值,也不允许分频字段污染父时钟选择位。
  • 父时钟选择值只能写入 DCLK_VOP_SEL_MASK。
  • 分频值只能写入 DCLK_VOP_DIV_MASK。
  • 与前面的最大分频检查组成双重保护。

5 删除 U-Boot 固定 get_mode,统一 DTS 时序和边沿

文件:

sysdrv/source/uboot/u-boot/drivers/video/drm/panel-lh24030c50.c

删除内容在修改前的位置:

  • 固定 lh24030c50_get_mode():修改前 275-296 行。
  • 函数表中的 .get_mode = lh24030c50_get_mode:修改前 302 行。

修改后的函数表位置:

275-280 行

static const struct rockchip_panel_funcs lh24030c50_funcs = {
        .prepare   = lh24030c50_prepare,
        .unprepare = lh24030c50_unprepare,
        .enable    = lh24030c50_enable,
        .disable   = lh24030c50_disable,
};

修改原因:

  • 不再注册 get_mode 后,U-Boot 显示框架会进入 display_get_timing_from_dts()。
  • U-Boot 与 Kernel 都读取同一个 DTS:
    • 240 × 320。
    • 相同的前后肩和同步宽度。
    • clock-frequency = 7000000。
    • pixelclk-active = 0。
  • U-Boot 因此能够正确填写 conn_state->bus_flags。
  • U-Boot 和 Kernel 的 RV1106 VOP 最终使用相同的 DCLK 极性,交接时不再翻转像素采样边沿。

没有改变的内容:

  • LCD 初始化命令。
  • RGB666 总线格式。
  • BPC=6。
  • 分辨率和 porch。
  • HSYNC、VSYNC、DE 极性。
  • Logo 图片名称。

6 Kernel 使用统一的 kHz 舍入比较 DCLK

文件:

sysdrv/source/kernel/drivers/gpu/drm/rockchip/rockchip_drm_vop.c

修改位置:

  • 当前 DCLK 转 kHz:3221 行。
  • 比较条件:3227 行。

当前代码:

u32 dclk_khz = DIV_ROUND_UP(clk_get_rate(vop->dclk), 1000);
...
adjusted_mode->crtc_clock != dclk_khz

为什么不能只把旧代码的 * 100 改成 * 1000:

实际 DCLK 是 CPLL 的整数分频结果:

216000000 / 31 = 6967741.935... Hz
clk_get_rate() 的整数结果约为 6967741 Hz

DRM 模式时钟以整数 kHz 表示,模式修正代码已经在:

rockchip_drm_vop.c:3093-3095

使用以下规则:

adj_mode->crtc_clock =
        DIV_ROUND_UP(clk_round_rate(vop->dclk,
                                    adj_mode->crtc_clock * 1000),
                     1000);

因此模式时钟是:

ceil(6967741 / 1000) = 6968 kHz

如果直接比较 Hz:

6968 × 1000 = 6968000 Hz
6968000 != 6967741

仍然会被误判。

新代码把当前硬件 DCLK 也按照完全相同的规则转换为 kHz:

adjusted_mode->crtc_clock = 6968 kHz
dclk_khz                  = 6968 kHz
比较结果                  = 相等

这样只有分辨率、同步参数或实际时钟真正改变时,Kernel 才会关闭平面并重设模式。

修复后的预期启动时序

U-Boot CRU 初始化
    ↓
板级 assigned-clock-rates 将 CPLL 设置为 216 MHz
    ↓
U-Boot VOP 请求 7 MHz
    ↓
选择 CPLL / 31,DCLK ≈ 6.967742 MHz
    ↓
U-Boot 从 DTS 读取 pixelclk-active=0
    ↓
显示 U-Boot Logo
    ↓
Linux CRU 初始化读取同一板级覆盖:CPLL 仍为 216 MHz
    ↓
不发生 216 MHz → 1 GHz 的中间切换
    ↓
VOP 节点再次请求 216 MHz,当前频率已经相同
    ↓
Kernel 当前 DCLK 与 adjusted_mode 都按 6968 kHz 比较
    ↓
不误判 mode_update,不关闭 Loader 显示平面
    ↓
Kernel Logo 接管

预期消除:

  • 两次 CPLL slow-mode 造成的两条横向花带。
  • U-Boot DCLK 分频溢出导致的超频花屏。
  • U-Boot/Kernel 像素时钟边沿切换。
  • Kernel 错误关闭显示平面导致的 Logo 跳变。

编译验证

1 U-Boot

执行:

cd /root/sdk/rv1106
./build.sh uboot

结果:

  • 返回码:0。
  • drivers/clk/rockchip/clk_rv1106.o 实际重新编译。
  • drivers/video/drm/panel-lh24030c50.o 实际重新编译。
  • U-Boot、SPL、TPL 和镜像打包成功。
  • 新 uboot.img 已安装到输出目录。

2 Kernel

执行:

cd /root/sdk/rv1106
./build.sh kernel

结果:

  • 返回码:0。
  • rockchip_drm_vop.c 参与完整 Kernel 编译并链接到 vmlinux。
  • jw_borad_v1.dtb 重新生成。
  • resource.img 和 boot.img 重新生成。
  • 新 boot.img 已安装到输出目录。

最终 DTB 验证

最终 DTB:

/root/sdk/rv1106/sysdrv/source/kernel/arch/arm/boot/dts/jw_borad_v1.dtb

1 CRU 完整 assigned-clock-rates

执行:

fdtget -t u \
  /root/sdk/rv1106/sysdrv/source/kernel/arch/arm/boot/dts/jw_borad_v1.dtb \
  /clock-controller@ff3a0000 assigned-clock-rates

输出:

1188000000 216000000 1104000000 400000000 200000000
100000000 300000000 100000000 100000000 200000000

第二项已经是 CPLL 216 MHz。

2 VOP 指定 CPLL 频率

执行:

fdtget -t u \
  /root/sdk/rv1106/sysdrv/source/kernel/arch/arm/boot/dts/jw_borad_v1.dtb \
  /vop@ff990000 assigned-clock-rates

输出:

216000000

3 像素时钟边沿

执行:

fdtget -t u \
  /root/sdk/rv1106/sysdrv/source/kernel/arch/arm/boot/dts/jw_borad_v1.dtb \
  /panel/display-timings/panel-timing pixelclk-active

输出:

0

启动后如已启用 debugfs,可检查:

cat /sys/kernel/debug/clk/clk_summary | grep -E 'cpll|dclk_vop'

期望值:

  • CPLL 约为 216000000 Hz。
  • DCLK VOP 约为 6967741 Hz 或显示为约 6968 kHz。
  • 启动过程中不应再出现 CPLL 先到 1 GHz、再回到 216 MHz 的过程。

三层实现改动清单

一、内核层(断电能力 + 死机兜底检测)

文件改动
sysdrv/source/kernel/drivers/power/reset/jw_poweroff.c 新增① 注册 pm_power_off:regulator_force_disable (power_hold) → 绕过 always‑on 拉低 GPIO0_A2 → 真正断电;② 注册 input handler 监听 KEY_POWER,持续按下 12s → kernel_power_off () 兜底(用户态死掉时仍生效)
.../drivers/power/reset/Makefile加 obj‑$(CONFIG_JW_POWEROFF) += jw_poweroff.o
.../drivers/power/reset/Kconfig加 CONFIG_JW_POWEROFF(默认 y,depends on POWER_RESET && INPUT)
.../arch/arm/configs/rv1106‑evb.config加 CONFIG_JW_POWEROFF=y
.../boot/dts/jw_rv1106‑adc‑btn.dtsi删除 restart‑poweroff 节点(否则它抢占 pm_power_off,断电变重启)
.../boot/dts/jw_borad_v1.dts加 poweroff‑ctrl {compatible = "jw,poweroff"; power‑supply = <&power_hold>; }

gpio‑keys 的 bell‑key (KEY_POWER/wakeup) 保留不动 —— 用户态 daemon 和 kernel handler 都靠它的输入事件,AOV 唤醒功能不破坏。&wdt 已使能无需改。

二、用户态守护进程(优雅关机)

文件改动
project/app/powerkeyd/powerkeyd.c 新增扫 /proc/bus/input/devices 找 KEY_POWER 设备,select 监听;长按 10s → 执行 poweroff(busybox 优雅关机,sync FS)
project/app/powerkeyd/Makefile 新增交叉编译后拷到 overlay/jw/usr/bin/powerkeyd(进 rootfs /usr/bin)
project/cfg/BoardConfig_IPC/overlay/jw/usr/bin/powerkeyd编译产物
.../overlay/jw/etc/init.d/S97powerkeyd 启用 / 改写启动 powerkeyd(现在整段注释,改为可用脚本)
.../overlay/jw/etc/init.d/S98watchdog 新增watchdog -T 15 /dev/watchdog & 喂狗(busybox CONFIG_WATCHDOG=y 已具备,CONFIG_DW_WATCHDOG=y 内核已具备)

三、U‑Boot 兜底层(死机复位后拦截)

文件改动
sysdrv/source/ukernel‑board/.../evb_rv1106.crk_board_late_init () 电池路径:power_gpio_init () 后、power_hold_on () 前,若 power_key_pressed () 持续 10s → 跳过 power_hold_on ()(A2 保持低:直接掉电)

配合链路:真死机 → WDT 15s 复位 → 用户仍按着键 → U‑Boot 检测长按 → 断电。

时序协调

  • 正常路径:10s 用户态 poweroff(sync FS)→ 内核 pm_power_off → A2 低 → 断电
  • 兜底路径:用户态死 → 12s 内核 handler → kernel_power_off → 断电(不 sync,但状态本就不可靠)

记得改

/root/sdk/rv1106/sysdrv/source/kernel/drivers/power/reset/Kconfig
/root/sdk/rv1106/sysdrv/source/kernel/drivers/power/reset/Makefile
config JW_POWEROFF
	bool "JW board power-off control"
	depends on OF
	help
	  JW battery board: provide a real power-off by force disabling the
	  power_hold regulator (GPIO0_A2) so the power latch is released, and
	  watch KEY_POWER input events to force a power-off when the power
	  button is held for ~15s (fallback when userspace is hung).
obj-$(CONFIG_JW_POWEROFF) += jw_poweroff.o

jw_poweroff.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * JW board power-off control
 *
 * Provides real power-off for the battery powered JW board based on
 * RV1106. Power is latched by the power_hold (GPIO0_A2) line: lowering it
 * cuts the main power supply, so "poweroff" really switches the board off
 * instead of just rebooting (the default restart-poweroff behaviour). The
 * kernel_power_off()/poweroff path ends up in ->pm_power_off, which force
 * disables the power_hold regulator (it is regulator-always-on, so a plain
 * disable is not enough).
 *
 * The driver also monitors KEY_POWER input events: if the power button is
 * kept pressed for ~12s it forces kernel_power_off() itself. This is a
 * fallback for the case where the userspace powerkeyd daemon is dead or
 * hung (userspace freeze), so a long press on the power key always manages
 * to shut the board down.
 *
 * Copyright (C) 2026 JW
 */

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/reboot.h>
#include <linux/regulator/consumer.h>
#include <linux/input.h>
#include <linux/workqueue.h>

#define JW_POWERKEY_HOLD_MS	15000

struct jw_power {
	struct platform_device *pdev;
	struct regulator *power_hold;
	struct input_handler handler;
	struct input_handle handle;
	struct delayed_work hold_work;
	bool key_pressed;
};

static struct jw_power *g_jw;

static void jw_cut_power(struct jw_power *jw)
{
	int ret;

	pr_emerg("jw_poweroff: cutting power (power_hold OFF)\n");

	ret = regulator_force_disable(jw->power_hold);
	if (ret)
		pr_emerg("jw_poweroff: regulator_force_disable failed (%d)\n",
			 ret);
}

/* pm_power_off: called at the end of any kernel_power_off()/poweroff. */
static void jw_do_power_off(void)
{
	struct jw_power *jw = g_jw;

	if (!jw)
		return;

	jw_cut_power(jw);

	for (;;)
		cpu_relax();
}

/* Emergency fallback when userspace (powerkeyd) is unresponsive. */
static void jw_hold_work(struct work_struct *work)
{
	pr_emerg("jw_poweroff: power key held %dms, forcing power off\n",
		 JW_POWERKEY_HOLD_MS);
	kernel_power_off();
}

static void jw_powerkey_handle(struct input_handle *handle,
			       unsigned int type, unsigned int code,
			       int value)
{
	struct jw_power *jw = handle->private;

	if (type != EV_KEY || code != KEY_POWER)
		return;

	if (value == 1) {
		if (jw->key_pressed)
			return;
		jw->key_pressed = true;
		pr_info("jw_poweroff: power key pressed, hold %dms to power off\n",
			JW_POWERKEY_HOLD_MS);
		queue_delayed_work(system_power_efficient_wq,
				   &jw->hold_work,
				   msecs_to_jiffies(JW_POWERKEY_HOLD_MS));
	} else if (value == 0) {
		if (!jw->key_pressed)
			return;
		jw->key_pressed = false;
		cancel_delayed_work(&jw->hold_work);
		pr_info("jw_poweroff: power key released, hold cancelled\n");
	}
}

static int jw_powerkey_connect(struct input_handler *handler,
			       struct input_dev *dev,
			       const struct input_device_id *id)
{
	struct jw_power *jw = handler->private;
	int ret;

	jw->handle.private = jw;
	jw->handle.dev = dev;
	jw->handle.handler = handler;
	jw->handle.name = "jw-powerkey";

	ret = input_register_handle(&jw->handle);
	if (ret)
		return ret;

	return input_open_device(&jw->handle);
}

static void jw_powerkey_disconnect(struct input_handle *handle)
{
	struct jw_power *jw = handle->private;

	cancel_delayed_work_sync(&jw->hold_work);
	input_close_device(handle);
	input_unregister_handle(handle);
}

static const struct input_device_id jw_powerkey_ids[] = {
	{
		.flags = INPUT_DEVICE_ID_MATCH_EVBIT |
			 INPUT_DEVICE_ID_MATCH_KEYBIT,
		.evbit = { BIT_MASK(EV_KEY) },
		.keybit = { BIT_MASK(KEY_POWER) },
	},
	{ },
};
MODULE_DEVICE_TABLE(input, jw_powerkey_ids);

static int jw_poweroff_probe(struct platform_device *pdev)
{
	struct device *dev = &pdev->dev;
	struct jw_power *jw;
	int ret;

	jw = devm_kzalloc(dev, sizeof(*jw), GFP_KERNEL);
	if (!jw)
		return -ENOMEM;
	jw->pdev = pdev;

	jw->power_hold = devm_regulator_get_optional(dev, "power");
	if (IS_ERR(jw->power_hold)) {
		dev_err(dev, "failed to get power_hold regulator (%ld)\n",
			PTR_ERR(jw->power_hold));
		return PTR_ERR(jw->power_hold);
	}

	INIT_DELAYED_WORK(&jw->hold_work, jw_hold_work);

	jw->handler.private = jw;
	jw->handler.id_table = jw_powerkey_ids;
	jw->handler.event = jw_powerkey_handle;
	jw->handler.connect = jw_powerkey_connect;
	jw->handler.disconnect = jw_powerkey_disconnect;
	jw->handler.name = "jw-powerkey";

	ret = input_register_handler(&jw->handler);
	if (ret) {
		dev_err(dev, "failed to register input handler (%d)\n", ret);
		return ret;
	}

	platform_set_drvdata(pdev, jw);
	g_jw = jw;
	pm_power_off = jw_do_power_off;

	dev_info(dev, "JW poweroff: real power-off + %dms power-key fallback ready\n",
		 JW_POWERKEY_HOLD_MS);

	return 0;
}

static int jw_poweroff_remove(struct platform_device *pdev)
{
	struct jw_power *jw = platform_get_drvdata(pdev);

	if (pm_power_off == jw_do_power_off)
		pm_power_off = NULL;
	if (g_jw == jw)
		g_jw = NULL;

	input_unregister_handler(&jw->handler);

	return 0;
}

static const struct of_device_id jw_poweroff_of_match[] = {
	{ .compatible = "jw,poweroff" },
	{ },
};
MODULE_DEVICE_TABLE(of, jw_poweroff_of_match);

static struct platform_driver jw_poweroff_driver = {
	.probe = jw_poweroff_probe,
	.remove = jw_poweroff_remove,
	.driver = {
		.name = "jw-poweroff",
		.of_match_table = jw_poweroff_of_match,
	},
};
module_platform_driver(jw_poweroff_driver);

MODULE_AUTHOR("JW");
MODULE_DESCRIPTION("JW board power-off control and long-press power key fallback");
MODULE_LICENSE("GPL v2");

设备树

/ {
	model = "JW Board V1";
	compatible = "jw,board-v1", "rockchip,rv1106";

	/* POWER_HOLD - 系统电源保持引脚,必须拉高以维持供电 */
	power_hold: power-hold-regulator {
		compatible = "regulator-fixed";
		pinctrl-names = "default";
		pinctrl-0 = <&power_hold_pins>;
		regulator-name = "power_hold";
		regulator-always-on;
		regulator-boot-on;
		enable-active-high;
		gpio = <&gpio0 RK_PA2 GPIO_ACTIVE_HIGH>;
	};

	/* 长按电源键关机 + 真正断电 (拉低 power_hold)
	 * 依赖 /drivers/power/reset/jw_poweroff.c */
	poweroff-ctrl {
		compatible = "jw,poweroff";
		power-supply = <&power_hold>;
	};
};

用户态脚本

/rv1106/project/app/powerkeyd/powerkeyd.c

/*
 * powerkeyd - JW board power key long-press power-off daemon
 *
 * Watches the gpio-keys input device that reports KEY_POWER (GPIO0_A3).
 * If the power button is held continuously for HOLD_MS it triggers a
 * graceful poweroff: busybox "poweroff" syncs the filesystems and asks
 * init to shut the system down, which ends in the kernel pm_power_off
 * handler (drivers/power/reset/jw_poweroff.c) that drops the power_hold
 * (GPIO0_A2) line and really cuts the board power.
 *
 * Copyright (C) 2026 JW
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <dirent.h>
#include <signal.h>
#include <time.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <linux/input.h>

#define HOLD_MS		10000
#define POLL_TIMEOUT_MS	200
#define PID_FILE	"/var/run/powerkeyd.pid"

static volatile sig_atomic_t g_stop;

static void on_signal(int sig)
{
	(void)sig;
	g_stop = 1;
}

static void write_pid(void)
{
	FILE *f;

	f = fopen(PID_FILE, "w");
	if (f) {
		fprintf(f, "%d\n", getpid());
		fclose(f);
	}
}

static long long now_ms(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}

static int has_key_power(int fd)
{
	unsigned char bits[KEY_MAX / 8 + 1];
	int i, ret;

	memset(bits, 0, sizeof(bits));
	ret = ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(bits)), bits);
	if (ret < 0)
		return 0;

	for (i = 0; i < KEY_MAX; i++) {
		if (i == KEY_POWER && (bits[i >> 3] & (1 << (i & 7))))
			return 1;
	}
	return 0;
}

static int open_powerkey_device(void)
{
	DIR *dir;
	struct dirent *ent;
	char path[64];
	int fd;

	dir = opendir("/dev/input");
	if (!dir)
		return -1;

	while ((ent = readdir(dir)) != NULL) {
		if (strncmp(ent->d_name, "event", 5) != 0)
			continue;
		snprintf(path, sizeof(path), "/dev/input/%.*s",
			 (int)(sizeof(path) - sizeof("/dev/input/") - 1),
			 ent->d_name);
		fd = open(path, O_RDONLY | O_NONBLOCK);
		if (fd < 0)
			continue;
		if (has_key_power(fd)) {
			closedir(dir);
			printf("powerkeyd: using %s for KEY_POWER\n", path);
			return fd;
		}
		close(fd);
	}
	closedir(dir);
	return -1;
}

static void trigger_poweroff(void)
{
	int ret;

	sync();
	printf("powerkeyd: power key held %dms, shutting down\n", HOLD_MS);

	/* Graceful path: let busybox init run shutdown scripts. */
	ret = system("poweroff >/dev/null 2>&1");
	if (ret != 0)
		ret = system("poweroff -f >/dev/null 2>&1");
	if (ret != 0)
		execl("/sbin/poweroff", "poweroff", "-f", (char *)NULL);

	/* Should never get here; let the kernel handler take over. */
	while (!g_stop)
		sleep(1000);
}

int main(int argc, char *argv[])
{
	struct pollfd	 p;
	struct input_event ev;
	int fd = -1;
	int i, foreground = 0;
	long long press_ms = 0;
	int pressed = 0;

	for (i = 1; i < argc; i++) {
		if (strcmp(argv[i], "--foreground") == 0 ||
		    strcmp(argv[i], "-F") == 0)
			foreground = 1;
	}

	if (!foreground) {
		fflush(NULL);
		i = fork();
		if (i < 0)
			exit(1);
		if (i > 0)
			_exit(0);
		setsid();
		i = fork();
		if (i < 0)
			exit(1);
		if (i > 0)
			_exit(0);
		chdir("/");
		umask(0);
	}

	write_pid();
	signal(SIGINT, on_signal);
	signal(SIGTERM, on_signal);
	signal(SIGHUP, SIG_IGN);

	while (!g_stop) {
		if (fd < 0) {
			fd = open_powerkey_device();
			if (fd < 0) {
				printf("powerkeyd: no KEY_POWER device yet, "
				       "retrying...\n");
				sleep(2);
				continue;
			}
			p.fd = fd;
			p.events = POLLIN;
			p.revents = 0;
		}

		if (pressed && now_ms() - press_ms >= HOLD_MS) {
			trigger_poweroff();
			break;
		}

		p.revents = 0;
		i = poll(&p, 1, POLL_TIMEOUT_MS);
		if (i < 0) {
			if (errno == EINTR)
				continue;
			perror("powerkeyd: poll");
			close(fd);
			fd = -1;
			continue;
		}
		if (i == 0 || !(p.revents & POLLIN))
			continue;

		while (read(fd, &ev, sizeof(ev)) == (ssize_t)sizeof(ev)) {
			if (ev.type != EV_KEY || ev.code != KEY_POWER)
				continue;
			if (ev.value == 1) {
				if (!pressed)
					printf("powerkeyd: power key down, "
					       "hold %dms to power off\n",
					       HOLD_MS);
				pressed = 1;
				press_ms = now_ms();
			} else if (ev.value == 0) {
				if (pressed)
					printf("powerkeyd: power key up\n");
				pressed = 0;
			}
		}
	}

	if (fd >= 0)
		close(fd);
	remove(PID_FILE);
	return 0;
}

/rv1106/project/app/powerkeyd/Makefile

ifeq ($(APP_PARAM), )
    APP_PARAM:=../Makefile.param
    include $(APP_PARAM)
endif

export LC_ALL=C
SHELL:=/bin/bash

CC := $(RK_APP_CROSS)-gcc

PKG_NAME := powerkeyd

# 二进制直接放进 overlay,随 rootfs 打包到 /usr/bin
OVERLAY_BIN := $(RK_APP_TOP_DIR)/../cfg/BoardConfig_IPC/overlay/jw/usr/bin

CFLAGS := -O2 -Wall -Wextra

all: $(PKG_NAME)

$(PKG_NAME): powerkeyd.c
	$(CC) $(CFLAGS) -o $@ $^
	@mkdir -p $(OVERLAY_BIN)
	@install -m 0755 $@ $(OVERLAY_BIN)/$@
	@echo "powerkeyd installed to $(OVERLAY_BIN)/$(PKG_NAME)"

clean:
	rm -f $(PKG_NAME)

.PHONY: all clean
  • phy-rockchip-csi2-dphy-hw.c:485 把 { 499, 0x0b } → { 499, 0x0e }(增大一整档 settle)。

原因

GC2145 传感器以 800x600@30 stream on 后,MIPI 链路上持续产生大量误码(信号质量/时序问题,属硬件层现象,不影响取流)

修改

/kernel/drivers/media/platform/rockchip/cif/mipi-csi2.c

注释掉 mipi-csi2.c:878 和 :932 两处 pr_err(错误计数逻辑保留)

  • 出现原因:

    内核启动时,usb摄像头识别比mipi摄像头快的话就会占用**/dev/video0**这个设备描述

  • 解决办法:

    将usb设备分配的设备号迁移至**/dev/video32+,让usb注册的video设备全部改成在32**之后

  • 修改过程

路径:

/kernel/drivers/media/usb/uvc/uvc_driver.c

/kernel/drivers/media/usb/uvc/uvcvideo.h

uvc_driver.c:2119

- ret = video_register_device(vdev, VFL_TYPE_VIDEO, -1);
+ ret = video_register_device(vdev, VFL_TYPE_VIDEO, UVC_VIDEO_NODE_NR);

uvcvideo.h

+ /* Reserve /dev/video0-31 for on-board devices (MIPI rkcif etc.).
+  * USB UVC cameras always register from nr 32 upward, so the MIPI
+  * camera keeps /dev/video0 regardless of probe order. */
+ #define UVC_VIDEO_NODE_NR		32
+

防止重复打印

/kernel/drivers/media/platform/rockchip/cif/capture.c

4455 - v4l2_info(&dev->v4l2_dev, "%s %d, state %d, curr_buf %p, next_buf %p\n",
4455 + v4l2_dbg(3, rkcif_debug, &dev->v4l2_dev,
4456 + 		"%s %d, state %d, curr_buf %p, next_buf %p\n",

原因

RK平台SDK,实现logo可变的方式有很多种

内核启动读取文件

这个方式最简单,写一个splash(开机精灵),启动的时候直接加载文件系统的图片。但是uboot的logo显示不了,开机显示logo有点慢。

修改boot.img中的资源

这个比较困难,而且要整个FIT文件烧录,烧录坏了还会导致内核无法启动。

flash加一个logo分区

这个最好,修改也方便,上位机直接从固定的地址烧录bmp图片进去就好了

流程:
  1. 修改.BoardConfig.mk文件

    export RK_PARTITION_CMD_IN_ENV="256K(env),1M@256K(idblock),1M(uboot),8M(boot),32M(rootfs),48M(oem),32M(userdata),1M(logo)"
    export RK_INCLUDE_LOGO_IMAGE=${RK_INCLUDE_LOGO_IMAGE:-y}
    export RK_LOGO_IMAGE_SOURCE=${RK_LOGO_IMAGE_SOURCE:-sysdrv/source/kernel/logo.bmp}
    export RK_PARTITION_FS_TYPE_CFG=rootfs@IGNORE@ubifs,oem@/oem@ubifs,userdata@/userdata@ubifs
    

    RK_INCLUDE_LOGO_IMAGE 控制完整 update.img 是否携带默认 logo.img。

    RK_INCLUDE_LOGO_IMAGE=n:不包含 Logo,升级时保留用户当前 Logo。用在升级的时候不更新logo

    RK_LOGO_IMAGE_SOURCE:指定默认 Logo 的源文件。

    RK_PARTITION_FS_TYPE_CFG: 指定分区的类型。

    rootfs@IGNORE@ubifs
    
    • IGNORE不是不使用。
    • 表示不由启动脚本重复挂载。
    • 内核已经根据启动参数将它挂载为 /。
    • 当前启动参数包含:
    oem@/oem@ubifs
    
    • mtd5经过UBI管理。
    • 挂载到 /oem。
    userdata@/userdata@ubifs
    
    • mtd6经过UBI管理。
    • 挂载到 /userdata。
  2. build.sh新增build_logo_img()生成logo.img

    参考:build.sh脚本修改示例

    build_logo_img() 负责:

    1. 检查 BoardConfig是否包含 (logo)。上面的RK_INCLUDE_LOGO_IMAGE=y/n
    2. 检查现有 env.img 是否包含 (logo)。env.img生成时有可能没有根据RK_PARTITION_CMD_IN_ENV生成相应的分区信息。
    3. 检查 RK_INCLUDE_LOGO_IMAGE。
    4. 校验源 BMP格式。
    5. 生成230454字节的 output/image/logo.img。
  3. mk-update_pack.sh

    这一步将logo.img添加到update.img中,不需要改动这个脚本。这个脚本直接就支持自动打包任意同名分区镜像。

    BoardConfig
      │
      │ export RK_INCLUDE_LOGO_IMAGE=y/n
      ▼
    project/build.sh
      │
      ▼
    build_logo_img()
      │
      ├── y:生成output/image/logo.img
      └── n:删除已有logo.img,不生成
      │
      ▼
    mk-update_pack.sh
      │
      ├── 找到logo.img:加入update.img
      └── 找不到logo.img:跳过logo分区内容
    
  4. 修改 mkimg 打包

    mkimg 是正式 Kernel/FIT镜像打包脚本,负责:

    生成resource.img
    准备Kernel
    准备DTB
    调用mkimage
    生成boot.img
    
    • Kernel构建系统将 scripts/resource_tool.c 编译成主机工具 scripts/resource_tool,mkimg 调用该工具生成 resource.img。

      ./resource_tool ${DTB_PATH} ${LOGO} ${LOGO_KERNEL} ${BATTERY_BMPS}
      
    • 去掉${LOGO} ${LOGO_KERNEL},删除LOGO和LOGO_KERNEL对应的变量

    • scripts/bmpconvert 这个用于logo转换,不需要了

    • cp logo.bmp ${objtree}/ 删除logo复制相关内容

    • 将DTB,kernel,resource.img合并成boot.img(原来就有的功能)

  5. 修改uboot源码

    这一步是让uboot原本加载从boot.img中加载logo改成读logo分区加载logo

    具体包括:

    1. 增加 load_logo_partition()。
    2. 使用 part_get_info_by_name(..., "logo", ...) 查找分区。
    3. 使用 blk_dread() 读取 BMP。
    4. 校验240×±320、24bpp、BI_RGB、230454字节。
    5. 处理正高度和负高度 BMP行序。
    6. 解码到 framebuffer。
    7. 修改 rockchip_show_logo(),使用 load_logo_partition()。
    8. 修改 rockchip_display_fixup(),复用同一个分区 Logo。
    9. 删除 logo,uboot 和 logo,kernel 文件名解析。
    10. 保留 load_bmp_logo() 给充电动画使用。
  6. 修改kernel源码

    原始流程中

    U-Boot根据 logo,kernel 从 boot.img/resource.img 准备 Kernel Logo framebuffer,Linux DRM驱动接管这块保留 framebuffer;Kernel本身不直接解析 resource.img。

    修改后的流程:

    Linux DRM驱动主动读取同一个 logo MTD分区,并把图片应用到 U-Boot保留的 framebuffer。

    具体修改:

    1. 使用 get_mtd_device_nm("logo") 查找 MTD。
    2. 跳过 SPI NAND坏块。
    3. 读取并校验 BMP。
    4. 转换到 framebuffer格式。
    5. 与 U-Boot已有像素执行 memcmp()。
    6. 相同时不重复复制,减少接管撕裂。
    7. 不同时更新 framebuffer并做 DMA同步。
    8. 读取失败时保留 U-Boot已经显示的图片。
    9. Logo错误不阻止 Kernel启动。
  7. 修改设备树

    基础 rv1106.dtsi 中原来存在:

    logo,uboot = "logo.bmp";
    logo,kernel = "logo_kernel.bmp";
    

    板级设备树必须显式删除:

    /delete-property/ logo,uboot;
    /delete-property/ logo,kernel;
    

    保留:

    logo,mode = "center";
    charge_logo,mode = "center";
    

    不能只是不重新配置,因为它们会从基础设备树继承下来。

在build_updateimg()中调用构建logo.img的方法

function build_updateimg(){
	# Enable building if env partition is no exist
	check_config ENV_SIZE || return 0

	IMAGE_PATH=$RK_PROJECT_OUTPUT_IMAGE
	PACK_TOOL_PATH=$SDK_ROOT_DIR/tools/linux/Linux_Pack_Firmware

	//这是新增的方法,需要调用
	build_logo_img

	# run update.img package script
	$PACK_TOOL_PATH/mk-update_pack.sh -id $RK_CHIP -i $IMAGE_PATH

	finish_build
}

脚本新增方法

function build_logo_img(){
	local bmp_bpp bmp_compression bmp_dib_size bmp_file_size bmp_height
	local bmp_image_size bmp_magic bmp_offset bmp_planes bmp_width
	local env_has_logo include_logo logo_dst logo_src logo_tmp
	local logo_file_size=230454

	# A stale env.img would make the packer silently omit (or retain) logo.
	env_has_logo=n
	if [ -f "$RK_PROJECT_OUTPUT_IMAGE/env.img" ] &&
		strings "$RK_PROJECT_OUTPUT_IMAGE/env.img" | grep -Fq '(logo)'; then
		env_has_logo=y
	fi

	if [[ "$RK_PARTITION_CMD_IN_ENV" != *"(logo)"* ]]; then
		if [ "$env_has_logo" = "y" ]; then
			msg_error "env.img still contains the logo partition; rebuild env.img before packing update.img"
			return 1
		fi
		return 0
	fi

	if [ "$env_has_logo" != "y" ]; then
		msg_error "env.img does not contain the configured logo partition; run ./build.sh firmware or ./build.sh all first"
		return 1
	fi

	include_logo=${RK_INCLUDE_LOGO_IMAGE:-y}
	logo_dst="$RK_PROJECT_OUTPUT_IMAGE/logo.img"
	case "$include_logo" in
		y)
			;;
		n)
			# logo.img is a generated artifact. Removing it is necessary because
			# mk-update_pack.sh includes every matching image left in this dir.
			rm -f "$logo_dst"
			msg_info "Skip logo.img to preserve the Logo already stored on the device"
			return 0
			;;
		*)
			msg_error "RK_INCLUDE_LOGO_IMAGE must be y or n: $include_logo"
			return 1
			;;
	esac

	logo_src=${RK_LOGO_IMAGE_SOURCE:-sysdrv/source/kernel/logo.bmp}
	[[ "$logo_src" != /* ]] && logo_src="$SDK_ROOT_DIR/$logo_src"
	if [ ! -f "$logo_src" ]; then
		msg_error "Not found factory Logo BMP: $logo_src"
		return 1
	fi

	bmp_magic=$(od -An -tx1 -N2 "$logo_src" | tr -d '[:space:]')
	bmp_file_size=$(od -An -tu4 -j2 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_offset=$(od -An -tu4 -j10 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_dib_size=$(od -An -tu4 -j14 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_width=$(od -An -td4 -j18 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_height=$(od -An -td4 -j22 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_planes=$(od -An -tu2 -j26 -N2 "$logo_src" | tr -d '[:space:]')
	bmp_bpp=$(od -An -tu2 -j28 -N2 "$logo_src" | tr -d '[:space:]')
	bmp_compression=$(od -An -tu4 -j30 -N4 "$logo_src" | tr -d '[:space:]')
	bmp_image_size=$(od -An -tu4 -j34 -N4 "$logo_src" | tr -d '[:space:]')

	if [ "$bmp_magic" != "424d" ] ||
		[ "$bmp_file_size" != "$logo_file_size" ] ||
		[ "$bmp_offset" != "54" ] || [ "$bmp_dib_size" != "40" ] ||
		[ "$bmp_width" != "240" ] ||
		{ [ "$bmp_height" != "320" ] && [ "$bmp_height" != "-320" ]; } ||
		[ "$bmp_planes" != "1" ] || [ "$bmp_bpp" != "24" ] ||
		[ "$bmp_compression" != "0" ] ||
		{ [ "$bmp_image_size" != "0" ] && [ "$bmp_image_size" != "230400" ]; }; then
		msg_error "Invalid factory Logo BMP; expected 240x(+/-)320, 24bpp BI_RGB, bfSize=$logo_file_size: $logo_src"
		return 1
	fi

	if [ "$(stat -c %s "$logo_src")" -lt "$logo_file_size" ]; then
		msg_error "Factory Logo BMP is shorter than bfSize: $logo_src"
		return 1
	fi

	mkdir -p "$RK_PROJECT_OUTPUT_IMAGE"
	logo_tmp="$logo_dst.tmp.$$"
	if ! dd if="$logo_src" of="$logo_tmp" bs=$logo_file_size count=1 \
		iflag=fullblock status=none; then
		rm -f "$logo_tmp"
		msg_error "Failed to generate logo.img from $logo_src"
		return 1
	fi
	if [ "$(stat -c %s "$logo_tmp")" -ne "$logo_file_size" ]; then
		rm -f "$logo_tmp"
		msg_error "Generated logo.img has an unexpected size"
		return 1
	fi
	mv -f "$logo_tmp" "$logo_dst"
	msg_info "Generated factory logo.img: $logo_dst ($logo_file_size bytes)"
}

查看支持的主频

cat /sys/devices/system/cpu/cpufreq/policy0/scaling_available_frequencies
408000 600000 816000 1104000 1200000 1296000 1416000 1512000 1608000
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq
1296000
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_governor
ondemand

在官方提供的根设备树rv1106.dtsi中配置了

cpu0_opp_table: cpu0-opp-table{
 	//前面还有
	opp-1104000000 {
		opp-hz = /bits/ 64 <1104000000>;
		opp-microvolt = <850000 850000 1000000>;
		clock-latency-ns = <40000>;
	};
	opp-1200000000 {
		opp-hz = /bits/ 64 <1200000000>;
		opp-microvolt = <850000 850000 1000000>;
		clock-latency-ns = <40000>;
	};
	//后面还有
}

只需要在板级设备下禁用高的频率

// 在rv1106.dtsi中,限制最高1104000hz
&cpu0_opp_table {
	opp-1200000000 {
		status = "disabled";
	};

	opp-1296000000 {
		status = "disabled";
	};

	opp-1416000000 {
		status = "disabled";
	};

	opp-1512000000 {
		status = "disabled";
	};

	opp-1608000000 {
		status = "disabled";
	};

	opp-1104000000 {
		status = "okay";
	};
};

ROOT用户添加密码登录

路径:overlay下 jw/etc/inittab文件

::respawn:-/bin/sh
#注释掉
#::respawn:-/bin/sh
#ttyFIQ0::respawn:/sbin/getty -L  ttyFIQ0 0 vt100
#启用
ttyFIQ0::respawn:/sbin/getty -L  ttyFIQ0 0 vt100
  • ttyFIQ0:当前Rockchip调试串口。

  • 0:保持Bootloader/Kernel已经设置的波特率。

  • getty:显示登录提示,然后调用 /bin/login。

  • 登录成功后才启动用户shell。

修改后串口会显示类似:(JW Board V1 login)来自:/etc/hostname

JW Board V1 login:
Password:

当前root密码已经设置,/etc/shadow 中的默认密码实际是:

用户名:root
密码:rockchip

因为BusyBox启用了 CONFIG_FEATURE_SECURETTY,建议同时创建:

/etc/securetty文件写入

ttyFIQ0

表示允许root从这个串口登录。

普通用户添加串口登录

  • 修改 etc/passwd
jw:x:1000:1000:JW User:/home/jw:/bin/sh
用户名 : 密码位置 	 : UID : GID : 说明 		: 用户目录 : 登录Shell
jw    :    x      :1000 : 1000: JW User   :/home/jw:/bin/sh
  • 修改 /etc/group
jw:x:1000:
  • 生成jw的密码
    • 在SDK编译主机上执行:
openssl passwd -1 -salt "$(openssl rand -hex 4)"
    • 输入准备设置的密码,会输出类似:
$1$xxxxxxxx$xxxxxxxxxxxxxxxxxxxxxx
    • 在 etc/shadow 中添加:不是明文密码。
jw:$1$a1b2c3d4$xxxxxxxxxxxxxxxxxxxxxx:10933:0:99999:7:::
    • 设置权限
chmod 600 /etc/shadow
  • 创建用户目录
mkdir -p /home/jw
chown 1000:1000 /home/jw
chmod 750 /home/jw
  • 禁止root从串口登录
/etc/inittab 中禁止直接启动root shell:

#::respawn:-/bin/sh

ttyFIQ0::respawn:/sbin/getty -L ttyFIQ0 0 vt100
/etc/securetty

不要在里面添加 ttyFIQ0。这样 BusyBox login 会拒绝 root 从这个串口登录,但允许 jw 登录。

新增panel-lh24030c50.c文件

配置makefile

  • 路径:/kernel/drivers/gpu/drm/panel/Makefile

  • 新增一行

    obj-$(CONFIG_DRM_PANEL_LH24030C50) += panel-lh24030c50.o
    
    • 当内核配置系统检测到 CONFIG_DRM_PANEL_LH24030C50 被赋值为 y(内建)或 m(模块)时,该行会生效。
    • 将 .c 文件编译为对应的 .o 目标文件,并最终链接进内核镜像(y)或生成独立的 .ko 内核模块文件(m)。

配置Kconfig

  • 路径:/kernel/drivers/gpu/drm/panel/Kconfig

  • 新增

    config DRM_PANEL_LH24030C50
    	tristate "LH24030C50 RGB panel"
    	depends on OF && SPI
    	depends on BACKLIGHT_CLASS_DEVICE
    	help
    	  Say Y here if you want to enable support for the LH24030C50
    	  RGB panel module driven by an ST7789V-compatible controller.
    
    • tristate:表示该选项支持三种状态——Y(内建)、M(模块)、N(不编译)。对应Makefile中的 obj-*。
    • depends on OF && SPI:依赖约束。只有启用了设备树(OF)和SPI总线支持时,该选项才会出现在菜单中。这防止了非硬件平台误选,也保证了编译时能引用到SPI子系统的头文件和符号。
    • depends on BACKLIGHT_CLASS_DEVICE:强制依赖背光类设备。因为面板需要背光调节功能,若内核未开启背光支持,该驱动编译会因找不到 struct backlight_device 等定义而失败。
    • help:给开发者或用户看的说明文本,描述该驱动适用的硬件型号。

内核配置

开启内核配置

kernel_defconfig

CONFIG_DRM_PANEL_LH24030C50=y #与上面makefile中的 CONFIG_DRM_PANEL_LH24030C50 一致
# CONFIG_DRM_PANEL_SIMPLE=y 如果用设备树用simple-panel
CONFIG_BACKLIGHT_CLASS_DEVICE=y
CONFIG_BACKLIGHT_GPIO=y

关闭内核配置

#CONFIG_FB_TFT=y
#CONFIG_DRM_PANEL_SITRONIX_ST7789V=y
#CONFIG_FB_TFT_ST7735R=y
#CONFIG_FB_TFT_ST7789V=y

频率过高

rv1106g-luckfox-pico-pro-max.dts

/**********CRU**********/
&cru {
	assigned-clocks = <&cru 3>;
	assigned-clock-rates = <216000000>;
};

aa_rv1106-lcd.dtsi

&vop {
	assigned-clocks = <&cru 201>;
	assigned-clock-parents = <&cru 3>;
	status = "okay";
};
timing0: panel-timing {
	clock-frequency = <7000000>;
	hactive = <240>;
	vactive = <320>;
	hfront-porch = <1>;
	hback-porch = <20>;
	hsync-len = <10>;
	vfront-porch = <8>;
	vback-porch = <2>;
	vsync-len = <6>;
	hsync-active = <0>;
	vsync-active = <0>;
	de-active = <1>;
	pixelclk-active = <0>;
};

rv1106-luckfox-pico-pro-max-ipc.dtsi

注释
/*****************************PINCTRL********************************/
// SPI
// &spi0 {
// 	pinctrl-0 = <&spi0m0_clk &spi0m0_miso &spi0m0_mosi &spi0m0_cs0>;
// 	#address-cells = <1>;
// 	#size-cells = <0>;
// 	spidev@0 {
// 	compatible = "rockchip,spidev";
// 		spi-max-frequency = <50000000>;
// 		reg = <0>;
// 	};

// 	fbtft@0 {
// 		compatible = "sitronix,st7789v";
// 		reg = <0>;
// 		spi-max-frequency = <20000000>;
// 		fps = <30>;
// 		buswidth = <8>;
// 		debug = <0x7>;
// 		led-gpios = <&gpio2 RK_PB0 GPIO_ACTIVE_HIGH>;//BL
// 		dc-gpios = <&gpio2 RK_PB1 GPIO_ACTIVE_HIGH>;//DC
// 		reset-gpios = <&gpio1 RK_PC3 GPIO_ACTIVE_LOW>;//RES
// 	};
// };
// SPDX-License-Identifier: (GPL-2.0+ OR MIT)
/*
 * Copyright (c) 2022 Rockchip Electronics Co., Ltd.
 */

#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/pinctrl/rockchip.h>
#include <dt-bindings/display/media-bus-format.h>
#include <dt-bindings/clock/rv1106-cru.h>

/ {
	backlight: backlight {
		status = "okay";
		compatible = "gpio-backlight";
		gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_HIGH>;
		default-on;
		power-supply = <&vcc_3v3>;
	};
	reserved-memory {
		#address-cells = <1>;
		#size-cells = <1>;
		ranges;

		drm_logo: drm-logo@00000000 {
			compatible = "rockchip,drm-logo";
			reg = <0x0 0x0>;
		};
	};
};

&spi1 {
	status = "okay";
	/delete-property/ pinctrl-0;
	/delete-property/ pinctrl-names;
	/* No pinctrl - pins used as GPIO for 9-bit SPI bitbang */

	panel: panel@0 {
		compatible = "lh,lh24030c50";
		reg = <0>;
		spi-max-frequency = <10000000>;
		reset-gpios = <&gpio1 RK_PB1 GPIO_ACTIVE_LOW>;
		spi-scl-gpios = <&gpio4 RK_PA7 GPIO_ACTIVE_HIGH>;
		spi-sdi-gpios = <&gpio4 RK_PA1 GPIO_ACTIVE_HIGH>;
		spi-cs-gpios = <&gpio4 RK_PA5 GPIO_ACTIVE_HIGH>;
		power-supply = <&vcc_3v3>;
		backlight = <&backlight>;
		status = "okay";

		display-timings {
			native-mode = <&timing0>;
			timing0: panel-timing {
				clock-frequency = <7000000>;
				hactive = <240>;
				vactive = <320>;
				hfront-porch = <0>;
				hback-porch = <51>;
				hsync-len = <51>;
				vfront-porch = <0>;
				vback-porch = <12>;
				vsync-len = <12>;
				hsync-active = <0>;
				vsync-active = <0>;
				de-active = <1>;
				pixelclk-active = <1>;
			};
		};

		port {
			panel_in_rgb: endpoint {
				remote-endpoint = <&rgb_out_panel>;
			};
		};
	};
};

&display_subsystem {
	status = "okay";
	logo-memory-region = <&drm_logo>;
};

&rgb {
	status = "okay";
	pinctrl-names = "default";
	pinctrl-0 = <&lcd_pins>;
	ports {
		rgb_out: port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			rgb_out_panel: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&panel_in_rgb>;
			};
		};
	};
};

&rgb_in_vop {
	status = "okay";
};

&route_rgb {
	status = "okay";
};

&vop {
	assigned-clocks = <&cru PLL_CPLL>;
	assigned-clock-rates = <216000000>;
	status = "okay";
};

/*
 * LCD RGB data D8-D17 reuses the SDIO pin group.
 */
&sdio {
	status = "disabled";
};

&emmc {
	status = "disabled";
};

// SPDX-License-Identifier: GPL-2.0-only
/*
 * LH24030C50 RGB panel driver based on the ST7789V controller.
 */

#include <linux/delay.h>
#include <linux/gpio/consumer.h>
#include <linux/module.h>
#include <linux/of.h>
#include <linux/regulator/consumer.h>
#include <linux/spi/spi.h>
#include <video/display_timing.h>
#include <video/videomode.h>
#include <video/of_videomode.h>

#include <drm/drm_device.h>
#include <drm/drm_modes.h>
#include <drm/drm_panel.h>
#include <linux/media-bus-format.h>

struct st7789v {
	struct drm_panel panel;
	struct spi_device *spi;
	struct gpio_desc *reset;
	struct gpio_desc *sclk;
	struct gpio_desc *mosi;
	struct gpio_desc *cs;
	struct regulator *power;
};
static inline struct st7789v *panel_to_st7789v(struct drm_panel *panel)
{
	return container_of(panel, struct st7789v, panel);
}

static void st7789v_spi_bitbang(struct st7789v *ctx, int dc, u8 data)
{
	int i;

	gpiod_set_value_cansleep(ctx->cs, 0);
	udelay(1);
	gpiod_set_value_cansleep(ctx->sclk, 0);
	gpiod_set_value_cansleep(ctx->mosi, dc ? 1 : 0);
	udelay(2);
	gpiod_set_value_cansleep(ctx->sclk, 1);
	udelay(2);
	for (i = 0; i < 8; i++) {
		gpiod_set_value_cansleep(ctx->sclk, 0);
		gpiod_set_value_cansleep(ctx->mosi, (data >> (7 - i)) & 1);
		udelay(2);
		gpiod_set_value_cansleep(ctx->sclk, 1);
		udelay(2);
	}
	gpiod_set_value_cansleep(ctx->sclk, 0);
	udelay(1);
	gpiod_set_value_cansleep(ctx->cs, 1);
	udelay(1);
}

static int st7789v_write_command(struct st7789v *ctx, u8 cmd)
{
	st7789v_spi_bitbang(ctx, 0, cmd);
	return 0;
}

static int st7789v_write_data(struct st7789v *ctx, u8 data)
{
	st7789v_spi_bitbang(ctx, 1, data);
	return 0;
}

static void st7789v_spi_idle(struct st7789v *ctx)
{
	gpiod_set_value_cansleep(ctx->cs, 1);
	gpiod_set_value_cansleep(ctx->sclk, 0);
	gpiod_set_value_cansleep(ctx->mosi, 0);
}

static const struct drm_display_mode default_mode = {
	.clock = 7000,
	.hdisplay = 240,
	.hsync_start = 240,
	.hsync_end = 240 + 51,
	.htotal = 240 + 51 + 51,
	.vdisplay = 320,
	.vsync_start = 320,
	.vsync_end = 320 + 12,
	.vtotal = 320 + 12 + 12,
	.flags = DRM_MODE_FLAG_NHSYNC | DRM_MODE_FLAG_NVSYNC,
};

static int st7789v_get_modes(struct drm_panel *panel,
			     struct drm_connector *connector)
{
	struct drm_display_mode *mode;
	const u32 bus_format = MEDIA_BUS_FMT_RGB666_1X18;
	const struct drm_display_mode *src_mode = &default_mode;
	struct videomode vm;

	if (!of_get_videomode(panel->dev->of_node, &vm, 0)) {
		struct drm_display_mode *dt_mode;

		dt_mode = drm_mode_create(connector->dev);
		if (dt_mode) {
			drm_display_mode_from_videomode(&vm, dt_mode);
			dt_mode->type = DRM_MODE_TYPE_DRIVER |
					DRM_MODE_TYPE_PREFERRED;
			src_mode = dt_mode;
		}
	}

	mode = drm_mode_duplicate(connector->dev, src_mode);
	if (!mode) {
		dev_err(panel->dev, "failed to add mode %ux%ux@%u\n",
			src_mode->hdisplay, src_mode->vdisplay,
			drm_mode_vrefresh(src_mode));
		return -ENOMEM;
	}

	drm_mode_set_name(mode);
	mode->type = DRM_MODE_TYPE_DRIVER | DRM_MODE_TYPE_PREFERRED;
	drm_mode_probed_add(connector, mode);

	connector->display_info.width_mm = 43;
	connector->display_info.height_mm = 57;

	drm_display_info_set_bus_formats(&connector->display_info,
					  &bus_format, 1);
	connector->display_info.bus_flags = DRM_BUS_FLAG_DE_HIGH |
					    DRM_BUS_FLAG_PIXDATA_DRIVE_NEGEDGE;

	return 1;
}
static void LCD_Init(struct st7789v *ctx)
{
	st7789v_spi_idle(ctx);
	st7789v_write_command(ctx, 0x01);
	msleep(150);
	st7789v_write_command(ctx, 0x11);
	msleep(120);
	//--------------------------------Display and color format setting----------------------------//
	st7789v_write_command(ctx, 0x36);
	st7789v_write_data(ctx, 0x08);
	st7789v_write_command(ctx, 0x3a);
	st7789v_write_data(ctx, 0x06);

	st7789v_write_command(ctx, 0xB0);
	st7789v_write_data(ctx, 0x11); // 第1参数:RM=1, DM=01(RGB)
	st7789v_write_data(ctx, 0xC0); // 第2参数:EPF1 EPF0=11,其余控制位0

	st7789v_write_command(ctx, 0xB1);
	st7789v_write_data(ctx, 0x40);
	st7789v_write_data(ctx, 0x04);
	st7789v_write_data(ctx, 0x0a);
	//--------------------------------ST7789S Frame rate setting----------------------------------//
	st7789v_write_command(ctx, 0xb2);
	st7789v_write_data(ctx, 0x0c);
	st7789v_write_data(ctx, 0x0c);
	st7789v_write_data(ctx, 0x00);
	st7789v_write_data(ctx, 0x33);
	st7789v_write_data(ctx, 0x33);
	st7789v_write_command(ctx, 0xb7);
	st7789v_write_data(ctx, 0x35);
	//---------------------------------ST7789S Power setting--------------------------------------//
	st7789v_write_command(ctx, 0xbb);
	st7789v_write_data(ctx, 0x2b);
	st7789v_write_command(ctx, 0xc0);
	st7789v_write_data(ctx, 0x2c);
	st7789v_write_command(ctx, 0xc2);
	st7789v_write_data(ctx, 0x01);
	st7789v_write_command(ctx, 0xc3);
	st7789v_write_data(ctx, 0x11);
	st7789v_write_command(ctx, 0xc4);
	st7789v_write_data(ctx, 0x20);
	st7789v_write_command(ctx, 0xc6);
	st7789v_write_data(ctx, 0x0f);
	st7789v_write_command(ctx, 0xd0);
	st7789v_write_data(ctx, 0xa4);
	st7789v_write_data(ctx, 0xa1);
	//--------------------------------ST7789S gamma setting---------------------------------------//
	st7789v_write_command(ctx, 0xe0);
	st7789v_write_data(ctx, 0xd0);
	st7789v_write_data(ctx, 0x00);
	st7789v_write_data(ctx, 0x06);
	st7789v_write_data(ctx, 0x09);
	st7789v_write_data(ctx, 0x0b);
	st7789v_write_data(ctx, 0x2a);
	st7789v_write_data(ctx, 0x3c);
	st7789v_write_data(ctx, 0x55);
	st7789v_write_data(ctx, 0x4b);
	st7789v_write_data(ctx, 0x08);
	st7789v_write_data(ctx, 0x16);
	st7789v_write_data(ctx, 0x14);
	st7789v_write_data(ctx, 0x19);
	st7789v_write_data(ctx, 0x20);

	st7789v_write_command(ctx, 0xe1);
	st7789v_write_data(ctx, 0xd0);
	st7789v_write_data(ctx, 0x00);
	st7789v_write_data(ctx, 0x06);
	st7789v_write_data(ctx, 0x09);
	st7789v_write_data(ctx, 0x0b);
	st7789v_write_data(ctx, 0x29);
	st7789v_write_data(ctx, 0x36);
	st7789v_write_data(ctx, 0x54);
	st7789v_write_data(ctx, 0x4b);
	st7789v_write_data(ctx, 0x0d);
	st7789v_write_data(ctx, 0x16);
	st7789v_write_data(ctx, 0x14);
	st7789v_write_data(ctx, 0x21);
	st7789v_write_data(ctx, 0x20);
}

static int st7789v_prepare(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	int ret;

	ret = regulator_enable(ctx->power);
	if (ret)
		return ret;

	gpiod_set_value_cansleep(ctx->reset, 1);
	msleep(50);
	gpiod_set_value_cansleep(ctx->reset, 0);
	msleep(50);
	st7789v_spi_idle(ctx);
	msleep(150);

	LCD_Init(ctx);

	return 0;
}

static int st7789v_enable(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);
	msleep(20); // 等待第一帧脏画面刷新完毕
	st7789v_write_command(ctx, 0x29);
	msleep(50);
	return 0;
}

static int st7789v_disable(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);

	st7789v_write_command(ctx, 0x28);

	return 0;
}

static int st7789v_unprepare(struct drm_panel *panel)
{
	struct st7789v *ctx = panel_to_st7789v(panel);

	st7789v_write_command(ctx, 0x10);
	msleep(120);
	gpiod_set_value_cansleep(ctx->reset, 1);
	st7789v_spi_idle(ctx);

	regulator_disable(ctx->power);

	return 0;
}

static const struct drm_panel_funcs st7789v_drm_funcs = {
	.disable	= st7789v_disable,
	.enable		= st7789v_enable,
	.get_modes	= st7789v_get_modes,
	.prepare	= st7789v_prepare,
	.unprepare	= st7789v_unprepare,
};

static int st7789v_probe(struct spi_device *spi)
{
	struct st7789v *ctx;
	int ret;

	ctx = devm_kzalloc(&spi->dev, sizeof(*ctx), GFP_KERNEL);
	if (!ctx)
		return -ENOMEM;

	spi_set_drvdata(spi, ctx);
	ctx->spi = spi;

	drm_panel_init(&ctx->panel, &spi->dev, &st7789v_drm_funcs,
		       DRM_MODE_CONNECTOR_DPI);

	ctx->power = devm_regulator_get(&spi->dev, "power");
	if (IS_ERR(ctx->power))
		return PTR_ERR(ctx->power);

	ctx->reset = devm_gpiod_get(&spi->dev, "reset", GPIOD_OUT_HIGH);
	if (IS_ERR(ctx->reset)) {
		dev_err(&spi->dev, "Couldn't get our reset line\n");
		return PTR_ERR(ctx->reset);
	}

	ctx->sclk = devm_gpiod_get(&spi->dev, "spi-scl", GPIOD_OUT_LOW);
	if (IS_ERR(ctx->sclk))
		return PTR_ERR(ctx->sclk);
	ctx->mosi = devm_gpiod_get(&spi->dev, "spi-sdi", GPIOD_OUT_LOW);
	if (IS_ERR(ctx->mosi))
		return PTR_ERR(ctx->mosi);
	ctx->cs = devm_gpiod_get(&spi->dev, "spi-cs", GPIOD_OUT_HIGH);
	if (IS_ERR(ctx->cs))
		return PTR_ERR(ctx->cs);

	st7789v_spi_idle(ctx);

	ret = drm_panel_of_backlight(&ctx->panel);
	if (ret)
		return ret;

	drm_panel_add(&ctx->panel);

	return 0;
}

static int st7789v_remove(struct spi_device *spi)
{
	struct st7789v *ctx = spi_get_drvdata(spi);

	drm_panel_remove(&ctx->panel);

	return 0;
}

static const struct of_device_id st7789v_of_match[] = {
	{ .compatible = "lh,lh24030c50" },
	{ }
};
MODULE_DEVICE_TABLE(of, st7789v_of_match);

static struct spi_driver st7789v_driver = {
	.probe = st7789v_probe,
	.remove = st7789v_remove,
	.driver = {
		.name = "lh24030c50",
		.of_match_table = st7789v_of_match,
	},
};
module_spi_driver(st7789v_driver);

MODULE_AUTHOR("Maxime Ripard <maxime.ripard@free-electrons.com>");
MODULE_DESCRIPTION("LH24030C50 RGB LCD panel driver");
MODULE_LICENSE("GPL v2");

幸狐sdk

建议直接用配置好的docker环境

  • 直接在windows安装docker
  • 再加载提供的镜像。
  • 再使用vscode安装dev contarner插件,直接使用界面进行开发。

安装docker

省略

加载镜像

镜像文件在luckfox_pico_docker.tar

docker load -i xxx.tar

启动容器

  • windows下(必须使用绝对路径)
docker run -it -d --name rk-develop --privileged -v E:\rk-develop\v-share:/home 4a59acf452d5 /bin/bash
  • linux下(可以使用相对路径)
docker run -it -d --name rk-develop --privileged -v ./v-share:/home 4a59acf452d5 /bin/bash

复制SDK到容器

直接将文件复制到启动容器命令的路径:E:\rk-develop\v-share,./v-share

vscode操作

安装插件

安装插件

安装插件

省略

可以借鉴幸狐官方:[luckfox-sdk环境配置](SDK 镜像编译 | LUCKFOX WIKI)

幸狐的SDK和RK官方的SDK目录结构基本一致

SDK源码:SDK-Source

docker开发环境:docker开发环境

vm开发环境:vm开发环境

编译生成的文件放在:compile-files

步骤:

  1. 选择开发板,作用是生成一下步的**.BoardConfig.mk**文件

    详细步骤:002_幸狐sdk编译-选择开发板

  2. 修改.BoardConfig.mk文件,主要是注释/删除摄像头配置、修改env适配flash大小等操作

    详细步骤:003_幸狐sdk编译-修改.BoardConfig.mk

  3. 适配屏幕,这一步需要修改设备树和驱动文件用来点亮屏幕。

    详细步骤:004_幸狐sdk编译-适配显示屏

  4. 适配摄像头,需要修改设备树和驱动文件支持摄像头

作用:

生成.BoardConfig.mk,文件是所有配置信息的集合。

  • uboot的配置信息:
# Uboot defconfig
export RK_UBOOT_DEFCONFIG=luckfox_rv1106_uboot_defconfig
  • 内核配置信息:
# Kernel defconfig
export RK_KERNEL_DEFCONFIG=luckfox_rv1106_linux_defconfig
  • 内核设备树
# Kernel dts
export RK_KERNEL_DTS=rv1106g-luckfox-pico-pro-max.dts
  • oem分区配置
# enable install app to oem partition
export RK_BUILD_APP_TO_OEM_PARTITION=y
  • 等等...

步骤:

执行

./build.sh lunch 
You're building on Linux
  Lunch menu...pick the Luckfox Pico hardware version:
  选择 Luckfox Pico 硬件版本:
                [0] RV1103_Luckfox_Pico
                [1] RV1103_Luckfox_Pico_Mini
                [2] RV1103_Luckfox_Pico_Plus
                [3] RV1103_Luckfox_Pico_WebBee
                [4] RV1106_Luckfox_Pico_Pro_Max
                [5] RV1106_Luckfox_Pico_Ultra
                [6] RV1106_Luckfox_Pico_Pi
                [7] RV1106_Luckfox_Pico_86Panel
                [8] RV1106_Luckfox_Pico_Zero
                [9] custom
Which would you like? [0~9][default:0]: 4
  Lunch menu...pick the boot medium:
  选择启动媒介:
                [0] SD_CARD
                [1] SPI_NAND
Which would you like? [0~1][default:0]: 1
  Lunch menu...pick the system version:
  选择系统版本:
                [0] Buildroot 
Which would you like? [0][default:0]: 0 

配置分析

#misc image
export RK_MISC=wipe_all-misc.img

wipe_all 类型的 misc 镜像通常用于:

  • 首次烧录后:清除残留数据,确保设备以干净状态启动
  • 出厂重置:清除 userdata、oem 等用户数据分区

删除overlay文件的脚本

# specify pre.sh for delete/overlay files
export RK_PRE_BUILD_OEM_SCRIPT=luckfox-buildroot-oem-pre.sh

# specify post.sh for delete/overlay files
export RK_PRE_BUILD_USERDATA_SCRIPT=luckfox-userdata-pre.sh

配置overlay文件的脚本

# declare overlay directory
export RK_POST_OVERLAY="overlay-luckfox-config overlay-luckfox-buildroot-init overlay-luckfox-buildroot-shadow overlay-luckfox-wifibt-firmware"

修改flash占用

210M(rootfs)改成60M(rootfs)

export RK_PARTITION_CMD_IN_ENV="256K(env),256K@256K(idblock),512K(uboot),4M(boot),30M(oem),10M(userdata),60M(rootfs)"

关闭wifi以及rockchip测试

注释


# enable rockchip test
#export RK_ENABLE_ROCKCHIP_TEST=y

# enable rockchip wifi
#export RK_ENABLE_WIFI=y

# config wifi ssid and passwd
#export LF_WIFI_SSID="Your wifi ssid"
#export LF_WIFI_PSK="Your wifi password"

关闭摄像头相关配置

注释

# Config sensor IQ files
# RK_CAMERA_SENSOR_IQFILES format:
#     "iqfile1 iqfile2 iqfile3 ..."
# ./build.sh media and copy <SDK root dir>/output/out/media_out/isp_iqfiles/$RK_CAMERA_SENSOR_IQFILES
#export RK_CAMERA_SENSOR_IQFILES="sc4336_OT01_40IRC_F16.json sc3336_CMK-OT2119-PC1_30IRC-F16.json mis5001_CMK-OT2115-PC1_30IRC-F16.json"
#export RK_CAMERA_SENSOR_IQFILES="sc4336_OT01_40IRC_F16.json sc3336_CMK-OT2119-PC1_30IRC-F16.json sc530ai_CMK-OT2115-PC1_30IRC-F16.json"

# Config sensor lens CAC calibrattion bin files
#export RK_CAMERA_SENSOR_CAC_BIN="CAC_sc4336_OT01_40IRC_F16"
#export RK_CAMERA_SENSOR_CAC_BIN="CAC_sc4336_OT01_40IRC_F16 CAC_sc530ai_CMK-OT2115-PC1_30IRC-F16"

流程(在内核设备树)

  1. 首先确认使用的屏幕

    这里使用的屏幕:ST7789V + CTC2.8 3SPI_RGB18BIT 需要spi去做初始化。

    有些通用并口LCD屏编写设备树的方式又不一样。

  2. 查看PCB原理图,确认接线。

    屏幕接线

  3. 找到背光引脚,先点亮背光。

背光

  1. 修改背光设备树(GPIO控制背光)

    新建aa_rv1106-lcd.dtsi测试文件。在使用的dts中#include引入,注意不能有panel节点,panel节点会控制背光,如果panel初始化失败,背光就没有效果。

    1. 新增背光节点

      #include <dt-bindings/pinctrl/rockchip.h>
      
      / {
      	backlight: backlight {
      		status = "okay";
      		compatible = "gpio-backlight";
      		gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_HIGH>;
      		default-on;
      		power-supply = <&vcc_3v3>;
      	};
      };
      
    2. 开启内核支持

      CONFIG_BACKLIGHT_GPIO=y
      
    3. 查看是否有配置

      ls /sys/class/backlight #下有 backlight
      
    4. 控制背光

      echo 0 > /sys/class/backlight/backlight/brightness
      echo 1 > /sys/class/backlight/backlight/brightness
      
  2. 修改背光设备树(PWM控制背光)

    1. 新增节点

      #include <dt-bindings/pinctrl/rockchip.h>
      / {
      	backlight: backlight {
      		status = "okay";
      		compatible = "pwm-backlight";
      		pwms = <&pwm3 0 25000 1>;
      		brightness-levels = <0 10 30 60 100 150 200 255>;
      		default-brightness-level = <7>;
      		power-supply = <&vcc_3v3>;
      	};
      };
      
      /* PWM3_M1 = GPIO1_B0, 用于背光 */
      &pwm3 {
      	status = "okay";
      	pinctrl-names = "active";
      	pinctrl-0 = <&pwm3m1_pins>;
      };
      
    2. 内核支持

      CONFIG_PWM=y
      #CONFIG_PWM_ROCKCHIP=y
      #CONFIG_BACKLIGHT_PWM=y
      

uboot使用的设备树

在uboot配置文件中声明

CONFIG_DEFAULT_DEVICE_TREE="rv1106-evb"
  • 路径:
/root/luckfox-dev/sdk/sysdrv/source/uboot/u-boot/arch/arm/dts/rv1106-evb.dts

新增一个dtsi

dtsi配置

  • rv1106-evb.dts引入这个配置

    #include "aa_rv1106-lcd.dtsi"
    

uboot配置文件开启

# Display / LCD
CONFIG_DM_VIDEO=y
CONFIG_DRM_ROCKCHIP=y
CONFIG_DRM_MEM_RESERVED_SIZE_MBYTES=32
CONFIG_DRM_ROCKCHIP_RGB=y
CONFIG_DRM_ROCKCHIP_PANEL=y
CONFIG_BACKLIGHT_GPIO=y
#CONFIG_I2C_EDID=y 看情况
// SPDX-License-Identifier: (GPL-2.0+ OR MIT)
/*
 * LCD panel: ST7789V 2.8" 240x320 RGB 18-bit + 3SPI
 * Backlight: GPIO1_B0
 * LCD Reset: GPIO1_B1
 * SPI init:  GPIO4_A7(CLK) GPIO4_A5(CS) GPIO4_A1(MOSI)
 */

#include <linux/media-bus-format.h>

/ {
	backlight: backlight {
		compatible = "gpio-backlight";
		gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_HIGH>;
		default-brightness-level = <200>;
		status = "okay";
	};

	panel: panel {
		compatible = "simple-panel-spi";
		bus-format = <MEDIA_BUS_FMT_RGB666_1X18>;
		backlight = <&backlight>;

		/* LCD_RST (GPIO1_B1) — release ST7789V reset */
		enable-gpios = <&gpio1 RK_PB1 GPIO_ACTIVE_HIGH>;
		enable-delay-ms = <10>;
		prepare-delay-ms = <10>;
		init-delay-ms = <120>;

		/* SPI bitbang for panel init (3-wire 9-bit protocol) */
		spi-sdi-gpios = <&gpio4 RK_PA1 GPIO_ACTIVE_HIGH>;  /* SPI1_MOSI */
		spi-scl-gpios = <&gpio4 RK_PA7 GPIO_ACTIVE_HIGH>;  /* SPI1_CLK */
		spi-cs-gpios  = <&gpio4 RK_PA5 GPIO_ACTIVE_HIGH>;  /* SPI1_CS0 */

		rockchip,cmd-type = "spi";
		rockchip,output = "rgb";
		rgb-mode = "p666";

		pinctrl-names = "default";
		pinctrl-0 = <&lcd_panel_gpios>;

		/* type(0=cmd,1=data) delay_ms num_bytes byte... */
		panel-init-sequence = [
			/* Sleep Out */
			00   78  01  11
			/* MADCTL — RGB orientation */
			00   00  01  36
			01   00  01  00
			/* COLMOD — 16bit (0x55) / 18bit (0x60) … use 0x06=RGB666 */
			00   00  01  3a
			01   00  01  06
			/* RAMCTRL */
			00   00  01  b0
			01   00  01  11
			01   00  01  00
			/* PORCTRK (Porch control) */
			00   00  01  b1
			01   00  01  40
			01   00  01  04
			01   00  01  0a
			/* GCTRL (Gate control) */
			00   00  01  b2
			01   00  01  0c
			01   00  01  0c
			01   00  01  00
			01   00  01  33
			01   00  01  33
			/* VCOMS */
			00   00  01  b7
			01   00  01  35
			/* VCOMS (VCOM setting) */
			00   00  01  bb
			01   00  01  2b
			/* Power Control 1 */
			00   00  01  c0
			01   00  01  2c
			/* VDV_VRH_EN */
			00   00  01  c2
			01   00  01  01
			/* VRH (VCOM Amplitude) */
			00   00  01  c3
			01   00  01  11
			/* VCIX_VGH */
			00   00  01  c4
			01   00  01  20
			/* VCOMS (VCOM) */
			00   00  01  c6
			01   00  01  0f
			/* Power Control 2 */
			00   00  01  d0
			01   00  01  a4
			01   00  01  a1
			/* Positive Gamma */
			00   00  01  e0
			01   00  01  d0
			01   00  01  00
			01   00  01  06
			01   00  01  09
			01   00  01  0b
			01   00  01  2a
			01   00  01  3c
			01   00  01  55
			01   00  01  4b
			01   00  01  08
			01   00  01  16
			01   00  01  14
			01   00  01  19
			01   00  01  20
			/* Negative Gamma */
			00   00  01  e1
			01   00  01  d0
			01   00  01  00
			01   00  01  06
			01   00  01  09
			01   00  01  0b
			01   00  01  29
			01   00  01  36
			01   00  01  54
			01   00  01  4b
			01   00  01  0d
			01   00  01  16
			01   00  01  14
			01   00  01  21
			01   00  01  20
			/* Display On */
			00   00  01  29
		];

		display-timings {
			native-mode = <&st7789v_timing>;

			st7789v_timing: timing0 {
				clock-frequency = <8500000>;
				hactive = <240>;
				vactive = <320>;
				hfront-porch = <10>;
				hback-porch  = <10>;
				hsync-len    = <10>;
				vfront-porch = <10>;
				vback-porch  = <10>;
				vsync-len    = <10>;
				hsync-active  = <0>;
				vsync-active  = <0>;
				de-active     = <0>;
				pixelclk-active = <0>;
			};
		};

		port {
			panel_in_rgb: endpoint {
				remote-endpoint = <&rgb_out_panel>;
			};
		};
	};
};

&display_subsystem {
	status = "okay";
	route {
		route_rgb: route-rgb {
			status = "okay";
			connect = <&panel_in_rgb>;
			logo,uboot = "logo.bmp";
			logo,kernel = "logo_kernel.bmp";
		};
	};
};

&vop {
	status = "okay";
};

&rgb {
	status = "okay";
	pinctrl-names = "default";
	pinctrl-0 = <&lcd_pins>;

	ports {
		rgb_out: port@1 {
			reg = <1>;
			#address-cells = <1>;
			#size-cells = <0>;

			rgb_out_panel: endpoint@0 {
				reg = <0>;
				remote-endpoint = <&panel_in_rgb>;
			};
		};
	};
};

&pinctrl {
	lcd-panel {
		lcd_panel_gpios: lcd-panel-gpios {
			rockchip,pins =
				/* SPI_MOSI (GPIO4_A1) */
				<4 RK_PA1 RK_FUNC_GPIO &pcfg_pull_none>,
				/* SPI_CLK (GPIO4_A7) */
				<4 RK_PA7 RK_FUNC_GPIO &pcfg_pull_none>,
				/* SPI_CS  (GPIO4_A5) */
				<4 RK_PA5 RK_FUNC_GPIO &pcfg_pull_none>;
		};
	};
};

为了方便查看笔记&流程!!!

  • 先下载Typora这个markdown编写和浏览工具
  • 下载破解版!!!
  • 笔记之间跳转比较方便

笔记&流程说明

先看000开头的文件。方便了解整体。

000_开发板硬件资料.md:硬件资料

000_开发导读.md :软件编译指南

build_all() 完整编译流程

build_all()
  ├── [可选] build_recovery     ← 只有 RK_ENABLE_RECOVERY=y 才执行
  ├── build_sysdrv              ← uboot + 内核 + 根文件系统 + busybox + 驱动模块
  ├── build_media               ← Rockchip 媒体库(rockit、mpi、isp、rga 等)
  ├── build_app                 ← 用户态应用程序(rkipc 等)
  ├── build_firmware            ← 打包所有镜像
  └── finish_build

第一步:build_sysdrv — 系统驱动层

# build.sh 第 541 行
function build_sysdrv(){
    make -C ${SDK_SYSDRV_DIR}    # → sysdrv/Makefile 的 all 目标
}

sysdrv/ 的 Makefile all 目标:

all: uboot kernel rootfs env

它依次调用 4 个子步骤:

1.1 make uboot — 编译 uboot

# sysdrv/Makefile 第 82-92 行
uboot: prepare
    make -C u-boot rv1106_defconfig          # 配置 uboot
    ./u-boot/make.sh --spl-new               # 编译 uboot + SPL
    cp uboot.img  output/image/
    cp idblock.img output/image/             # 一级引导
    cp download.bin output/image/            # 下载模式固件

产出文件:

output/out/sysdrv_out/ → 最终复制到 output/image/
  ├── uboot.img          ← uboot 主镜像
  ├── idblock.img        ← 一级引导(SPL + ddr init + 安全启动)
  └── download.bin       ← 烧录模式固件(loader)

1.2 make kernel — 编译内核

# sysdrv/Makefile 第 100-140 行
kernel: prepare
    make -C kernel ARCH=arm rv1106_defconfig     # 配置内核(含 fragment 合并)
    make -C kernel ARCH=arm zImage dtbs -jN      # 编译内核本身
    make -C kernel ARCH=arm boot.img             # 打包 boot.img(含内核 + dtb + resource)
    cp boot.img output/image/
    cp vmlinux   output/bin/                     # 带调试信息的完整内核
    cp *.dtb     output/bin/

关键点: 这里用的就是 make 命令,但通过 .config + rv1106-evb.config(fragment)合并生成最终配置。它不只是简单地执行 make,还做了:

  • fragment 合并:rv1106_defconfig + rv1106-evb.config → 最终 .config
  • 编译驱动模块(make modules)
  • 调用 update_dtb_bootargs.sh 修改设备树中的启动参数(root=/dev/xxx)
  • 打包 FIT 格式的 boot.img

产出文件:

output/image/
  ├── boot.img          ← FIT 格式,内含 zImage + dtb + resource
output/out/sysdrv_out/
  ├── vmlinux           ← 完整内核(带调试符号)
  ├── rv1106g-evb1-v11.dtb
  └── kernel_drv_ko/    ← 内核驱动模块 .ko 文件

1.3 make rootfs — 构建根文件系统

# 这步最复杂,依赖链:rootfs_prepare → pctools → busybox → boardtools → drv → strip
rootfs: rootfs_prepare pctools busybox boardtools drv

子步骤详情:

子步骤做的事产出
rootfs_prepare解压 rootfs 脚本模板 + 拷贝工具链运行时库(libc, libm, libpthread 等) 到 rootfs_*/基础根文件系统骨架
pctools编译 PC 端工具(mkenvimage、mk-fitimage.sh、mkfs.ubifs 等)output/out/sysdrv_out/pc/
busybox编译 busybox(busybox-1.27.2),生成 _install/bin/busybox 及所有 symlink提供 sh, ls, cp, mv 等基础命令
boardtools编译板端工具(来自 sysdrv/tools/board/)一些板级小工具
drvmake modules_install → 提取所有 .ko 到 kernel_drv_ko/内核模块拷贝
strip如果是 RELEASE 模式,strip 掉调试符号缩小根文件系统体积

最后根据存储介质打包根文件系统镜像:

  • spi_nand → rootfs_ubi → mkfs_ubi.sh → rootfs_base.img(UBIFS 格式)
  • emmc → rootfs_ext4 → mkfs_ext4.sh → rootfs_base.img(ext4 格式)
  • spi_nor → rootfs_jffs2 → mkfs_jffs2.sh → rootfs_base.img(JFFS2 格式)

产出文件:

output/out/sysdrv_out/
  ├── rootfs_glibc_rv1106/     ← 根文件系统目录(未打包)
  ├── rootfs_glibc_rv1106.tar  ← 根文件系统 tar 包
  ├── pc/                      ← PC 端工具
  ├── bin/                     ← 板端工具 + 调试文件
  └── kernel_drv_ko/           ← 内核驱动模块

第二步:build_media — 媒体库

# build.sh 第 523 行
function build_media(){
    make -C ${SDK_MEDIA_DIR}    # → media/Makefile
}
# media/Makefile
all: media_libs
    make -C ./samples                    # 编译 media 示例程序
    cp -rfa ... 到 output/out/media_out/ # 拷贝全部产出

media_libs:
    # 遍历所有子目录编译:
    # rockit/     — Rockchip 多媒体处理框架
    # isp3.x/     — 图像信号处理库
    # rv1106/     — 芯片级库(mpp、rga 等)
    # 等等

产出文件(到 output/out/media_out/):

media_out/
  ├── lib/              ← 所有 .so(librockit.so, libmpi.so, libisp.so, librga.so, libmpp.so...)
  ├── include/          ← 头文件(rockit 等 API 头)
  ├── bin/              ← 示例程序
  ├── share/isp_iqfiles/ ← 各传感器 IQ 调优文件(*.bin)
  ├── usr/              ← 额外资源
  └── root/             ← 需要放到根文件系统 / 下的文件

这是给 app 层提供依赖 — build_app 会链接 media_out 下的 .so 和头文件。


第三步:build_app — 用户态应用程序

# build.sh 第 461 行
function build_app(){
    check_config RK_APP_TYPE || return 0         # RK_APP_TYPE 为空则跳过
    build_meta --export --media_dir ...           # 导出 meta 头文件
    make -C ${SDK_APP_DIR}                       # → project/app/Makefile
}

会被跳过的条件: RK_APP_TYPE 未设置或为空 → 不编译

# project/app/Makefile
all:
    # 遍历所有子目录(rkipc/、ipcweb/、uvc_app_tiny/ 等)
    # 每个子目录根据 RK_APP_TYPE 决定是否编译
    make -C component/rkadk/     # 条件编译
    make -C component/lvgl/      # 条件编译
    make -C rkipc/               # RK_APP_TYPE=RKIPC_RV1106 时编译
    make -C uvc_app_tiny/        # RK_APP_TYPE=UVC_TINY 时编译
    # ...
    # 最后把所有产出拷贝到 out/
    MAROC_COPY_PKG_TO_APP_OUTPUT  # → project/app/out/

产出文件(到 project/app/out/):

app/out/
  ├── bin/rkipc         ← 主程序(IP Camera 守护进程)
  ├── lib/              ← .so(librkfsmk.so, libwpa_client.so 以及第三方库)
  └── share/            ← 配置文件(*.ini)、字体、测试音频

第四步:build_firmware — 打包固件镜像

# build.sh 第 1943 行
function build_firmware(){
    build_env                    # 生成 env.img(uboot 环境变量分区)
    build_meta                   # 生成 meta 分区(摄像头参数、IQ 文件等)

    __PACKAGE_ROOTFS             # 解压 rootfs.tar → 合并 app_out/media_out 内容
    __PACKAGE_OEM                # 打包 OEM 分区(/oem,放 app 和 media 的 bin/lib/share)
    __PACKAGE_USERDATA           # 打包空 userdata 分区

    build_mkimg rootfs ...       # 制作 rootfs 分区镜像(UBIFS/ext4/JFFS2)
    build_mkimg oem ...          # 制作 OEM 分区镜像
    build_mkimg userdata ...     # 制作 userdata 分区镜像

    build_updateimg              # 打包 update.img(统一烧录镜像)
}

最终固件产出(到 output/image/):

output/image/
  ├── uboot.img          ← uboot 镜像
  ├── idblock.img        ← 一级引导
  ├── download.bin       ← 烧录 loader
  ├── boot.img           ← 内核 + dtb + resource
  ├── rootfs.img         ← 根文件系统(UBIFS/ext4/JFFS2)
  ├── oem.img            ← OEM 分区(app + media 的 bin/lib/share + IQ 文件)
  ├── userdata.img       ← 空 userdata 分区
  ├── env.img            ← uboot 环境变量(分区表、启动参数)
  ├── misc.img           ← misc 分区(恢复模式标记)
  └── update.img         ← 统一烧录包(包含以上所有)

编译流程全局图

                 .BoardConfig.mk  ← 您在这里配置芯片、分区、APP_TYPE 等
                        │
                  build.sh all
                        │
         ┌──────────────┼──────────────┐
         │              │              │
    build_sysdrv   build_media    build_app
         │              │              │
    ┌────┼────┐    media/ 下     app/ 下各子目录
    │    │    │    各库源码      根据 RK_APP_TYPE 选择编译
   uboot kernel rootfs
    │    │    │         │              │
    │    │    │    media_out/     app/out/
    │    │    │    (.so + .h)    (rkipc + .so + .ini)
    │    │    │         │              │
    └────┼────┼─────────┼──────────────┘
         │    │         │              │
         │    │    build_firmware      │
         │    │    __PACKAGE_ROOTFS ◄──┘
         │    │    __PACKAGE_OEM    ◄──┘(合并到 oem 分区)
         │    │         │
         │    │    output/image/
         │    │    ├── boot.img
         │    │    ├── rootfs.img
         │    │    ├── oem.img
         │    │    └── update.img
         │    │
    ┌────┘    └──────────────────────────┐
    │                                    │
  uboot 阶段编译              kernel 阶段编译
  只是 make + 脚本拷贝       不只是 make:
                              - fragment 合并配置
                              - 修改 dtb 启动参数
                              - 打包 FIT boot.img
                              - 编译驱动模块

数据引脚

引脚序号信号名称备注说明
117MIPI_CSI_RX_D3NMIPI CSI 差分数据 3 负端
118MIPI_CSI_RX_D3PMIPI CSI 差分数据 3 正端
119MIPI_CSI_RX_CK1NMIPI CSI 差分时钟 1 负端
120MIPI_CSI_RX_CK1PMIPI CSI 差分时钟 1 正端
121MIPI_CSI_RX_D2NMIPI CSI 差分数据 2 负端
122MIPI_CSI_RX_D2PMIPI CSI 差分数据 2 正端
PHY 组配套高速时钟可用数据 Lane用途
PHY0CK0P/CK0ND0P/D0N、D1P/D1N摄像头 1(2Lane MIPI)
PHY1CK1P/CK1ND2P/D2N、D3P/D3N摄像头 2(2Lane MIPI,当前接线)

其他引脚

引脚序号信号名称备注说明复用关系
5I2C4_SCL_M2_1V8I2C时钟VI_CIF_D11/UART5_TX_M2/I2C4_SCL_M2/GPIO3_C7_d
7I2C4_SDA_M2_1V8I2C数据引脚VI_CIF_D12/UART5_RX_M2**/I2C4_SDA_M2**/GPIO3_D0_d
2MIPI_CLK0_OUT第0路摄像头时钟线24MHzVI_CIF_CLKO_M0/MIPI_CLK0_OUT/GPIO3_C4_d
3MIPI_RSTVI_CIF_VSYNC_M0/GPIO3_C5_d
4MIPI_PWDNSensor 电源使能 / 待机开关,唤醒芯片输出图像VI_CIF_D10/PWM7_IR_M2/MIPI_CLK1_OUT/GPIO3_C6_d

RV1106 引脚复用功能表

引脚号引脚复用功能
1MIPI_AVDD1V8/GPIO7_VCC1V8
2VI_CIF_CLKO_M0/MIPI_CLK0_OUT/GPIO3_C4_d
3VI_CIF_VSYNC_M0/GPIO3_C5_d
4VI_CIF_D10/PWM7_IR_M2/MIPI_CLK1_OUT/GPIO3_C6_d
5VI_CIF_D11/UART5_TX_M2/I2C4_SCL_M2/GPIO3_C7_d
6VI_CIF_D13/UART5_RTS_M2/I2C3_SCL_M2/GPIO3_D1_d
7VI_CIF_D12/UART5_RX_M2/I2C4_SDA_M2/GPIO3_D0_d
8VI_CIF_D14/UART5_CTS_M2/I2C3_SDA_M2/GPIO3_D2_d
9VI_CIF_D15/PWM1_M2/GPIO3_D3_d
10DVDD_1
11SDMMC0_DET/GPIO3_A1_u
12SDMMC0_D1/UART2_TX_M0/PWM9_M0/GPIO3_A2_u
13GPIO4_VCC
14SDMMC0_D0/UART2_RX_M0/PWM8_M0/GPIO3_A3_u
15SDMMC0_CLK/UART5_RTS_M0/I2C0_SCL_M2/JTAG_LPMCU_TCK_M1/PWM10_M0/GPIO3_A4_d
16SDMMC0_CMD/UART5_CTS_M0/I2C0_SDA_M2/JTAG_LPMCU_TMS_M1/PWM11_IR_M0/GPIO3_A5_u
17SDMMC0_D3/UART5_TX_M0/JTAG_CPU_TMS_M0/JTAG_HPMCU_TMS_M1/GPIO3_A6_u
18SDMMC0_D2/UART5_RX_M0/JTAG_CPU_TCK_M0/JTAG_HPMCU_TCK_M1/GPIO3_A7_u
19RTC_AVDD3V3
20RTC_XOUT
21RTC_XIN
22SARADC_IN1/PWM1_M1/GPIO4_C1_z
23SARADC_IN0/GPIO4_C0_z
24SARADC_USB_AVDD1V8
25USB_VBUSDET
26USB_DM
27USB_DP
28USB_AVDD3V3
29CODEC_LINEOUT
30CODEC_VCM
31CODEC_AVDD1V8
32CODEC_MICBIAS
33CODEC_MIC0N
34CODEC_MIC0P
35CODEC_MICIN
36CODEC_MIC1P
37CODEC_AVSS
38EMMC_D5/SPI1_CLK_M0/UART1_RX_M2/I2C2_SCL_M1/GPIO4_A7_u
39EMMC_D3/FSPI_D3/GPIO4_A6_u
40EMMC_D4/SPI1_CS0_M0/UART1_TX_M2/I2C2_SDA_M1/GPIO4_A5_u
41EMMC_D0/FSPI_D0/GPIO4_A4_u
42EMMC_D1/FSPI_D1/GPIO4_A3_u
43GPIO3_VCC
44EMMC_D2/FSPI_D2/GPIO4_A2_u
45EMMC_D6/SPI1_MOSI_M0/UART0_TX_M2/I2C0_SCL_M1/GPIO4_A1_u
46EMMC_D7/SPI1_MISO_M0/UART0_RX_M2/I2C0_SDA_M1/GPIO4_A0_u
47EMMC_CMD/FSPI_CS0/GPIO4_B0_u
48EMMC_CLK/FSPI_CLK/GPIO4_B1_d
49DDR_VDDQ_1
50DDR_VDDQ_2
51DVDD_2
52DRAM_ZQ
53DDR_PLL_AVDD1V8
54DVDD_3
55DVDD_4
56DDR_VDDQ_3
57TVSS
58UART0_RX_M0/CLK_32K/CLK_REFOUT/RTC_CLKO/GPIO0_A0_z
59UART0_TX_M0/PWM2_M0/GPIO0_A1_d
60PWM3_IR_M0/GPIO0_A2_d
61PMU_VCC3V3
62PMIC_PWR_CTRL_M1/GPIO0_A3_u
63PMIC_PWR_CTRL_M0/PWM1_M0/GPIO0_A4_d
64I2C1_SCL_M0/UART1_RTS_M0/PWM5_M0/GPIO0_A5_d
65I2C1_SDA_M0/UART1_CTS_M0/PWM6_M0/GPIO0_A6_d
66nPOR
67PMU_DVDD0V9
68OSC_XIN
69OSC_XOUT
70OSC_AVDD1V8/PLL_AVDD1V8
71OSC_PLL_DVDD
72UART3_TX_M0/I2C2_SCL_M0/PWM7_IR_M0/GPIO1_A0_d
73UART3_RX_M0/I2C2_SDA_M0/PWM4_M0/GPIO1_A1_d
74PWM0_M0/CPU_AVS/VI_CIF_D0_M1/GPIO1_A2_d
75UART1_TX_M0/I2C0_SCL_M0/GPIO1_A3_d
76UART1_RX_M0/I2C0_SDA_M0/GPIO1_A4_d
77UART4_RX_M0/PWM3_IR_M1/GPIO1_B0_d
78UART4_TX_M0/PWM7_IR_M1/SPI1_CS1_M0/VI_CIF_D1_M1/GPIO1_B1_d
79JTAG_CPU_TCK_M1/UART2_TX_M1/JTAG_HPMCU_TCK_M0/JTAG_LPMCU_TCK_M0/GPIO1_B2_d
80JTAG_CPU_TMS_M1/UART2_RX_M1/JTAG_HPMCU_TMS_M0/JTAG_LPMCU_TMS_M0/GPIO1_B3_u
81GPIO1_VCC3V3
82DVDD_5
83VO_LCDC_D1/VI_CIF_D8_M1/PWM10_M1/UART4_RTS_M1/GPIO1_C6_d
84VO_LCDC_D0/VI_CIF_D9_M1/PWM11_IR_M1/UART4_CTS_M1/GPIO1_C7_d
85VO_LCDC_CLK/VI_CIF_CLKO_M1/I2C3_SCL_M1/UART5_TX_M1/PWM11_IR_M2/AUD_DSM_N/GPIO1_D3_d
86VO_LCDC_VSYNC/VI_CIF_VSYNC_M1/I2C3_SDA_M1/UART5_RX_M1/SPI0_CS1_M0/PWM0_M1/AUD_DSM_P/GPIO1_D2_d
87VO_LCDC_HSYNC/VI_CIF_HREF_M1/PWM10_M2/UART5_CTS_M1/UART3_RX_M1/GPIO1_D1_d
88GPIO6_VCC
89VO_LCDC_DEN/VI_CIF_CLKI_M1/PWM3_IR_M2/UART5_RTS_M1/UART3_TX_M1/GPIO1_D0_d
90VO_LCDC_D2/VI_CIF_D7_M1/PWM9_M1/UART4_TX_M1/SDMMC1_D2_M1/GPIO1_C5_d
91VO_LCDC_D3/VI_CIF_D6_M1/PWM8_M1/UART4_RX_M1/SDMMC1_D3_M1/GPIO1_C4_d
92VO_LCDC_D4/VI_CIF_D5_M1/PWM6_M2/I2C4_SDA_M1/SDMMC1_CMD_M1/SPI0_MISO_M0/GPIO1_C3_d
93VO_LCDC_D5/VI_CIF_D4_M1/PWM5_M2/I2C4_SCL_M1/SDMMC1_CLK_M1/SPI0_MOSI_M0/GPIO1_C2_d
94VO_LCDC_D6/VI_CIF_D3_M1/PWM4_M2/SPI0_CLK_M0/SDMMC1_D0_M1/GPIO1_C1_d
95VO_LCDC_D7/VI_CIF_D2_M1/PWM2_M2/SPI0_CS0_M0/SDMMC1_D1_M1/GPIO1_C0_d
96OTP_AVDD1V8/ETH_AVDD1V8/TSADC_AVDD1V8
97ETH_PHY_RXN
98ETH_PHY_RXP
99ETH_PHY_TXN
100ETH_PHY_TXP
101ETH_AVDD3V3
102ETH_EXTR
103DVDD_6
104UART0_TX_M1/I2C1_SDA_M1/VO_LCDC_D17/PWM6_M1/GPIO2_B1_d
105UART0_RX_M1/I2C1_SCL_M1/VO_LCDC_D16/PWM5_M1/GPIO2_B0
106UART0_CTS_M1/I2S0_SDO1_SDI3/VO_LCDC_D15/PWM4_M1/I2C3_SDA_M0/PRELIGHT_TRIG_OUT/GPIO2_A7_d
107UART0_RTS_M1/I2S0_SDO2_SDI2/VO_LCDC_D14/PWM2_M1/I2C3_SCL_M0/FLASH_TRIG_OUT/GPIO2_A6_d
108GPIO5_VCC
109SDMMC1_D1_M0/I2S0_SCLK/VO_LCDC_D8/UART1_CTS_M1/I2C4_SDA_M0/GPIO2_A0_d
110SDMMC1_D0/I2S0_LRCK/VO_LCDC_D9/UART1_RTS_M1/I2C4_SCL_M0/GPIO2_A1
111SDMMC1_CLK_M0/I2S0_MCLK/VO_LCDC_D10/GPIO2_A2_d
112SDMMC1_CMD_M0/I2S0_SDO3_SDI1/VO_LCDC_D11/GPIO2_A3_d
113SDMMC1_D3_M0/I2S0_SDO0/VO_LCDC_D12/UART1_TX_M1/GPIO2_A4_d
114SDMMC1_D2_M0/I2S0_SDI0/VO_LCDC_D13/UART1_RX_M1/GPIO2_A5_d
115CPU_DVDD
116DVDD_7
117VI_CIF_D0_M0/MIPI_CSI_RX_D3N/LVDS_RX_D3N/GPIO3_B0_d
118VI_CIF_D1_M0/MIPI_CSI_RX_D3P/LVDS_RX_D3P/GPIO3_B1_d
119VI_CIF_D2_M0/MIPI_CSI_RX_CK1N/LVDS_RX_CK1N/GPIO3_B2_d
120VI_CIF_D3_M0/MIPI_CSI_RX_CK1P/LVDS_RX_CK1P/GPIO3_B3_d
121VI_CIF_D4_M0/MIPI_CSI_RX_D2N/LVDS_RX_D2N/GPIO3_B4_d
122VI_CIF_D5_M0/MIPI_CSI_RX_D2P/LVDS_RX_D2P/GPIO3_B5_d
123VI_CIF_D6_M0/MIPI_CSI_RX_D1N/LVDS_RX_D1N/GPIO3_B6_d
124VI_CIF_D7_M0/MIPI_CSI_RX_D1P/LVDS_RX_D1P/GPIO3_B7_d
125VI_CIF_D8_M0/MIPI_CSI_RX_CK0N/LVDS_RX_CK0N/GPIO3_C0_d
126VI_CIF_D9_M0/MIPI_CSI_RX_CK0P/LVDS_RX_CK0P/GPIO3_C1_d
127VI_CIF_CLKI_M0/MIPI_CSI_RX_D0N/LVDS_RX_D0N/GPIO3_C2_d
128VI_CIF_HREF_M0/MIPI_CSI_RX_D0P/LVDS_RX_D0P/GPIO3_C3_d
E-PADVSS
引脚序号信号名称
11SDMMSC0_DET
14SDMMSC0_D0
12SDMMSC0_D1
18SDMMSC0_D2
17SDMMSC0_D3
15SDMMSC0_CLK
16SDMMSC0_CMD0p

相关芯片

功能IC型号特点
充电管理SLM6300
电量计CW2015CHBD
电源管理EA3036CQBR

充电检测

  • 引脚
名称引脚号复用关系
NCHRG123VI_CIF_D6_M0/MIPI_CSI_RX_D1N/LVDS_RX_D1N/GPIO3_B6_d
NSTDBY124VI_CIF_D7_M0/MIPI_CSI_RX_D1P/LVDS_RX_D1P/GPIO3_B7_d
  • 电平情况
引脚/状态充电充满移除
NCHRGLHH
NSTDBYHLH

充电检测2

说明:通过判断usb插入,来去确定是否在充电

  • 引脚

    名称引脚号复用关系
    USB_VBUSDET/USB_DET25USB_VBUSDET/GPIO1_D0
  • 默认0,插入之后1.8v

电量查看

名称引脚号复用关系
I2C数据线 SCL75UART1_TX_M0/I2C0_SCL_M0/GPIO1_A3_d
I2C时钟线 SDA76UART1_RX_M0/I2C0_SDA_M0/GPIO1_A4_d
模块名称引脚序号引脚名
USB切换58GPIO0_A0
SD卡使能59GPIO0_A1
电源控制60POW_HOLD/GPIO0_A2
电源开关检测按钮62SOC_GPIO_BELL/GPIO0_A3
红外摄像头电源开关63GPIO0_A4
照明灯光74PWM0_M0
LCD背光77LCD_BL_EN

电源控制:SOC手动拉高,外围电路就能持续供电,详细看下图

电源开关检测按钮:kernel启动后就会拉高电源控制引脚持续给soc供电。

8

ST7789V屏幕

数字是SOC的引脚号,字符是LCD的引脚名称。引脚复用关系

{
  "38": "SPI1_CLK",
  "40": "SPI1_CS0",
  "45": "SPI1_MOSI",
  "77": "LCD_BL_EN",
  "78": "LCD_RST",
  "83": "LCD_D1",
  "84": "LCD_D0",
  "85": "LCD_CLK",
  "86": "LCD_VSYNC",
  "87": "LCD_HSYNC",
  "89": "LCD_DEN",
  "90": "LCD_D2",
  "91": "LCD_D3",
  "92": "LCD_D4",
  "93": "LCD_D5",
  "94": "LCD_D6",
  "95": "LCD_D7",
  "104": "LCD_D17",
  "105": "LCD_D16",
  "106": "LCD_D15",
  "107": "LCD_D14",
  "109": "LCD_D8",
  "110": "LCD_D9",
  "111": "LCD_D10",
  "112": "LCD_D11",
  "113": "LCD_D12",
  "114": "LCD_D13"
}

SOC

  • rv1106G3
对比维度🟢 RV1106 G2🔵 RV1106 G3
AI算力0.5 TOPS1.0 TOPS
NPU精度支持 INT4/INT8/INT16 混合运算支持 INT4/INT8/INT16 混合运算
内存大小128MB DDR3L(部分版本可达256MB)256MB DDR3L
无线连接常见版本不集成,需外接无线模块常见版本集成 2.4GHz WiFi6 和 Bluetooth 5.2/BLE

FLASH(nand)

  • C19193269_NAND,具体型号:GD5F1GM7UEYIGR

  • 1Gb SLC NAND Flash (128M)

  • 页大小 2048(针对文件系统修改有需求)

详细可前往GD5F1GM7UEYIGR

屏幕

  • 320x240分辨率
  • spi+18并口
  • 40p引脚

详细文档前往ST7789V+CTC2.8_3SPI_RGB18BIT

红外摄像头

可见光摄像头

SDK build.sh使用说明

选择参考的板级配置

./build.sh lunch

You're building on Linux
Lunch menu...pick a combo:

BoardConfig-*.mk naming rules:
BoardConfig-"启动介质"-"电源方案"-"硬件版本"-"应用场景".mk
BoardConfig-"boot medium"-"power solution"-"hardware version"-"applicaton".mk

----------------------------------------------------------------
0. BoardConfig-EMMC-ALL-2xRK806-HW_V10-IPC_MULTI_SENSOR.mk
                             boot medium(启动介质): EMMC
                          power solution(电源方案): 2xRK806
                        hardware version(硬件版本): HW_V10
                              applicaton(应用场景): IPC_MULTI_SENSOR
----------------------------------------------------------------

----------------------------------------------------------------
1. BoardConfig-SPI_NAND-ALL-RK806-HW_V10-IPC_SINGLE_SENSOR.mk
                             boot medium(启动介质): SPI_NAND
                          power solution(电源方案): RK806
                        hardware version(硬件版本): HW_V10
                              applicaton(应用场景): IPC_SINGLE_SENSOR
----------------------------------------------------------------

Which would you like? [0]:

输入对应的序号选择对应的参考板级。

一键自动编译

./build.sh lunch # 选择参考板级 ./build.sh # 一键自动编译

编译U-Boot

./build.sh clean uboot ./build.sh uboot

生成镜像文件: output/image/MiniLoaderAll.bin output/image/uboot.img

编译kernel

./build.sh clean kernel ./build.sh kernel

生成镜像文件: output/image/boot.img

编译rootfs

./build.sh clean rootfs ./build.sh rootfs

编译后使用./build.sh firmware命令打包成rootfs.img 生成镜像文件:output/image/rootfs.img

编译media

./build.sh clean media ./build.sh media

生成文件的存放目录: output/out/media_out

编译参考应用

./build.sh clean app ./build.sh app

生成文件的存放目录: output/out/app_out 注:app依赖media

固件打包

./build.sh firmware

生成文件的存放目录: output/image

SDK目录结构说明:

├── build.sh -> project/build.sh ---- SDK编译脚本
├── media --------------------------- 多媒体编解码、ISP等算法相关(可独立SDK编译)
├── sysdrv -------------------------- U-Boot、kernel、rootfs目录(可独立SDK编译)
├── project ------------------------- 参考应用、编译配置以及脚本目录
├── output -------------------------- SDK编译后镜像文件存放目录
├── docs ---------------------------- SDK文档目录
└── tools --------------------------- 烧录镜像打包工具以及烧录工具

镜像存放目录说明

编译完的文件存放在output目录下

output/
├── image
│   ├── download.bin ---------------- 烧录工具升级通讯的设备端程序,只会下载到板子内存
│   ├── env.img --------------------- 包含分区表和启动参数
│   ├── uboot.img ------------------- uboot镜像
│   ├── idblock.img ----------------- loader镜像
│   ├── boot.img -------------------- kernel镜像
│   ├── rootfs.img ------------------ kernel镜像
│   └── userdata.img ---------------- userdata镜像
└── out
    ├── app_out --------------------- 参考应用编译后的文件
    ├── media_out ------------------- media相关编译后的文件
    ├── rootfs_xxx ------------------ 文件系统打包目录
    ├── S20linkmount ---------------- 分区挂载脚本
    ├── sysdrv_out ------------------ sysdrv编译后的文件
    └── userdata -------------------- userdata

注意事项

在windows下复制源码包时,linux下的可执行文件可能变为非可执行文件,或者软连接失效导致无法编译使用。
因此使用时请注意不要在windows下复制源代码包。

Stc8g1k08a芯片io扩展

STC8G1K08A

​ stc8引脚

引脚使用说明:

  • P3.0、P3.1,烧录引脚,设计为端子形式:P3.0、P3.1、VCC、GND,引出为端子。烧录过程连接端子。烧录完成之后连接通信模块,作为数据上报。同时可以作为后续的调试引脚,正式投产使用之后,可通过这个串口进行日志查看等。
  • P3.2、P3.3,作为iic引脚。设置成2p端子形式。与PCA9555芯片做通信。
  • P5.4,作为PCA9555的中断(低电平有效)触发引脚。当PCA9555读到的电平状态发生变化时触发这个中断进行数据上报。
  • P5.5,预留引脚。可以作为led提示灯等功能。

PCA9555

​ PCA9555引脚

  • **A0-A2:**全部拉低。此时通讯读写地址为:0x40 (write),0x41(read)
  • **INT:**中断引脚,当P00-P17状态发生改变触发。与stc8g1k08a的P54引脚相连。
  • **SDL、SDA:**通讯引脚。与stc8g1k08a的P32、P33引脚相连。
  • **P00-P07、P10-P17:**与微动开关相连。用于读取微动开关状态。每4个作为一组,使用端子引出来。p00-p03、p04-p07、p10-p13、p14-p17。

STC8G1K08A

​ **说明:**1.9V~5.5V、温度范围:-40°C ~ +85°C。

​ **引脚:**8个引脚。VCC、GND、P3.0、P3.1、P3.2、P3.3、P5.4、P5.5

​ 价格:

​ 立创商城千份单价0.7464元。

stc8g芯片立创商城价格

​ 淘宝价格千份单价0.62065元。

stc8g芯片淘宝价格

PCA9555

​ **说明:**2.3V~5.5V、温度范围:-40°C ~ +85°C。

​ **引脚:**24引脚。P00-P7、P10-P17、INT、SCL、SDA、A0、A1、A2。可检测16路

​ 价格:

​ 使用国产芯片立创商城价格千份1.27元。

pca9555立创商城价格

通信模块

​ 4g模块还没确定。上面两个模块完成搭建后,数据上传只需要通过串口进行上传,既通信模块可以使用4g模块、wifi模块、蓝牙、lora等。

显示&摄像头接口与屏幕

屏幕接口

与屏幕进行通信的接口。我们划分成3个领域:

消费电子

  • HDMI (High-Definition Multimedia Interface)
  • MIPI (Mobile Industry Processor Interface):DSI (Display Serial Interface) 用于连接屏幕,CSI (Camera Serial Interface) 用于连接摄像头。
  • DisplayPort (DP):DP接口
  • DVI (Digital Visual Interface):数字视频接口的“前辈”。曾是LCD显示器的主流接口,但现在已基本被HDMI和DP取代,仅在老旧设备上可见。
  • VGA (Video Graphics Array)
  • eDP (Embedded DisplayPort):DP的“嵌入式版本”
  • LVDS (Low-Voltage Differential Signaling):eDP的“前辈”

嵌入式系统

  • MCU并口 (如8080/6800):一种“内存映射”接口。主控芯片可以直接读写屏幕内部的显示缓冲区(GRAM),操作就像读写内存一样简单。接口线数多(8/16位数据线+控制线),速度有限,适合驱动小尺寸屏幕。
  • SPI (Serial Peripheral Interface):一种高速的串行接口。引脚少(最多4根),速度比I²C快,常用于分辨率稍高一些的彩色TFT或OLED屏幕。
  • I²C (Inter-Integrated Circuit):一种非常简单的两线制串行接口。速度最慢,通常用于驱动分辨率极低的单色OLED或段码屏,更多时候用于配置屏幕的触摸芯片或传感器。
  • RGB (TTL) 并行接口:一种“实时流”接口。它不像MCU接口那样有显存,需要主控持续不断地提供像素时钟(PCLK)和RGB数据。速度比MCU并口快,适合驱动3.5寸到10寸左右的屏幕。
  • FPD-Link (Flat Panel Display Link):由TI(德州仪器)开发的一种串行接口技术,专为汽车和视频传输应用设计,可以通过一根线缆传输视频、音频和控制信号,传输距离远。
  • APIX (Automotive Pixel Link):同样是一种专为汽车应用设计的高带宽、抗干扰串行接口。
  • SDI (Serial Digital Interface):广播级和专业视频制作的“标准”。用于传输未压缩的数字视频信号,传输距离远,稳定性极高,常见于电视台、转播车和专业摄像机。
  • DVI:在一些对色彩精度要求极高的专业显示器(如医疗影像、图形设计)上,DVI-D(纯数字)接口因其信号纯净度仍有一定市场。
  • USB-C:现代设备的“全能端口”。它不是一个专门的显示接口,但通过“DP Alt Mode”模式,可以传输DisplayPort信号,实现视频输出、数据传输和供电三合一。
  • 电子墨水屏 (E-ink):靠反射环境光显示,极度省电,拥有类纸的阅读体验。广泛应用于电子书阅读器(如Kindle)和电子货架标签。
  • Micro OLED:将OLED微型化,使其在极小的尺寸下拥有超高的像素密度(PPI)。是目前VR/AR头显设备的核心显示技术。

摄像头接口

消费电子

  • MIPI CSI-2
  • USB (UVC)

工业

  • GigE Vision:基于以太网,是工业视觉的主流选择。
    • 核心优势:传输距离远(标准100米),便于多相机组网,成本相对较低。
    • 主要局限:带宽相对较低(1Gbps),有轻微延迟。
  • Camera Link:一个高带宽、低延迟的专用标准。
    • 核心优势:性能稳定,延迟极低且可预测,适合高速、高精度的检测。
    • 主要局限:需要专用的图像采集卡,系统成本和布线复杂度高。
  • CoaXPress (CXP):新一代高速标准,使用同轴电缆。
    • 核心优势:带宽极高(单通道可达12.5Gbps),传输距离远(超100米),能同时传输数据、控制和供电。
    • 主要局限:需要专用的采集卡,成本较高。
  • USB3 Vision:基于USB 3.0的工业相机标准。
    • 核心优势:即插即用,成本较低,带宽不错(5Gbps)。
    • 主要局限:传输距离短(通常3-5米)。
  • GMSL (Gigabit Multimedia Serial Link):源自汽车电子,正进入工业领域。
    • 核心优势:长距离(10-15米)、高带宽(6Gbps)、高可靠性、强抗干扰,支持数据与供电共线。
    • 主要局限:成本高,需要专用的解串器芯片。

嵌入式领域

  • MIPI CSI-2:在高性能嵌入式平台(如树莓派、RK3588等开发板)上是首选。能充分发挥处理器性能,用于人脸识别、AI推理等任务。
  • USB (UVC):在需要快速原型验证、灵活连接时非常流行。开发简单,能快速搭建系统。
  • DVP (Digital Video Port):一个简单、低成本的并行接口。
    • 核心优势:硬件简单,易于驱动,连低端单片机都能驾驭。
    • 主要局限:带宽低,抗干扰差,已逐渐被淘汰。
    • 应用场景:仅适用于极低成本、低分辨率的项目,如简单的扫码枪、玩具摄像头等。
  • LVDS:一种抗干扰强的差分信号技术。
    • 特点:比MIPI传输距离更远(可达10米),但功耗和成本也更高。在一些工业或车载嵌入式场景中仍有应用。
  • SPI/I2C:这些通常不作为图像数据的主传输接口,因为带宽极低。它们主要用于摄像头的寄存器配置和控制(如调节曝光、白平衡等)。

屏幕技术

  • OLED (有机发光二极管):每个像素自发光,无需背光。因此能做到纯黑(像素完全不发光),对比度无限高,色彩鲜艳,响应速度快,且可以实现柔性、可折叠等形态。缺点是可能存在烧屏风险,且生产成本较高。
    • AMOLED (有源矩阵OLED):目前主流的OLED技术,使用TFT背板精确控制每个像素,功耗更低,性能更好。
    • PMOLED (无源矩阵OLED):结构简单,成本低,但尺寸和分辨率做不大,常用于小尺寸的副屏、穿戴设备等。
  • LCD (液晶显示器):技术成熟,成本低廉,寿命长,是目前最普及的显示技术。它本身不发光,需要背光源。根据液晶排列方式不同,又分为:(TFT-LCD分支)
    • IPS (平面转换):可视角度极佳,色彩还原准确,是目前手机、显示器和电视的主流选择。
    • VA (垂直排列):原生对比度非常高,黑色表现深邃,常用于曲面屏和影音向的显示器。
    • TN (扭曲向列):响应时间最快,但色彩和可视角度最差,现已基本被淘汰,仅用于一些入门级电竞显示器。
  • Mini-LED:不是一种新的屏幕类型,而是LCD的一种高级背光技术。它将背光灯珠做得非常小(100-200微米),数量众多,从而能实现精细的区域控光(Local Dimming),极大地提升了LCD的对比度和HDR效果,是LCD对抗OLED的利器。
  • Micro-LED:被视为显示技术的“终极形态”。它结合了OLED的自发光和LCD的长寿命、高亮度优点,且功耗更低。但目前制造难度极大、成本极高,尚未大规模商用。

图像格式

DVP

全称:digital video port 数字视频接口,常用于摄像头(CMOS Sensor)与主控数据传输。

信号类别引脚名称功能描述
输出总线PCLK像素时钟:每个时钟周期对应传输一个像素的数据。
VSYNC帧同步信号:标志一帧(一幅画面)数据的开始和结束。
HSYNC行同步信号:标志一行数据的开始和结束。
DATA[0:n]并行数据线:传输实际的像素数据,位宽可为8、10、12或16位。
输入总线XCLK / MCLK主时钟输入:由主控芯片(如ISP)提供给图像传感器的工作时钟。
SCL / SDAI²C控制总线:用于主控芯片读写图像传感器内部的寄存器,进行参数配置。
PWDN电源使能:控制传感器进入正常工作或待机(Standby)模式。
RESET复位信号:用于对图像传感器进行硬件复位。
电源总线AVDD / DOVDD / DVDD分别为传感器的模拟电路、数字I/O口和内核提供工作电压。
  • 优点:
    • 设计简单:接口逻辑直观,在低分辨率、低速应用中易于实现。
    • PCB布局相对容易:相比高速串行接口(如MIPI),对走线的阻抗控制和等长要求不那么严苛。
  • 缺点:
    • 信号完整性受限:并行总线在高速传输时,各数据线之间的信号偏斜(Skew)和串扰(Crosstalk)问题突出,限制了最高频率。
    • 传输速率瓶颈:最高约96M的像素时钟限制了其处理高分辨率、高帧率视频流的能力。
    • 引脚数量多:需要多根数据线及控制线,占用较多芯片I/O资源。
    • 抗干扰能力弱:CMOS电平的非差分信号不如LVDS等差分信号抗干扰能力强。

hback-porch:HBP或tbp,行信号左边沿无效信号时间

HSYNC:行同步

前往:参考

说明

SPI初始化+RGB显示:SPI负责初始化参数,RGB线路负责显示图像。可以通过SPI通信控制RGB显示,比如翻转,改变分辨率之类操作。

流程

  1. SPI初始化
  2. RGB控制

模块

蓝牙模块

蓝牙类型标志特性
经典蓝牙SPP,蓝牙3.0及以下功耗高,速率较快,先配对后连接
低功耗蓝牙BLE,蓝牙4.0及以上功耗低,速率较慢,快速连接,有组网,广播,定位等新功能
双模蓝牙SPP+BLE同时集成经典蓝牙和低功耗蓝牙,兼容性强
主从类别特性
单从机仅能被动等待设备连接,无法主动发起连接请求
主从一体可以切换为主设备和从设备,主设备时可以发起连接请求
  • 测温头固定4字节,解析方式:4字节转成float;

  • 2字节全屏测温数据:2字节全屏测温数据/64 – 50;

  • YUV流:按照YUYV格式解析;

/* 温度换算: ℃ = raw / 64 - 50 */
#define TEMP_RAW_TO_C(raw)  ((raw) / 64.0f - 50.0f)

即得到的:帧温度原始值 除以 64.0f - 50.0f

官方SDK提供的demo是C++的示例

x86_64的linux

  • 使用官方c++示例

    • 修改demo/linux下makefile名字

    • 执行make clean

    • 执行make

  • 使用c自己写

    • 删除无用文件,libusb-1.0.so 才是真正使用的,libusb-1.0.so.0,libusb-1.0.2.0用不到也能删

      下载
    • 修改include/HCUsbSDK.h

      #if defined _WIN32 || defined _WIN64
      #define USB_SDK_API  extern "C" __declspec(dllimport)
      #elif defined __linux__ || defined __APPLE__
      #define USB_SDK_API  extern "C"
      #endif
      

      改成

      #if defined _WIN32 || defined _WIN64
      #define USB_SDK_API  extern "C" __declspec(dllimport)
      #elif defined __linux__ || defined __APPLE__
        #ifdef __cplusplus
          #define USB_SDK_API  extern "C"
        #else
          #define USB_SDK_API //extern "C"  是c++的语法,c不支持
        #endif
      #endif
      
    • 编写makefile

      CC = gcc
      CFLAGS = -g -O0 -Wall
      INCLUDE = -I. -I../../include
      LDLIBS = -lHCUSBSDK -lpthread
      LIBPATH = ../../library/linux64 
      
      SRCS = $(wildcard *.c)
      OBJS = $(SRCS:.c=.o)
      TARGET = usb_demo
      
      $(TARGET): $(OBJS)
      	$(CC) $(CFLAGS) $^ -o $@ -L$(LIBPATH) $(LDLIBS) -Wl,-rpath=$(LIBPATH)
      	cp $@ $(LIBPATH)
      
      %.o: %.c
      	$(CC) $(CFLAGS) $(INCLUDE) -c $< -o $@
      
      all: $(TARGET)
      	@echo "Build $(TARGET) Finished"
      
      clean:
      	rm -f $(OBJS) $(TARGET) $(LIBPATH)/$(TARGET)
      
    • 编写测试

      #include <stdio.h>
      #include "../../include/HCUsbSDK.h"
      int main(void)
      {
          USB_Init();
          
          printf("SDK_version=%d\n",USB_GetSDKVersion());
          printf("DeviceCount=%d\n", USB_GetDeviceCount());
          return 0;
      }
      
    • 注意点

      1. 编写的应用程序必须在.so依赖同级目录。

        library/linux64/
        ├── usb_demo              ← 程序
        ├── libHCUSBSDK.so        ← SDK
        ├── libhpr.so
        ├── libusb-1.0.so         ← 被  libHCUSBSDK.so dlopen
        └── libuvc.so             ← 被  libHCUSBSDK.so dlopen
        
  • 其他示例

    前往其他地方

  • 设备初始化

    #include "../../include/HCUsbSDK.h"
    if (!USB_Init()) { printf("USB_Init failed\n"); return -1; }
    
  • 获取已连接的设备数量

    int count = USB_GetDeviceCount();
    printf("DeviceCount=%d\n", count);
    if (count <= 0) 
    {     
        USB_Cleanup();
    	return -1;
    }
    
  • 遍历设备

    USB_DEVICE_INFO *devices = malloc(sizeof(USB_DEVICE_INFO) * count);
    if (!USB_EnumDevices(count, devices))
    {
        free(devices);
        USB_Cleanup();
        return -1;
    }
    printf("Device: type=%d name=%s\n",devices[0].byProtocolType,devices[0].szDeviceName);
    
  • 登录设备0

    /* ========== 登录设备 ========== */
    USB_USER_LOGIN_INFO li = {0};
    USB_DEVICE_REG_RES  lr = {0};
    li.dwSize = sizeof(li);
    li.dwTimeout = 5000;
    li.dwVID = devices[0].dwVID;
    li.dwPID = devices[0].dwPID;
    memcpy(li.szSerialNumber, devices[0].szSerialNumber, MAX_SERIAL_NUMBER_LEN);
    memcpy(li.szUserName, "admin", 5);
    memcpy(li.szPassword, "12345", 5);
    li.byLoginMode = 1;
    lr.dwSize = sizeof(lr);
    
    LONG userId = USB_Login(&li, &lr);
    if (userId < 0)
    {
        printf("Login failed, err=%d\n", USB_GetLastError());
        free(devices);
        USB_Cleanup();
        return -1;
    }
    

整体代码

#include "../../include/HCUsbSDK.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
    if (!USB_Init())
    {
        printf("USB_Init failed\n");
        return -1;
    }

    int count = USB_GetDeviceCount();
    printf("DeviceCount=%d\n", count);
    if (count <= 0)
    {
        USB_Cleanup();
        return -1;
    }

    USB_DEVICE_INFO *devices = malloc(sizeof(USB_DEVICE_INFO) * count);
    if (!USB_EnumDevices(count, devices))
    {
        free(devices);
        USB_Cleanup();
        return -1;
    }
    printf("Device: type=%d name=%s\n", devices[0].byProtocolType, devices[0].szDeviceName);

    /* ========== 登录设备 ========== */
    USB_USER_LOGIN_INFO li = {0};
    USB_DEVICE_REG_RES  lr = {0};
    li.dwSize = sizeof(li);
    li.dwTimeout = 5000;
    li.dwVID = devices[0].dwVID;
    li.dwPID = devices[0].dwPID;
    memcpy(li.szSerialNumber, devices[0].szSerialNumber, MAX_SERIAL_NUMBER_LEN);
    memcpy(li.szUserName, "admin", 5);
    memcpy(li.szPassword, "12345", 5);
    li.byLoginMode = 1;
    lr.dwSize = sizeof(lr);

    LONG userId = USB_Login(&li, &lr);
    if (userId < 0)
    {
        printf("Login failed, err=%d\n", USB_GetLastError());
        free(devices);
        USB_Cleanup();
        return -1;
    }
    printf("Login OK, UserID=%ld\n", (long)userId);
    printf("  DeviceName=%s\n", lr.szDeviceName);
    printf("  SerialNumber=%s\n", lr.szSerialNumber);
    printf("  SoftwareVersion=%d.%d\n",
           lr.dwSoftwareVersion >> 16, lr.dwSoftwareVersion & 0xFFFF);
    printf("  RetryLoginTimes=%d\n", lr.byRetryLoginTimes);
    printf("  SurplusLockTime=%u\n", lr.dwSurplusLockTime);

    /* ========== 登出并清理 ========== */
    USB_Logout(userId);
    USB_Cleanup();
    free(devices);
    printf("Done.\n");
    return 0;
}
typedef struct tagUSB_COMMON_COND
{
    DWORD dwSize;
    BYTE  byChannelID;     // 通道号
    BYTE  bySID;           // 场景ID
    BYTE  byRes[6];
} USB_COMMON_COND, *LPUSB_COMMON_COND;

操作的是哪个通道、哪个场景,以及这个条件结构体本身有多大

  • dwSize
    • 结构体大小,给 SDK 做参数校验和版本兼容。
  • byChannelID
    • 指定“对哪个通道操作”。
    • 设成 USB_CHANNEL_IR ,表示红外通道。
  • bySID
    • 场景 ID。
    • 某些功能可能按场景区分配置,不一定每个接口都用得到。
  • byRes[6]
    • 预留字节,通常清零,不用你管。

USB_THERMAL_STREAM_PARAM

  • USB_CONFIG_INPUT_INFO 下发的码流参数
  • USB_CONFIG_OUTPUT_INFO 用来接收查询结果

查询

/* ---- 能力查询 ---- */
void print_capabilities(void)
{
    USB_COMMON_COND cond = {0};
    cond.dwSize = sizeof(cond);
    cond.byChannelID = USB_CHANNEL_IR; //配置红外通道

    /* 查询当前热成像码流参数 */
    USB_THERMAL_STREAM_PARAM tsp = {0};
    tsp.dwSize = sizeof(tsp);

    USB_CONFIG_INPUT_INFO in = {0};
    USB_CONFIG_OUTPUT_INFO out = {0};
    in.lpCondBuffer = &cond;
    in.dwCondBufferSize = sizeof(cond);
    out.lpOutBuffer = &tsp;
    out.dwOutBufferSize = sizeof(tsp);

	if (USB_GetDeviceConfig(g_userId, USB_GET_THERMAL_STREAM_PARAM_CAPABILITIES, &in, &out))
    {
        printf("  cap.dwVideoCodingType=%u (0x%08x)\n",
               (unsigned)cap.dwVideoCodingType, (unsigned)cap.dwVideoCodingType);
        static const char *desc[] = {
            "热成像裸数据", "全屏测温数据", "实时裸数据",
            "热图数据", "热成像实时流", "YUV实时数据",
            "PS封装MJPEG", "全屏测温+YUV实时流", "YUV+裸数据", "YUV+裸数据","仅YUV不含测温头","测温头 + 离线测温信息 + 裸数据 + YUV"
        };
        for (int i = 0; i < 10+1; i++)
            printf("    bit%d=%d %s\n", i,
                   (cap.dwVideoCodingType >> i) & 1, desc[i]);
    }
}
  • 初始化和登录

    可以借鉴:002_usb红外摄像头初始化登录.md

  • 查看摄像头的能力

    可以借鉴:003_usb红外摄系统能力查询.md

  • 配置参数

    int configure_device(void)
    {
        USB_COMMON_COND cond = {0};
        cond.dwSize = sizeof(cond);
        cond.byChannelID = USB_CHANNEL_IR;
    
        USB_THERMAL_STREAM_PARAM tsp = {0};
        tsp.dwSize = sizeof(tsp);
        tsp.byVideoCodingType = 8; //
        tsp.dwWidth = 256; tsp.dwHeight = 192; tsp.dwFrameRate = 25;
    
        USB_CONFIG_INPUT_INFO in = {0};
        USB_CONFIG_OUTPUT_INFO out = {0};
        in.lpCondBuffer = &cond; in.dwCondBufferSize = sizeof(cond);
        in.lpInBuffer = &tsp; in.dwInBufferSize = sizeof(tsp);
        if (!USB_SetDeviceConfig(g_userId, USB_SET_THERMAL_STREAM_PARAM, &in, &out)) return -1;
    
        USB_VIDEO_PARAM vp = {0};
        vp.dwVideoFormat = USB_STREAM_YUY2;
        vp.dwWidth = 256; vp.dwHeight = 192; vp.dwFramerate = 25;
        in.lpInBuffer = &vp; in.dwInBufferSize = sizeof(vp);
        if (!USB_SetDeviceConfig(g_userId, USB_SET_VIDEO_PARAM, &in, &out)) return -1;
        printf("Config: type=8 YUY2 256x192@25\n");
        return 0;
    }
    
    • 参数解析
  • 开启流&设置流回调

    static void __stdcall cb(LONG h, USB_FRAME_INFO *f, void *u)
    {
        (void)h; (void)u;
        if (!f || !f->pBuf || f->dwStreamType != USB_STREAM_YUY2) return;
        g_frameCnt++;
    
        if (g_frameCnt == 1)
        {
            printf("[CB#1] %lux%lu buf=%lu\n",
                   (unsigned long)f->dwWidth, (unsigned long)f->dwHeight,
                   (unsigned long)f->dwBufSize);
            printf("  raw: ");
            for (int i = 0; i < f->dwBufSize; i++) printf("%02x ", f->pBuf[i]);
            printf("\n");
        }
    
        if (g_frameCnt <= 3 || (g_frameCnt % 25) == 0)
            printf("[CB#%d] frame\n", g_frameCnt);
    }
    
    int start_stream(int seconds)
    {
        USB_STREAM_CALLBACK_PARAM cp = {0};
        cp.dwSize = sizeof(cp);
        cp.dwStreamType = USB_STREAM_YUY2;
        cp.funcStreamCallBack = cb;
    
        LONG h = USB_StartStreamCallback(g_userId, &cp);
        if (h < 0) return -1;
    
        sleep(seconds);
        USB_StopChannel(g_userId, (DWORD)h);
        return 0;
    }
    

海康有很多种摄像头

  • usb摄像头
  • 网络摄像头
  • ...

共用一套SDK。

usb红外摄像头请看

  • 下载对应的SDK

海康开放平台

下载

原生示例

#include <Arduino.h>
#include <SPI.h>
// ESP-12E (NodeMCU) 引脚定义
#define LED   D0      // GPIO16
#define CS    D8      // GPIO15
#define RS    D1      // GPIO5
#define RESET D2      // GPIO4

void SPI_Init(void)
{
    SPI.begin();
    SPI.setClockDivider(40000000); // 设置SPI速度为40MHz
    SPI.setBitOrder(MSBFIRST);
    SPI.setDataMode(SPI_MODE0);  
}
void Lcd_Writ_Bus(uint8_t d) {
  SPI.transfer(d);
}

void Lcd_Write_Com(uint8_t VH) {   
  digitalWrite(RS, LOW);  // 使用digitalWrite替代直接端口操作
  Lcd_Writ_Bus(VH);
}

void Lcd_Write_Data(uint8_t VH) {
  digitalWrite(RS, HIGH); // 使用digitalWrite替代直接端口操作
  Lcd_Writ_Bus(VH);
}

void Lcd_Write_Com_Data(uint8_t com, uint8_t dat) {
  Lcd_Write_Com(com);
  Lcd_Write_Data(dat);
}

void Address_set(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) {
  Lcd_Write_Com(0x2a);
  Lcd_Write_Data(x1>>8);
  Lcd_Write_Data(x1);
  Lcd_Write_Data(x2>>8);
  Lcd_Write_Data(x2);
  Lcd_Write_Com(0x2b);
  Lcd_Write_Data(y1>>8);
  Lcd_Write_Data(y1);
  Lcd_Write_Data(y2>>8);
  Lcd_Write_Data(y2);
  Lcd_Write_Com(0x2c); 							 
}

void Lcd_Init(void)
{
  digitalWrite(RESET,HIGH);
  delay(5); 
  digitalWrite(RESET,LOW);
  delay(15);
  digitalWrite(RESET,HIGH);
  delay(15);

  digitalWrite(CS,LOW);  //CS

    Lcd_Write_Com(0xF7);  
    Lcd_Write_Data(0xA9); 
    Lcd_Write_Data(0x51); 
    Lcd_Write_Data(0x2C); 
    Lcd_Write_Data(0x82);  

    Lcd_Write_Com(0xC0);  
    Lcd_Write_Data(0x11); 
    Lcd_Write_Data(0x09); 

    Lcd_Write_Com(0xC1);  
    Lcd_Write_Data(0x41); 

    Lcd_Write_Com(0xC5);  
    Lcd_Write_Data(0x00); 
    Lcd_Write_Data(0x0A); 
    Lcd_Write_Data(0x80);
 
    Lcd_Write_Com(0xB1);  
    Lcd_Write_Data(0xB0); 
    Lcd_Write_Data(0x11); 

    Lcd_Write_Com(0xB4);  
    Lcd_Write_Data(0x02); 
  
    Lcd_Write_Com(0xB6);    
    Lcd_Write_Data(0x02);
    Lcd_Write_Data(0x22);  
 
    Lcd_Write_Com(0xB7);    
    Lcd_Write_Data(0xC6);  

    Lcd_Write_Com(0xBE);    
    Lcd_Write_Data(0x00);   
    Lcd_Write_Data(0x04); 
 
    Lcd_Write_Com(0xE9);    
    Lcd_Write_Data(0x00);   
 
    Lcd_Write_Com(0x36);  
    Lcd_Write_Data(0x08);   

    Lcd_Write_Com(0x3A);    
    Lcd_Write_Data(0x66); 

    Lcd_Write_Com(0xE0);    
    Lcd_Write_Data(0x00);  
    Lcd_Write_Data(0x07); 
    Lcd_Write_Data(0x10); 
    Lcd_Write_Data(0x09); 
    Lcd_Write_Data(0x17); 
    Lcd_Write_Data(0x0B); 
    Lcd_Write_Data(0x41); 
    Lcd_Write_Data(0x89); 
    Lcd_Write_Data(0x4B); 
    Lcd_Write_Data(0x0A); 
    Lcd_Write_Data(0x0C); 
    Lcd_Write_Data(0x0E); 
    Lcd_Write_Data(0x18); 
    Lcd_Write_Data(0x1B); 
    Lcd_Write_Data(0x0F); 

    Lcd_Write_Com(0xE1);    
    Lcd_Write_Data(0x00);  
    Lcd_Write_Data(0x17); 
    Lcd_Write_Data(0x1A); 
    Lcd_Write_Data(0x04); 
    Lcd_Write_Data(0x0E); 
    Lcd_Write_Data(0x06); 
    Lcd_Write_Data(0x2F); 
    Lcd_Write_Data(0x45); 
    Lcd_Write_Data(0x43); 
    Lcd_Write_Data(0x02); 
    Lcd_Write_Data(0x0A); 
    Lcd_Write_Data(0x09); 
    Lcd_Write_Data(0x32); 
    Lcd_Write_Data(0x36); 
    Lcd_Write_Data(0x0F); 

    Lcd_Write_Com(0x11);    //Exit Sleep 
    delay(120); 			
    Lcd_Write_Com(0x29);    //Display on 

    digitalWrite(CS,HIGH);
}
void H_line(unsigned int x, unsigned int y, unsigned int l, unsigned int c)                   
{	
  unsigned int i,j;
  digitalWrite(CS,LOW);
  Lcd_Write_Com(0x02c); //write_memory_start
  //digitalWrite(RS,HIGH);
  l=l+x;
  Address_set(x,y,l,y);
  j=l*2;
  for(i=1;i<=j;i++)
  {
      Lcd_Write_Data((c>>8)&0xF8);
      Lcd_Write_Data((c>>3)&0xFC);
      Lcd_Write_Data(c<<3);
  }
  digitalWrite(CS,HIGH);   
}

void V_line(unsigned int x, unsigned int y, unsigned int l, unsigned int c)                   
{	
  unsigned int i,j;
  digitalWrite(CS,LOW);
  Lcd_Write_Com(0x02c); //write_memory_start
  //digitalWrite(RS,HIGH);
  l=l+y;
  Address_set(x,y,x,l);
  j=l*2;
  for(i=1;i<=j;i++)
  { 
      Lcd_Write_Data((c>>8)&0xF8);
      Lcd_Write_Data((c>>3)&0xFC);
      Lcd_Write_Data(c<<3);
  }
  digitalWrite(CS,HIGH);   
}

void Rect(unsigned int x,unsigned int y,unsigned int w,unsigned int h,unsigned int c)
{
  H_line(x  , y  , w, c);
  H_line(x  , y+h, w, c);
  V_line(x  , y  , h, c);
  V_line(x+w, y  , h, c);
}

void Rectf(unsigned int x,unsigned int y,unsigned int w,unsigned int h,unsigned int c)
{
  unsigned int i;
  for(i=0;i<h;i++)
  {
    H_line(x  , y  , w, c);
    H_line(x  , y+i, w, c);
  }
}

int RGB(int r,int g,int b)
{
  return r << 16 | g << 8 | b;
}

void LCD_Clear(unsigned int j)                   
{	
  unsigned int i,m;
  digitalWrite(CS,LOW);
  Address_set(0,0,320,480);
  for(i=0;i<320;i++)
    for(m=0;m<480;m++)
    {
      Lcd_Write_Data((j>>8)&0xF8);
      Lcd_Write_Data((j>>3)&0xFC);
      Lcd_Write_Data(j<<3);
    }
  digitalWrite(CS,HIGH);   
}
void setup() {
  // 初始化引脚模式
  pinMode(LED, OUTPUT);
  pinMode(RS, OUTPUT);
  pinMode(RESET, OUTPUT);
  pinMode(CS, OUTPUT);
  
  // 设置初始状态
  digitalWrite(LED, HIGH);  // 背光开启
  digitalWrite(CS, HIGH);   // 初始不选中
  digitalWrite(RS, HIGH);   // 初始数据模式
  
  SPI_Init();
  Lcd_Init();
}

void loop() {
  LCD_Clear(0xf800);  // 红色
  LCD_Clear(0x07E0);  // 绿色
  LCD_Clear(0x001F);  // 蓝色
  LCD_Clear(0x0000);  // 黑色
  
  for(int i=0; i<100; i++) {  // 减少测试图形数量
    Rect(random(300), random(300), random(100), random(100), random(65535));
  }
}

GFX示例

/*******************************************************************************
 * Start of Arduino_GFX setting
 *
 * Arduino_GFX try to find the settings depends on selected board in Arduino IDE
 * Or you can define the display dev kit not in the board list
 * Defalult pin list for non display dev kit:
 * Arduino Nano, Micro and more: CS:  9, DC:  8, RST:  7, BL:  6, SCK: 13, MOSI: 11, MISO: 12
 * ESP32 various dev board     : CS:  5, DC: 27, RST: 33, BL: 22, SCK: 18, MOSI: 23, MISO: nil
 * ESP32-C3 various dev board  : CS:  7, DC:  2, RST:  1, BL:  3, SCK:  4, MOSI:  6, MISO: nil
 * ESP32-S2 various dev board  : CS: 34, DC: 38, RST: 33, BL: 21, SCK: 36, MOSI: 35, MISO: nil
 * ESP32-S3 various dev board  : CS: 40, DC: 41, RST: 42, BL: 48, SCK: 36, MOSI: 35, MISO: nil
 * ESP8266 various dev board   : CS: 15, DC:  4, RST:  2, BL:  5, SCK: 14, MOSI: 13, MISO: 12
 * Raspberry Pi Pico dev board : CS: 17, DC: 27, RST: 26, BL: 28, SCK: 18, MOSI: 19, MISO: 16
 * RTL8720 BW16 old patch core : CS: 18, DC: 17, RST:  2, BL: 23, SCK: 19, MOSI: 21, MISO: 20
 * RTL8720_BW16 Official core  : CS:  9, DC:  8, RST:  6, BL:  3, SCK: 10, MOSI: 12, MISO: 11
 * RTL8722 dev board           : CS: 18, DC: 17, RST: 22, BL: 23, SCK: 13, MOSI: 11, MISO: 12
 * RTL8722_mini dev board      : CS: 12, DC: 14, RST: 15, BL: 13, SCK: 11, MOSI:  9, MISO: 10
 * Seeeduino XIAO dev board    : CS:  3, DC:  2, RST:  1, BL:  0, SCK:  8, MOSI: 10, MISO:  9
 * Teensy 4.1 dev board        : CS: 39, DC: 41, RST: 40, BL: 22, SCK: 13, MOSI: 11, MISO: 12
 ******************************************************************************/
#include <Arduino_GFX_Library.h>

// ESP12E 引脚定义
#define TFT_CS   15   // GPIO15
#define TFT_DC   4    // GPIO4
#define TFT_RST  2    // GPIO2
#define TFT_BL   5    // GPIO5(背光控制)
void testDisplay();
// 使用硬件SPI
Arduino_DataBus *bus = new Arduino_ESP8266SPI(TFT_DC, TFT_CS);
Arduino_GFX *gfx = new Arduino_ILI9488_18bit(bus, TFT_RST); // 使用18位模式

void setup(void) {
  Serial.begin(115200);
  Serial.println("\n\nILI9488 3.5\" Display Test");
  
  // 先控制复位引脚
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, LOW);
  delay(100);
  digitalWrite(TFT_RST, HIGH);
  delay(200);  // 复位后等待
  
  // 初始化背光(尝试两种逻辑)
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);  // 尝试高电平
  delay(100);
  digitalWrite(TFT_BL, LOW);   // 尝试低电平
  delay(100);
  
  Serial.println("Initializing display...");
  
  // 初始化显示屏(降低SPI速度)
  if (!gfx->begin(27000000)) { // 27MHz SPI速度
    Serial.println("Display init failed!");
  } else {
    Serial.println("Display init success!");
  }
  
  // 执行硬件测试
  testDisplay();
  
  Serial.println("Setup complete");
}

void testDisplay() {
  // 测试背光
  Serial.println("Testing backlight...");
  for (int i = 0; i < 3; i++) {
    digitalWrite(TFT_BL, HIGH);
    delay(300);
    digitalWrite(TFT_BL, LOW);
    delay(300);
  }
  digitalWrite(TFT_BL, HIGH); // 保持开启
  
  // 颜色填充测试
  Serial.println("Color fill test...");
  gfx->fillScreen(RED);
  delay(1000);
  gfx->fillScreen(GREEN);
  delay(1000);
  gfx->fillScreen(BLUE);
  delay(1000);
  
  // 显示文本
  gfx->fillScreen(BLACK);
  gfx->setTextColor(WHITE, BLACK);
  gfx->setTextSize(2, 2);
  gfx->setCursor(30, 140);
  gfx->println("ILI9488 3.5\"");
  
  gfx->setCursor(50, 180);
  gfx->println("480x320");
  
  gfx->setCursor(80, 220);
  gfx->setTextColor(YELLOW, BLACK);
  gfx->println("WORKING!");
}

void loop() {
  // 显示动态内容
  static int counter = 0;
  gfx->fillRect(0, 0, 480, 40, BLACK); // 清空顶部区域
  
  gfx->setTextSize(2, 2);
  gfx->setCursor(10, 10);
  gfx->setTextColor(random(0xFFFF), BLACK);
  gfx->printf("Counter: %d", counter++);
  
  delay(500);
}

GFX结合LVGL

/*******************************************************************************
 * [原有Arduino_GFX设置代码保持不变]
 ******************************************************************************/
#include <Arduino_GFX_Library.h>
#include <lvgl.h> // 添加LVGL库

void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p);
void create_test_ui();
// ESP12E 引脚定义
#define TFT_CS   15   // GPIO15
#define TFT_DC   4    // GPIO4
#define TFT_RST  2    // GPIO2
#define TFT_BL   5    // GPIO5(背光控制)

// 使用硬件SPI
Arduino_DataBus *bus = new Arduino_ESP8266SPI(TFT_DC, TFT_CS);
Arduino_GFX *gfx = new Arduino_ILI9488_18bit(bus, TFT_RST); // 使用18位模式

/* LVGL配置 */
#define LV_HOR_RES_MAX 480   // 水平分辨率
#define LV_VER_RES_MAX 320   // 垂直分辨率

#define LV_BUF_SIZE (LV_HOR_RES_MAX * 2) // 缓冲区大小(行数)

static lv_disp_draw_buf_t draw_buf;     // LVGL绘制缓冲区
static lv_color_t buf1[LV_BUF_SIZE];    // 第一缓冲区
static lv_color_t buf2[LV_BUF_SIZE];    // 第二缓冲区(双缓冲)

// 测试界面相关变量
static lv_obj_t *main_screen;
static lv_obj_t *time_label;
static lv_obj_t *counter_label;
static lv_obj_t *anim_bar;
static lv_obj_t *arc;
static uint32_t counter = 0;

void setup(void) {
  Serial.begin(115200);
  Serial.println("\n\nILI9488 with LVGL Integration");
  
  // 复位显示屏
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, LOW);
  delay(100);
  digitalWrite(TFT_RST, HIGH);
  delay(200);
  
  // 初始化背光
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);  // 保持常亮

  // 初始化显示屏
  if (!gfx->begin(27000000)) { // 27MHz SPI速度
    Serial.println("Display init failed!");
    while(1); // 停止执行
  }
  // 设置横屏模式
  gfx->setRotation(1); // 1表示90度旋转,通常是横屏
  Serial.println("Display init success!");

  /* 初始化LVGL */
  lv_init();
  
  /* 初始化双缓冲区 */
  lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_BUF_SIZE);
  
  /* 注册显示驱动 */
  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res = LV_HOR_RES_MAX;
  disp_drv.ver_res = LV_VER_RES_MAX;
  disp_drv.flush_cb = my_disp_flush;  // 注册刷新回调
  disp_drv.draw_buf = &draw_buf;
  lv_disp_drv_register(&disp_drv);

  /* 创建测试界面 */
  create_test_ui();
  
  Serial.println("LVGL test interface initialized");
}

/* 显示刷新回调函数 */
void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);
  
  gfx->draw16bitRGBBitmap(
    area->x1,        // x起始位置
    area->y1,        // y起始位置
    (uint16_t *)color_p, // 数据指针
    w,               // 宽度
    h                // 高度
  );
  
  lv_disp_flush_ready(disp); // 通知LVGL刷新完成
}

/* 创建测试界面 */
void create_test_ui() {
  // 创建主屏幕
  main_screen = lv_scr_act();
  lv_obj_set_style_bg_color(main_screen, lv_color_hex(0x003060), 0);
  
  // 标题
  lv_obj_t *title = lv_label_create(main_screen);
  lv_label_set_text(title, "LVGL Test Interface");
  // 移除字体设置,使用默认字体
  lv_obj_set_style_text_color(title, lv_color_white(), 0);
  lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
  
  // 时间显示标签
  time_label = lv_label_create(main_screen);
  lv_label_set_text(time_label, "Time: 0s");
  // 移除字体设置,使用默认字体
  lv_obj_set_style_text_color(time_label, lv_color_white(), 0);
  lv_obj_align(time_label, LV_ALIGN_TOP_MID, 0, 50);
  
  // 计数器显示标签
  counter_label = lv_label_create(main_screen);
  lv_label_set_text(counter_label, "Counter: 0");
  // 移除字体设置,使用默认字体
  lv_obj_set_style_text_color(counter_label, lv_color_white(), 0);
  lv_obj_align(counter_label, LV_ALIGN_TOP_MID, 0, 80);
  
  // 弧形进度条
  arc = lv_arc_create(main_screen);
  lv_obj_set_size(arc, 150, 150);
  lv_arc_set_rotation(arc, 180);
  lv_arc_set_bg_angles(arc, 0, 360);
  lv_arc_set_value(arc, 0);
  lv_obj_align(arc, LV_ALIGN_CENTER, 0, -20);
  
  // 弧形标签
  lv_obj_t *arc_label = lv_label_create(main_screen);
  lv_label_set_text(arc_label, "Arc Progress");
  lv_obj_set_style_text_color(arc_label, lv_color_white(), 0);
  lv_obj_align_to(arc_label, arc, LV_ALIGN_OUT_BOTTOM_MID, 0, 10);
  
  // 进度条
  anim_bar = lv_bar_create(main_screen);
  lv_obj_set_size(anim_bar, 300, 20);
  lv_obj_align(anim_bar, LV_ALIGN_BOTTOM_MID, 0, -50);
  lv_bar_set_range(anim_bar, 0, 100);
  lv_bar_set_value(anim_bar, 0, LV_ANIM_ON);
  
  // 进度条标签
  lv_obj_t *bar_label = lv_label_create(main_screen);
  lv_label_set_text(bar_label, "Animated Bar");
  lv_obj_set_style_text_color(bar_label, lv_color_white(), 0);
  lv_obj_align_to(bar_label, anim_bar, LV_ALIGN_OUT_TOP_MID, 0, -5);
  
  // 创建动画
  lv_anim_t a;
  lv_anim_init(&a);
  lv_anim_set_var(&a, anim_bar);
  lv_anim_set_values(&a, 0, 100);
  lv_anim_set_time(&a, 3000);
  lv_anim_set_playback_time(&a, 3000);
  lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
  lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_bar_set_value);
  lv_anim_start(&a);
}

void loop() {
  // 更新时间显示
  static uint32_t last_update = 0;
  uint32_t current_time = millis() / 1000;
  
  if (current_time != last_update) {
    last_update = current_time;
    char time_buf[32];
    sprintf(time_buf, "Time: %ds", (int)current_time);
    lv_label_set_text(time_label, time_buf);
    
    // 更新计数器
    counter++;
    char counter_buf[32];
    sprintf(counter_buf, "Counter: %d", (int)counter);
    lv_label_set_text(counter_label, counter_buf);
    
    // 更新弧形进度条
    lv_arc_set_value(arc, counter % 101);
  }
  
  lv_timer_handler(); // 处理LVGL任务
  delay(5); // 短暂延时
}

触摸屏驱动解决方案 (ILI9488 + MSP3520)

有问题

#include <Arduino_GFX_Library.h>
#include <XPT2046_Touchscreen.h> // MSP3520兼容XPT2046库

// 显示屏引脚配置
#define TFT_CS   15   // GPIO15
#define TFT_DC   4    // GPIO4
#define TFT_RST  2    // GPIO2
#define TFT_BL   5    // GPIO5

// 触摸屏引脚配置
#define TOUCH_CS 16   // GPIO16 (触摸片选)
#define TOUCH_IRQ -1  // 不使用中断

// 使用硬件SPI
Arduino_DataBus *bus = new Arduino_ESP8266SPI(TFT_DC, TFT_CS);
Arduino_GFX *gfx = new Arduino_ILI9488_18bit(bus, TFT_RST);

// 触摸屏对象
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);

// 触摸校准参数 (根据实际调整)
const int TS_MINX = 200;
const int TS_MINY = 200;
const int TS_MAXX = 3800;
const int TS_MAXY = 3800;

void setup() {
  Serial.begin(115200);
  Serial.println("\nILI9488 + MSP3520 Test");
  
  // 显示屏复位
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, LOW);
  delay(50);
  digitalWrite(TFT_RST, HIGH);
  delay(150);
  
  // 背光初始化(固定高电平)
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);  // 保持常亮

  // 降低SPI速度确保稳定性
  if (!gfx->begin(15000000)) { // 15MHz
    Serial.println("Display init failed!");
    while(1); // 卡死检测
  }
  Serial.println("Display ready");
  
  // 触摸屏初始化
  touch.begin();
  touch.setRotation(1); // 根据屏幕方向调整
  Serial.println("Touch ready");

  // 显示初始界面
  gfx->fillScreen(BLACK);
  gfx->setTextSize(2, 2);
  gfx->setTextColor(WHITE);
  gfx->setCursor(60, 140);
  gfx->print("TOUCH TEST");
}

void loop() {
  // 触摸检测
  if (touch.touched()) {
    TS_Point p = touch.getPoint();
    
    // 坐标转换 (480x320)
    int x = map(p.y, TS_MINY, TS_MAXY, 0, 479);
    int y = map(p.x, TS_MINX, TS_MAXX, 319, 0);
    
    Serial.printf("Touch: X=%d, Y=%d, Pressure=%d\n", x, y, p.z);
    
    // 视觉反馈:触摸点
    gfx->fillCircle(x, y, 5, RED);
    delay(50);
    gfx->fillCircle(x, y, 5, BLACK);
  }
  
  // 显示帧率计数器
  static uint32_t lastUpdate = 0;
  static int counter = 0;
  
  if (millis() - lastUpdate > 1000) {
    gfx->fillRect(0, 0, 100, 30, BLACK);
    gfx->setCursor(10, 10);
    gfx->setTextColor(GREEN);
    gfx->printf("FPS:%2d", counter);
    
    counter = 0;
    lastUpdate = millis();
  }
  counter++;
}

使用TFT_eSPI库驱动

需要配置TFT_eSPI

  1. 打开Arduino库文件夹中的TFT_eSPI目录
  2. 编辑User_Setup.h文件
  3. 确保有以下配置:


#define USER_SETUP_INFO "User_Setup"

#define TFT_DRIVER      ILI9488

// ESP8266引脚配置
#define TFT_CS   15   // GPIO15
#define TFT_DC   4    // GPIO4
#define TFT_RST  2    // GPIO2

// 使用SPI
#define TFT_SPI_FREQUENCY  27000000

// 屏幕尺寸
#define TFT_WIDTH  480
#define TFT_HEIGHT 320

// 颜色格式
#define TFT_RGB_ORDER TFT_BGR

// 字体设置
#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF
#include <TFT_eSPI.h>      // 包含TFT_eSPI库
#include <SPI.h>           // SPI通信库
#include <XPT2046_Touchscreen.h> // MSP3520触摸屏驱动

// 屏幕尺寸定义
#define SCREEN_WIDTH  480
#define SCREEN_HEIGHT 320

// 触摸屏引脚定义
#define TOUCH_CS  16  // GPIO16 (触摸片选)
#define TOUCH_IRQ -1  // 不使用中断

// 创建TFT和触摸对象
TFT_eSPI tft = TFT_eSPI();  // 创建TFT对象
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);  // 创建触摸对象

// 触摸校准参数 (根据实际情况调整)
const int TS_MINX = 250;
const int TS_MINY = 280;
const int TS_MAXX = 3750;
const int TS_MAXY = 3780;

// 颜色定义
#define BACKGROUND_COLOR 0x18E3  // 深蓝色背景
#define UI_COLOR         0x07FF  // 青色UI元素
#define TEXT_COLOR       0xFFFF  // 白色文本
#define BUTTON_COLOR     0xF800  // 红色按钮
#define BUTTON_TEXT      0xFFFF  // 白色按钮文本

// 按钮结构
struct Button {
  int x, y, width, height;
  String label;
};

// 创建按钮数组
Button buttons[] = {
  {50, 120, 120, 50, "Button 1"},
  {190, 120, 120, 50, "Button 2"},
  {330, 120, 120, 50, "Button 3"}
};

const int buttonCount = sizeof(buttons) / sizeof(Button);

void setup() {
  Serial.begin(115200);
  Serial.println("\nILI9488 + MSP3520 with TFT_eSPI");
  
  // 初始化显示屏
  tft.init();
  tft.setRotation(1);  // 设置屏幕方向 (0-3)
  tft.fillScreen(TFT_BLACK);
  
  // 初始化背光 (如果连接了背光引脚)
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);  // 开启背光
  
  // 初始化触摸屏
  touch.begin();
  touch.setRotation(1);  // 设置触摸方向与屏幕一致
  
  // 创建UI界面
  createUI();
  
  Serial.println("Setup complete");
}

void createUI() {
  // 绘制背景
  tft.fillScreen(BACKGROUND_COLOR);
  
  // 绘制标题
  tft.setTextColor(TEXT_COLOR, BACKGROUND_COLOR);
  tft.setTextSize(3);
  tft.setTextDatum(TC_DATUM);  // 顶部居中
  tft.drawString("TFT_eSPI Demo", SCREEN_WIDTH / 2, 20);
  
  // 绘制子标题
  tft.setTextSize(2);
  tft.drawString("ILI9488 + MSP3520", SCREEN_WIDTH / 2, 60);
  
  // 绘制按钮
  for (int i = 0; i < buttonCount; i++) {
    drawButton(buttons[i]);
  }
  
  // 绘制触摸状态区域
  tft.fillRoundRect(50, 200, SCREEN_WIDTH - 100, 80, 10, TFT_DARKGREY);
  tft.setTextColor(TEXT_COLOR, TFT_DARKGREY);
  tft.setTextSize(2);
  tft.drawString("Touch Status", SCREEN_WIDTH / 2, 210);
  tft.setTextSize(1);
  tft.drawString("Touch screen to see coordinates", SCREEN_WIDTH / 2, 240);
}

void drawButton(Button btn) {
  // 绘制按钮背景
  tft.fillRoundRect(btn.x, btn.y, btn.width, btn.height, 10, BUTTON_COLOR);
  
  // 绘制按钮边框
  tft.drawRoundRect(btn.x, btn.y, btn.width, btn.height, 10, TFT_WHITE);
  
  // 绘制按钮文本
  tft.setTextColor(BUTTON_TEXT, BUTTON_COLOR);
  tft.setTextSize(2);
  tft.setTextDatum(MC_DATUM);  // 居中
  tft.drawString(btn.label, btn.x + btn.width / 2, btn.y + btn.height / 2);
}

void processTouch() {
  static int lastX = -1, lastY = -1;
  
  if (touch.touched()) {
    TS_Point p = touch.getPoint();
    
    // 映射触摸坐标到屏幕坐标
    int x = map(p.y, TS_MINY, TS_MAXY, 0, SCREEN_WIDTH - 1);
    int y = map(p.x, TS_MINX, TS_MAXX, SCREEN_HEIGHT - 1, 0);
    
    // 限制坐标在屏幕范围内
    x = constrain(x, 0, SCREEN_WIDTH - 1);
    y = constrain(y, 0, SCREEN_HEIGHT - 1);
    
    // 显示触摸坐标
    tft.fillRect(60, 250, 360, 20, TFT_DARKGREY);
    tft.setTextColor(TFT_YELLOW, TFT_DARKGREY);
    tft.setTextSize(2);
    tft.drawString("X: " + String(x) + " Y: " + String(y), SCREEN_WIDTH / 2, 250);
    
    // 检查按钮点击
    for (int i = 0; i < buttonCount; i++) {
      if (isPointInButton(x, y, buttons[i])) {
        buttonPressed(i);
        break;
      }
    }
    
    // 绘制触摸点
    if (lastX != -1 && lastY != -1) {
      tft.fillCircle(lastX, lastY, 4, BACKGROUND_COLOR);
    }
    tft.fillCircle(x, y, 4, TFT_YELLOW);
    
    lastX = x;
    lastY = y;
  } 
  else if (lastX != -1) {
    // 清除触摸点
    tft.fillCircle(lastX, lastY, 4, BACKGROUND_COLOR);
    lastX = lastY = -1;
  }
}

bool isPointInButton(int x, int y, Button btn) {
  return (x >= btn.x && x < (btn.x + btn.width) &&
          y >= btn.y && y < (btn.y + btn.height));
}

void buttonPressed(int index) {
  // 视觉反馈:按钮按下效果
  Button btn = buttons[index];
  
  // 绘制按下的按钮
  tft.fillRoundRect(btn.x, btn.y, btn.width, btn.height, 10, TFT_DARKGREEN);
  tft.drawRoundRect(btn.x, btn.y, btn.width, btn.height, 10, TFT_WHITE);
  tft.setTextColor(TFT_WHITE, TFT_DARKGREEN);
  tft.setTextSize(2);
  tft.drawString(btn.label, btn.x + btn.width / 2, btn.y + btn.height / 2);
  
  // 在串口显示按下的按钮
  Serial.println("Button pressed: " + btn.label);
  
  delay(100); // 短暂显示按下的状态
  
  // 恢复按钮原始状态
  drawButton(btn);
}

void updateCounter() {
  static uint32_t lastUpdate = 0;
  static int counter = 0;
  
  if (millis() - lastUpdate > 1000) {
    // 更新计数器显示
    tft.fillRect(SCREEN_WIDTH - 100, 10, 90, 25, BACKGROUND_COLOR);
    tft.setTextColor(TEXT_COLOR, BACKGROUND_COLOR);
    tft.setTextSize(2);
    tft.setTextDatum(TR_DATUM); // 右上角对齐
    tft.drawString("Time: " + String(counter) + "s", SCREEN_WIDTH - 10, 10);
    
    counter++;
    lastUpdate = millis();
  }
}

void loop() {
  processTouch();  // 处理触摸事件
  updateCounter(); // 更新计数器
  delay(10);       // 短暂延迟
}

🔍 USB设备 vs 无线设备(ADB中的本质区别)

对比项USB连接设备无线连接设备(TCP/IP)
显示在 adb devices 中的标识硬件序列号(如 f3ec0fc48fafc16f)IP:端口(如 192.168.1.100:5555)
连接方式插上USB线,ADB自动识别(需驱动)必须先用 adb tcpip 5555 开启端口,再用 adb connect IP:PORT 手动连接
连接命令无需 adb connect,直接识别必须 adb connect,是无线特有的
断开方式拔掉USB线adb disconnect IP:PORT 或 adb disconnect
传输稳定性高速、稳定受Wi-Fi影响,可能延迟
适用场景刷机、传大文件、底层调试摆脱线缆,适合屏幕投影或日常调试

📌 单设备 vs 多设备(命令用法的唯一区别)

无论设备是USB还是无线,ADB命令本身完全一样,区别仅在于:

  • 只有一个设备时:直接敲 adb shell、adb push、adb pull,无需额外参数。
  • 有多个设备时:所有命令都必须加 -s 指定设备标识(标识就是 adb devices 显示的那一串)。

也就是说:

  • 单设备:adb push <本地> <远程>
  • 多设备:adb -s <设备标识> push <本地> <远程> (标识可以是序列号或IP:端口)

同样的,adb pull、adb shell 等也是如此。

⚠️ 容易混淆的关键点(你的总结里没错,但我要强调)

  1. adb connect 只用于无线设备,不能用于USB设备(更不能接USB序列号)。
  2. adb push 是推送(电脑→设备),adb pull 是拉取(设备→电脑),两者在多设备时都需要加 -s,你文件中的示例只给了单设备,但实际多设备时用法一样,只是多了 -s。
  3. 无线连接后,所有ADB命令和USB设备完全通用,只是设备标识变了(IP:端口),所以多设备时 adb -s 192.168.1.100:5555 push ... 完全可行。

qemu ARM 驱动学习

本文档记录从零开始搭建驱动学习项目的完整步骤,包含所有脚本的源码与说明。


目录


1. 安装前置工具

# 设备树编译器
apt-get install -y device-tree-compiler

# QEMU ARM 模拟器
apt-get install -y qemu-system-arm

# ARM 交叉编译器 (也可用 Linaro 等预编译工具链)
apt-get install -y gcc-arm-linux-gnueabihf

# 辅助工具
apt-get install -y parted dosfstools mtools cpio

验证:

dtc --version                     # Device Tree Compiler 1.6.x
qemu-system-arm --version         # QEMU 6.x
arm-linux-gnueabihf-gcc --version # ARM gcc 11.x

2. 内核源码与编译

2.1 获取源码

# 方法 A:主线内核
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.226.tar.xz
tar -xf linux-5.10.226.tar.xz
mv linux-5.10.226 kernel

# 方法 B:厂商内核(RK、NXP 等,含 SoC 驱动补丁)
# 本项目使用 Rockchip 5.10 内核
# 目录: kernel/

2.2 配置内核

cd kernel
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- vexpress_defconfig

vexpress_defconfig 开启的关键选项:

配置项说明
CONFIG_ARCH_VEXPRESS=yvexpress 平台
CONFIG_SMP=y多核支持
CONFIG_ARM_GIC=y中断控制器
CONFIG_SERIAL_AMBA_PL011=y串口驱动
CONFIG_MMC_PL180=ySD 卡
CONFIG_SMC_LAN9118=y网卡

涉及的文件:

文件说明
arch/arm/configs/vexpress_defconfig默认配置
.config生成的最终配置
include/generated/autoconf.h配置的 C 头文件
include/config/配置标记目录

2.3 编译内核

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

编译产物:

文件说明
arch/arm/boot/zImage内核镜像
arch/arm/boot/dts/*.dtb设备树
vmlinux未压缩内核(带符号表)
Module.symvers模块符号表

2.4 vermagic 匹配原理

vermagic 由 .config 决定,必须和运行内核一致:

.config 选项vermagic 字段
CONFIG_SMP=ySMP
CONFIG_PREEMPT=ypreempt
CONFIG_THUMB2_KERNEL=ythumb2
# 查看内核 vermagic
strings arch/arm/boot/zImage | grep vermagic

2.5 kernel-build.sh

该脚本由 boot.sh 自动调用,也可单独执行。

位置:kernel-build.sh

#!/bin/bash
# kernel-build.sh - 编译 ARM 内核 (vexpress_defconfig)
# boot.sh 自动调用,也可单独执行

set -e

DIR="$(cd "$(dirname "$0")" && pwd)"
KERNEL_SRC="$DIR/kernel"

cd "$KERNEL_SRC"

if [ "$1" = "clean" ]; then
    echo "清理内核..."
    if [ -f arch/arm/boot/zImage ]; then
        ts=$(date +%Y-%m-%d-%H:%M:%S)
        cp arch/arm/boot/zImage "arch/arm/boot/zImage-vexpress-backup-${ts}"
        echo "已备份: zImage-vexpress-backup-${ts}"
    fi
    make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean 2>&1
    make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- clean 2>&1
    echo "=== 清理完成 ==="
    exit 0
fi

echo "配置内核 (vexpress_defconfig)..."
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- vexpress_defconfig 2>&1

echo "编译内核... make -j$(nproc)"
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc) zImage dtbs modules_prepare 2>&1

echo ""
echo "=== 内核编译完成 ==="
ls -lh arch/arm/boot/zImage

echo ""
echo "复制 vexpress 设备树到 dts/..."
cp arch/arm/boot/dts/vexpress-v2p-ca9.dts  "$DIR/dts/" 2>/dev/null
cp arch/arm/boot/dts/vexpress-v2m.dtsi     "$DIR/dts/" 2>/dev/null
cp arch/arm/boot/dts/vexpress-v2p-ca9.dtb  "$DIR/dts/" 2>/dev/null
echo "完成"

用法:

./kernel-build.sh         # 编译内核
./kernel-build.sh clean   # 清理(自动备份 zImage)

3. 根文件系统 (initramfs)

3.1 设计思路

不使用完整 Linux 根文件系统(几百 MB),而是用最小 initramfs:

initramfs.cpio.gz (~1.4MB)
├── init              (PID 1 进程, 编译自 init.c)
└── bin/
    ├── busybox       (静态编译, 包含 sh/ls/insmod 等 100+ 命令)
    ├── sh → busybox
    ├── insmod → busybox
    └── ...

3.2 busybox 编译

修改busybox 的.config (table补全功能)

CONFIG_ASH_TAB_COMPLETION = y

编译:

cd rootfs
tar -xf busybox.tar.bz2
cd busybox-1_36_1
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- defconfig
# → Settings: Build static binary (no shared libs) [=y]
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4
# 产物: busybox (ARM 静态二进制)

3.3 init.c

PID 1 进程:挂载文件系统并启动 shell。

位置:rootfs/init.c

/* init: 挂载文件系统, 启动交互式 shell */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mount.h>
#include <sys/reboot.h>

int main(void)
{
    mkdir("/proc", 0755);
    mount("proc", "/proc", "proc", 0, NULL);
    mkdir("/sys", 0755);
    mount("sysfs", "/sys", "sysfs", 0, NULL);
    mkdir("/dev", 0755);
    mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);

    setenv("PS1", "\\w # ", 1);
    printf("QEMU ARM 设备树学习环境 - poweroff 关机\n");

    execl("/bin/sh", "sh", NULL);

    sync();
    reboot(0x4321fedc);
    return 0;
}

3.4 build-initramfs.sh

位置:rootfs/build-initramfs.sh

#!/bin/bash
# build-initramfs.sh - 构建完整的 initramfs 根文件系统
#   1. 编译 init.c
#   2. 放入 busybox 并预创建所有命令的软链接
#   3. 打包成 cpio.gz
#
# 输出: initramfs.cpio.gz

set -e
cd "$(dirname "$0")"

arm-linux-gnueabihf-gcc -static -Os -o init init.c 2>&1 | grep -v warn_unused || true

# 清理并创建目录结构
rm -rf initramfs_root
mkdir -p initramfs_root/{bin,sbin,etc,dev,proc,sys,tmp}

cp init initramfs_root/
cp busybox-1_36_1/busybox initramfs_root/bin/
chmod +x initramfs_root/init initramfs_root/bin/busybox

# 复制 .ko 模块(如果有)
if [ -d ../modules ] && [ "$(ls -A ../modules/**/*.ko 2>/dev/null)" ]; then
    echo "复制内核模块..."
    mkdir -p initramfs_root/modules
    cp ../modules/**/*.ko initramfs_root/modules/
fi

# 预创建 busybox 命令软链接
BB_LN="ln -s /bin/busybox initramfs_root"

for cmd in \
    sh ls cp mv rm cat echo printf test sleep \
    mkdir rmdir ln chmod chown chgrp \
    clear reset stty tty \
    ps top free uptime kill killall pkill \
    insmod lsmod rmmod modprobe modinfo \
    dmesg mount umount \
    grep find head tail wc sort cut tr uniq \
    seq basename dirname readlink realpath \
    date cal hexdump xxd strings \
    which env set export unset \
    tar gzip gunzip zcat bzip2 bunzip2 \
    vi diff patch cmp less more \
    df du sync dd mknod \
    xargs expr timeout yes nice \
    pidof pgrep watch; do
    $BB_LN/bin/$cmd 2>/dev/null || true
done

# poweroff 用 sysrq 触发关机
cat > initramfs_root/sbin/poweroff << 'EOF'
#!/bin/sh
sync
echo 1 > /proc/sys/kernel/sysrq 2>/dev/null
echo o > /proc/sysrq-trigger
EOF
chmod +x initramfs_root/sbin/poweroff

for cmd in \
    reboot halt \
    mdev ifconfig route ping ping6 arp \
    udhcpc ip neigh \
    swapon swapoff fdisk blkid blockdev \
    hwclock sysctl trigger; do
    $BB_LN/sbin/$cmd 2>/dev/null || true
done

cd initramfs_root
find . | cpio -o -H newc | gzip > ../initramfs.cpio.gz
cd ..

echo "=== initramfs 已生成 ==="
ls -lh initramfs.cpio.gz

rm -rf initramfs_root init

3.5 手动构建

cd rootfs
bash build-initramfs.sh

4. 设备树编译

4.1 文件结构

dts/
├── vexpress-v2p-ca9.dts    ← 板级设备树
└── vexpress-v2m.dtsi       ← 母板描述(被 include)

vexpress-v2p-ca9.dts 通过 #include "vexpress-v2m.dtsi" 引用公共部分。

4.2 编译命令

# 因为使用了 #include,需要用 cpp 预处理后再用 dtc 编译
cpp -nostdinc -I dts/ -undef -x assembler-with-cpp \
    dts/vexpress-v2p-ca9.dts | \
dtc -I dts -O dtb -o dts/vexpress-v2p-ca9.dtb -

# 反编译查看 DTB 内容
dtc -I dtb -O dts dts/vexpress-v2p-ca9.dtb

4.3 phandle 机制

DTS 中的标签(&gic、&uart0)在编译时被替换为数字 phandle:

DTS (源文件):  interrupts = <&gic 0 37 4>;
DTB (二进制):  interrupts = <0x0001 0x00 0x25 0x04>;

5. 内核模块开发

5.1 模块源码

位置:modules/002_of/002_of.c

#include "linux/module.h"
#include "linux/init.h"
#include "linux/of.h"

static int of_init(void)
{ 
    printk("of_init\n");
    return 0;
}

static void of_exit(void)
{
    printk("of_exit\n");
}

module_init(of_init);
module_exit(of_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("wyl");
MODULE_DESCRIPTION("of function test");

5.2 模块 Makefile

位置:modules/002_of/Makefile

# ARM 交叉编译内核模块
KERNELDIR ?= ../../kernel

PWD := $(shell pwd)

obj-m := 002_of.o

all:
    $(MAKE) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \
        -C $(KERNELDIR) M=$(PWD) modules

clean:
    $(MAKE) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \
        -C $(KERNELDIR) M=$(PWD) clean

5.3 编译流程

make -C kernel M=modules/002_of modules
  ├── 读取 kernel/Makefile
  ├── 读取 kernel/.config          ← 生成 vermagic
  ├── 进入 scripts/mod/modpost     ← 符号校验
  ├── 进入 modules/002_of/
  │     ├── 002_of.c → .o
  │     ├── modpost → .mod.c
  │     └── 链接 → .ko
  └── 生成 Module.symvers

5.4 常见问题

错误原因解决
invalid module formatvermagic 不匹配用同一份 .config 重编
Unknown symbol内核符号未导出检查 EXPORT_SYMBOL
undefined reference函数不存在确认内核 API 版本

6. QEMU 启动

6.1 boot.sh

主启动脚本。

位置:boot.sh

#!/bin/bash
# boot.sh - QEMU ARM 驱动/设备树学习环境 一键启动

DIR="$(cd "$(dirname "$0")" && pwd)"
KERNEL_DIR="$DIR/kernel/arch/arm/boot"

# 检查内核是否已编译
if [ ! -f "$KERNEL_DIR/zImage" ]; then
    echo "================================================"
    echo "  没有内核 zImage, 开始编译内核..."
    echo "================================================"
    bash "$DIR/kernel-build.sh"
    echo ""
fi

echo "编译设备树..."
(cpp -nostdinc -I"$DIR/dts" -undef -x assembler-with-cpp \
  "$DIR/dts/vexpress-v2p-ca9.dts" 2>/dev/null | \
dtc -I dts -O dtb -o "$DIR/dts/vexpress-v2p-ca9.dtb" - 2>&1) | grep -v Warning || true

echo "构建 initramfs..."
bash "$DIR/rootfs/build-initramfs.sh"

echo "=== QEMU vexpress-a9 启动 ==="
qemu-system-arm \
    -M vexpress-a9 \
    -kernel "$KERNEL_DIR/zImage" \
    -dtb "$DIR/dts/vexpress-v2p-ca9.dtb" \
    -initrd "$DIR/rootfs/initramfs.cpio.gz" \
    -append "console=ttyAMA0,115200" \
    -nographic -m 512M -no-reboot

6.2 启动流程

./boot.sh
  │
  ├── ① 检查 zImage → 没有则 kernel-build.sh
  │
  ├── ② 设备树: dts → dtb
  │
  ├── ③ initramfs: init.c + busybox → cpio.gz
  │
  └── ④ QEMU
        │
        └── 虚拟机内部
              ├── 加载 zImage
              ├── 加载 initramfs
              ├── 传入 DTB
              ├── 内核启动 → /init
              └── shell 就绪

6.3 QEMU 参数说明

参数说明
-M vexpress-a9模拟 vexpress-a9 开发板
-kernel内核镜像路径
-dtb设备树路径
-initrd根文件系统路径
-append "console=ttyAMA0"内核命令行
-nographic无图形界面
-m 512M512MB 内存
-no-reboot关机即退出

6.4 vexpress-a9 外设列表

地址设备内核驱动
0x10009000PL011 UART (控制台)SERIAL_AMBA_PL011
0x1000a000PL011 UART #1同上
0x10011000SP805 定时器ARM_TIMER_SP805
0x10017000PL031 RTCRTC_DRV_PL031
0x10020000PL181 MMC (SD卡)MMC_ARMMMCI
0x100e0000SMSC LAN9118 网卡SMC_LAN9118
0x1e000000GIC 中断控制器ARM_GIC

vexpress-a9 没有 I2C/SPI/GPIO 模拟。需要这些外设需用 -M virt。


7. 错误排查

7.1 QEMU 无输出

# 原因:控制台参数不对
# 排查:检查 DTS 中 UART 地址和内核命令行
# 解决:确认 console= 参数与 DTB 中的 UART 匹配

7.2 Kernel panic: VFS: Unable to mount root fs

# 原因:内核没找到根文件系统
# 排查:
#   - -initrd 参数是否正确
#   - initramfs 是否损坏
# 解决:
file rootfs/initramfs.cpio.gz  # 应是 gzip compressed data

7.3 insmod: invalid module format

# 原因:vermagic 不匹配
# 排查:
strings kernel/arch/arm/boot/zImage | grep vermagic
strings modules/xxx/xxx.ko | grep vermagic
# 解决:确保 .config 一致,重新编译模块

7.4 make 报错 .git 不存在

# 原因:内核源码是 tar 解压的,没有 git 仓库
# 影响:无,只是警告信息
# 解决:忽略

附:全部脚本索引

#文件名用途
1boot.sh一键启动 QEMU
2kernel-build.sh编译/清理内核
3rootfs/build-initramfs.sh构建 initramfs
4rootfs/init.cPID 1 进程源码
5modules/002_of/Makefile模块 Makefile
6modules/002_of/002_of.c模块示例源码

全志开发板

登录信息

  • 本地 IP:192.168.0.104
  • SSH 用户名:root
  • SSH 密码:root
  • 登录命令:ssh root@192.168.0.104

注意:此文件包含明文密码,不要上传到公开仓库或发送给无关人员。

外设

  • USB 摄像头:VGA Webcam: HGX Camera
  • 驱动:Linux UVC (uvcvideo)
  • 设备节点:/dev/video0
  • 支持格式:
    • YUYV:640×480 / 480×480,标称 25 fps
    • MJPEG:640×480 / 480×480,标称 25 fps
  • 摄像头没有枚举出 USB 麦克风

学习目标

  • 全志平台音视频开发
  • 摄像头采集、编码、传输与播放
  • 网络摄像头 / 视频监控应用

2026-08-16 现场探测

SSH 已成功登录,当前环境如下:

  • 开发板:野火鲁班猫 A1(设备树 sun50iw9-lubancat-a1.dtb)
  • SoC:全志 H616(allwinner,h616 / sun50iw9)
  • CPU:4 核 Cortex-A53,最高 1.512 GHz,AArch64
  • 内存:约 4 GB
  • 系统:Ubuntu 22.04.4 LTS
  • 内核:Linux 5.4.125,全志 BSP 内核
  • 视频硬件设备:存在 /dev/cedar_dev
  • 已安装:v4l-utils、GStreamer 核心库
  • 尚未安装:FFmpeg、GStreamer 命令行工具和常用插件
  • ALSA 当前只有板载音频设备,没有可直接使用的真实录音输入;学习音频采集时可加一个 USB 麦克风或 USB 声卡

摄像头已通过 v4l2-ctl 连续抓取 50 帧,数据流可以正常启动。虽然设备标称 25 fps,本次实测约 13~14 fps;后续需要从光照/自动曝光、USB 链路和驱动参数三个方向确认瓶颈。

常用检查命令:

v4l2-ctl --all -d /dev/video0
v4l2-ctl --list-formats-ext -d /dev/video0
v4l2-ctl -d /dev/video0 \
  --set-fmt-video=width=640,height=480,pixelformat=MJPG \
  --set-parm=25 --stream-mmap=3 --stream-count=100 --stream-to=/dev/null

推荐学习路线

第一阶段:先弄懂 Linux 通用音视频接口

先不要碰全志私有库,掌握这些概念:

  1. V4L2 的设备、格式、分辨率、帧率和 controls。
  2. YUYV、NV12、MJPEG、H.264 的区别;原始帧与压缩码流的区别。
  3. REQBUFS、mmap、QBUF/DQBUF、poll,以及每帧的时间戳。
  4. ALSA 的声卡、PCM、采样率、采样格式、period/buffer。

第一个小项目:用 v4l2-ctl 抓一张 MJPEG 图片;然后用 C 写一个 V4L2 mmap 程序,连续取帧并统计真实 fps。

第二阶段:跑通第一条网络视频链路

建议安装:

apt update
apt install ffmpeg gstreamer1.0-tools \
  gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
  gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly

优先使用摄像头原生 MJPEG,不做二次编码。开发板发送 RTP/JPEG(把接收端 IP 换成电脑 IP):

gst-launch-1.0 -v v4l2src device=/dev/video0 ! \
  'image/jpeg,width=640,height=480,framerate=25/1' ! \
  rtpjpegpay ! udpsink host=192.168.0.103 port=5000

电脑接收:

gst-launch-1.0 -v udpsrc port=5000 \
  caps='application/x-rtp,media=video,encoding-name=JPEG,payload=26' ! \
  rtpjpegdepay ! jpegdec ! autovideosink

这个阶段重点理解 RTP 包、UDP 丢包、延迟和接收端抖动缓冲,不要急着做网页播放器。

第三阶段:H.264、RTSP 与浏览器观看

先用软件 x264 在 640×480 下学习编码参数:码率、GOP、关键帧、低延迟和码率控制。然后引入 MediaMTX 一类现成服务器:

/dev/video0 -> V4L2 -> H.264 编码 -> RTSP/MediaMTX
                                    -> VLC
                                    -> WebRTC/HLS -> 浏览器

RTSP 适合 VLC/监控客户端,但浏览器一般不能直接播放 RTSP;需要转换为 WebRTC 或 HLS。把“采集/编码”和“流媒体服务器”拆开学习,比从零实现 RTSP 服务器更合适。

第四阶段:用 C/C++ 做应用

建议按这个顺序写:

  1. V4L2 mmap 抓帧器。
  2. libjpeg-turbo 解 MJPEG,或直接转交压缩帧。
  3. GStreamer appsink/appsrc 接入自己的业务代码。
  4. 增加断线重连、帧率统计、队列上限和丢帧策略。
  5. 接入 USB 麦克风,用 ALSA 采集 PCM,再编码 Opus/AAC。
  6. 用 PTS 做音画同步;不要依靠“每秒固定多少帧”推算时间。

开发依赖可按需安装:

apt install build-essential cmake pkg-config libv4l-dev libturbojpeg0-dev \
  libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libasound2-dev

第五阶段:全志 H616 硬件编解码

当前 /dev/cedar_dev 只说明 BSP 内核提供了 Video Engine 接口;系统里没有发现配套的 CedarX/VideoEngine 用户态库,不能仅凭这个设备节点直接让 Ubuntu 自带 FFmpeg 硬编。

这一步应使用与鲁班猫 A1 当前 BSP/内核匹配的野火或全志 SDK,重点找这些内容:

  • CedarX / VideoEngine / VEncoder 示例和库
  • H.264 编码示例、DMA/物理连续内存分配方式
  • 摄像头帧到编码器的零拷贝路径
  • 编码器输出 Annex-B H.264 后交给 GStreamer/FFmpeg/MediaMTX 推流

不要混用其他 H616 镜像里的二进制库,也不要把主线 Linux 的 Cedrus 解码接口和厂商 BSP 的 CedarX 接口当成同一套 API。先确认官方 SDK 与 5.4.125 内核、AArch64 用户态完全匹配。

建议的练手项目

按完成顺序:

  1. camera-info:打印摄像头能力、格式和 controls。
  2. v4l2-capture:mmap 取帧并计算 fps,保存单帧 JPEG。
  3. rtp-mjpeg-camera:把原生 MJPEG 通过 RTP 发到电脑。
  4. rtsp-h264-camera:软件 H.264 + MediaMTX,VLC 可观看。
  5. av-camera:USB 麦克风 + 摄像头,完成时间戳和音画同步。
  6. h616-hw-camera:把软件 H.264 替换为全志硬件编码,比较 CPU、延迟、功耗和画质。

页是读写单位,块是擦除单位

一、基本概念区分

术语常见设备作用最小操作单位
扇区 (Sector)机械硬盘、SD卡物理寻址和传输的最小单位通常 512B 或 4KB
块 (Block)NAND Flash擦除操作的最小单位通常 128KB、256KB 等
页 (Page)NAND Flash读写操作的最小单位通常 2KB、4KB、8KB 等

关键关系(针对 NAND Flash):

  • 一个块包含多个页(比如 64 个页,每个页 2KB → 块大小 128KB)
  • 读/写按页进行,擦除必须按块进行(不能单独擦除一个页)
  • “扇区”在 Flash 中不常用,但在一些文件系统或 SSD 控制器里会模拟扇区来兼容传统接口

二、举例

flash size = 255MB;
block size = 128KB;
page size = 2KB
  • 总容量 255 MB = 255 × 1024 KB = 261120 KB

  • 若块大小 = 128 KB,则块数量应为 261120 ÷ 128 = 2040 个

  • 若页大小 = 2 KB,则每块的页数应为 128 ÷ 2 = 64 页

参考项目:

embedded-study\littlefs:littlefs

使用流程参考笔记:1

内存卡,emmc,flash,eeprom(at24c256),nor flash(w25q64)这些存储设备里面都分为,页(page),扇区(sector),块(block)。

以w25q64这个存储芯片为例,共有

  • 128块(8MB/64KB),即每个块大小为64KB
  • 每个块又分成16个扇区(64KB/4KB),即每个扇区大小为4KB
  • 每个扇区又分成16页(4KB/256B),即每个页大小为256B

文件系统

区域内容作用
引导扇区/超级块文件系统魔数、块大小、inode 总数等文件系统的“身份证”,系统挂载时首先读取这里来识别格式
元数据区inode 表、目录项(dentry)记录文件的权限、大小、时间戳以及数据块的物理地址
数据区文件的实际内容存放你的文档、照片、代码等真实数据

超级块 = 书的版权页(写明书名、总页数、章节数)。

inode 表 = 书的目录(告诉你“第几章在第几页”)。

数据块 = 书的正文内容。

步骤Windows(自动)Linux(手动/自动)核心动作
1. 发现设备弹出“发现新硬件”内核生成设备节点 /dev/sdb1硬件被识别
2. 探测格式读引导扇区,解析 FAT32/NTFS 签名读 Superblock(超级块),解析 magic number读取“身份证”
3. 建立连接自动分配盘符 E:执行 mount /dev/sdb1 /mnt/sdcard建立映射关系
4. 访问数据直接访问 E:通过 /mnt/sdcard访问路径重定向

Flutter

dart项目创建

# dart --version
Dart SDK version: 3.14.0-85.0.dev (dev) (Mon Aug 3 09:10:12 2026 -0700) on "windows_x64"
  • 创建
dart create project-name
  • 运行
cd project-name # 进入项目 目录
dart run 

或者

cd project-name/bin # 进入到脚本目录
dart ./cli.dart     # 直接执行脚本
  • hello world代码
void main(List<String> arguments) {
  print('Hello world!');
}
  • 项目结构
project-name
│
├── bin/                          # 可执行入口目录
│   └── cli.dart                  # 主程序入口 (main 函数)
│
├── lib/                          # 库代码目录 (可被 import/复用)
│   └── cli.dart                  # 示例库: calculate() 返回 42
│
├── test/                         # 单元测试目录
│   └── cli_test.dart             # 测试 calculate() == 42
│
├── pubspec.yaml                  # 包元数据 & 依赖声明 (path, lints, test)
├── pubspec.lock                  # 依赖锁定版本 (自动生成)
├── analysis_options.yaml        # 静态分析/lint 配置 (package:lints)
│
├── .dart_tool/                   # pub/构建工具缓存 (自动生成, git 忽略)
├── .gitignore                    # git 忽略规则
│
├── CHANGELOG.md                  # 版本变更记录
├── README.md                     # 项目说明
│
└── Microsoft/                    # ⚠️ 杂项
    └── Windows/PowerShell/
        └── ModuleAnalysisCache   # PowerShell 分析器缓存 (与项目无关)
  • var

    自动推导

  • int

    int 存整形,不能将double类型赋值给int类型,可以通过**toDouble()**方法变成double类型

  • double

    double存小数,不能将int类型赋值给double类型,可以通过**toInt()**方法变成int类型

  • num

    存数字,可以是整形,可以是浮点型。可以将int或double类型赋值给num类型

  • bool

    布尔类型,false/true

  • String

    字符串类型

特性finalconst
值确定时间运行时 (Runtime)编译时 (Compile-time)
内存与性能普通对象,每次使用都会创建新实例常量池复用,相同值共享同一内存,性能更优
变量可修改性变量引用不可变变量引用不可变
对象内容可变性对象内部可以修改对象本身及其内容完全不可变
适用场景值需要在运行时才能确定,如 DateTime.now()值在编译时就能确定,如 3.14、"hello"
类成员变量可以,final 修饰不可以,需要使用 static const
final list = [1, 2];
// list = [3, 4]; // ❌ 错误:不能重新赋值
list.add(3);      // ✅ 正确:可以修改列表内容
print(list);      // 输出 [1, 2, 3]
const list = [1, 2];
// list = [3, 4]; // ❌ 错误:不能重新赋值
// list.add(3);   // ❌ 错误:不能修改内容

list

dart语言没有数组的概念,只有一个列表概念。也就是说dart想用数组只能用list。

  • 定义

    List names = ['Alice', 'Bob', 'Charlie'];
    List ages = [1, 2, 3];
    
  • 添加新数据到列表

    names.add("David");
    age.add(4);
    //添加另一个list
    names.addAll(["Eve", "Frank"]);
    age.addAll([5, 6]);
    
  • 移除

  • 遍历

  • .....剩下的方法调用就不演示了。

Map

key-value键值对

  • 定义

    Map map = {'a': 1, 'b': 2};
    Map map2 = {1: 1, 2: 2};
    
  • .....剩下的方法调用就不演示了。

  • 可空(加 ?):在类型后面加个问号,变量才能接受 null。
nullableName = 'Rose';       // ✅ 也允许
  • ?.(条件成员访问):如果对象为 null,直接返回 null,不会调用后续方法,避免崩溃。
String? str = null;
print(str?.length); // 输出 null(不会报错)
  • ??(空值合并):如果左边为 null,就使用右边的默认值。
String? str = null;
String result = str ?? '默认值'; // result 为 '默认值'
  • ??=(空赋值):只有当变量为 null 时,才给它赋值。
String? str = null;
str ??= '赋值'; // str 变为 '赋值'
  • 百分百确定某个可空变量在当前场景下不是 null
String? maybeName = getName(); // 假设你知道它一定返回非空
int length = maybeName!.length; // 用了 !,如果为 null 则会抛出运行时异常

计算运算符

运算符作用
+加
-减
*乘
/取商/模运算
%取余
a++,++a加1再赋值给a
a--,--a减1再赋值给a
+=加上某个数再赋值回去
-=减去某个数再赋值回去
=赋值运算符,将右边的值赋值给左边的变量

比较运算符

更多操作运算符作用
<比较大小,判断右边是否大于左边,返回bool类型
>比较大小,判断右边是否小于左边,返回bool类型
==比较是否相等

if

switch/case

for

while

void main(List<String> args) {
  noParametersFunc();
  withParametersFunc("wyl");
  test();
}

//无参数函数
void noParametersFunc() {
  print("No parameters function");
}

//带参数函数
void withParametersFunc(String name) {
  print("Hello, $name!");
}

匿名函数

Function test = () {
  print("abcd");
};// <-- 必须要有这个;

其中的 ↓ 就是匿名函数

() {
  print("abcd");
};

这个匿名函数赋值给Function类型的test变量

当匿名函数里面只有一行的时候,可以在简写成:

() => print();

() => {print()}; 
// => 后面只能跟一个表达式(Expression),不能跟语句块(Statement)
// {} 被视为返回 void 的表达式

调用

test();

回调函数

无参

Function test = () => print("abcd2");
//回调函数 ,通过传入一个Function
void testFuncCallback(Function callback) {
  //业务逻辑执行结束
  callback(); //执行回调
}
//调用
testFuncCallback(test);

有参

Function test = (String name) => print("abcd2 $name");
//回调函数 ,通过传入一个Function
void testFuncCallback(Function func) {
  //业务处理
  String name = "wyl";
  func(name);
}
//调用
testFuncCallback(test);
testFuncCallback((String name) {
    print("Anonymous function: $name");
});

关于Function

Function是一个函数类型,也就是说所有的函数都是这个类型。有点类似c语言中的函数指针,指向一个函数,然后这个指针可以直接调用。

比如:

void hello()
{
	print('hello')
}
Function func = hello;
func();//直接调用
//有参也是同理
int a=10,b=20;
func(a,b);

但是:func可以传入任何函数(参数个数、类型任意),类型不安全。这种方式编译时不会报错,但是运行时会崩溃。不建议使用

  • 创建

  • 封装
  • 继承
  • 多态

结合dart语言,形成可以代替前端那一套的ui,但它也提供了非常灵活的方式去调用各平台的原生 API,以实现 USB 串口、蓝牙、WiFi 等硬件相关的功能。

对比

H5+原生APP

1

RN&Weex

1

Flutter

1

Front

Css html js

Vue

index.html

​ 浏览器加载index.html

<!-- 浏览器看到的是: -->
<body>
    <div id="app"></div>  <!-- 空的,没有内容 -->
    <script type="module" src="/src/main.ts"></script>
</body>
  1. 解析 HTML:遇到 <div id="app">,创建空的 DOM 元素
  2. 注册元素:在 DOM 树中记录这个 div,设置 id="app"
  3. 样式计算:计算 CSS(如果有样式的话),但现在是空的
  4. 布局和绘制:绘制一个空矩形(0高度或默认高度)
  5. 继续解析:继续解析后面的 <script> 标签

进入到Vue的世界

  1. 解析main.ts,这一步开发环境和打包后是不同的。打包后就没有ts文件了,全部变成js文件。
  2. 在main.ts中,vue的语法,创建一个组件。再将这个组件绑定到id为app的div。
  3. App.vue的内容加载到界面
    中。

vite创建项目,不使用npm

npm create vue@latest

依赖安装

npm i

main.ts

1.main.ts中引入createApp,vue中的创建vue组件的工具。
2.引入App.vue,这个组件
3.创建app组件并挂载到id=app的div

//引入createApp用于创建应用
import { createApp } from "vue";
//引入App.vue根组件
import App from "./App.vue";
createApp(App).mount("#app");

App.vue

1.分为模板,脚本,样式区

2.学vue主要是学脚本区,其他两个区是css3,h5的技术栈

<template>
	
</template>
<script lang="ts">
    export default {
        name: 'App'
    }
</script>
<style scoped>

</style>

OptionsAPI(配置)和CompositionAPI(组合)

配置API

​ 每个功能都拆分。各种功能的数据放在一起。各种功能的方法也放一起。以及监视,计算属性都放一起。

export default{
	data(){
		return{
			user:{ //用户 功能 数据
				
			},
			dept:{ //部门 功能 数据

			}
		}
	},
	methods:{
		getUser(){//用户 功能 方法

		},
		getDept(){//部门 功能 方法

		}
	},
	computed:{

	},
	watch:{

	}
}

组合API

​ 将功能组合到一起。一个功能里面包含数据、方法、计算属性、监视。通过函数返回。

export default {
        name: 'Person',
        setup() { 
            let name = '张三' 
            let age = 18//此时的name不是响应式
            let phone = '12345678901'
            let dept = {
                id: 1,
                name: 'IT部'
            }
            console.log(this) //setup中this是undefined,Vue3弱化了this
            function addAge(){
                age++ //修改界面不会变化,但是值会变化
            }
            function shouTest(){
                alert(phone)
                console.log(phone)
            }
            return {name, age, phone, dept,addAge, shouTest}
        }
    }

基本使用

​ setup中没有this的概念,不能用this

export default {
        name: 'Person',
        setup() { 
            let name = '张三' //此时的name不是响应式
            let age = 18
            let phone = '12345678901'
            let dept = {
                id: 1,
                name: 'IT部'
            }
            function addAge(){
                age++
            }
            function shouTest(){
                alert(phone)
                console.log(phone)
            }
            return {a:name,b:age,phone,dept,addAge,shouTest}// 如果setup没有返回,是拿不到数据的,这个返回方式可以a:name方式,也可以phone方式
        }
}

和vue2语法混用的一些注意点。

  1. vue3的setup可以使用vue2语法中的data()中的内容。

引入ref

需要响应式的数据用ref(data)包起来。

基本类型数据,以及对象类型(底层还是使用到了reative)

import {ref} from 'vue'
let test = "test"//非响应式
let name = ref("张三")
let age = ref(18)

引入reative

只能定义 对象类型 数组类型 数据

import { reactive } from 'vue'
let car=reactive({name:'benci',price=100})
function carPriceChange(){
    car.price++;
}
let game=['ys','ww','aa']
function changeGame(){
    game[1]='bb'
}

ref定义对象类型

let obj = ref({name:'wyl',age:25})
//使用方式有点特殊
//obj是一个ref对象,不是里面包含的对象
function changeName(){
    obj.value.name = "gw"
}

对象复制

Object.assign(obj1,obj2,obj3,...)
//将obj3里面的key,value复制到obj2,又将obj2中的key,value复制到obj1
let person = reactive({
        name: '张三',
        age: 18,
        sex: '男'
    })
let { name, age, sex } = toRefs(person)  //直接可以用name,age,sex
let name1 = toRef(person, 'name')   //直接可以用name1 并且name1和person中的name是完全一致并且具有响应式功能的

toRefs

用于结构对象,从对象中提取值

toRef

Vue3只能监视以下4种数据

  • ref定义的数据
  • reactive定义的数据
  • 函数返回的一个值(getter函数)
  • 一个包含上述内容的数组

场景

  1. 监视ref基本类型
import { ref,watch } from 'vue'
let sum = ref(0)
function changeSum() {
    sum.value = sum.value + 1
}
let stopWatch = watch(sum, (newValue, oldValue) => {
    console.log(oldValue, newValue)
    if (newValue > 10) {
        stopWatch() //停止监视
    }
})

​ 2.ref监视 对象类型数据

let person = ref({
    name: '张三',
    age: 18
})
function ageAdd() {
	person.value.age++
}
function changePerson() {
	person.value = {
		name: '李四',
		age: 20
	}
}
watch(person, (newValue, oldValue) => { //这里监视的是对象的地址值的变化,整个对象都发生了变化
    console.log(oldValue, newValue)
    console.log('person被修改了')
})

watch(person, (newValue, oldValue) => {
   console.log(oldValue, newValue)
   console.log('person被修改了')
},{deep: true})//深度监视,对象里面的m 的属性被修改了也监视
//还有一个选项immediate: true,立即执行一次,界面加载就执行

3.reative类型数据

let person = reactive({
    name: '张三',
    age: 18
})
function ageAdd() {
    person.age++
}
function changePerson() {
    person = Object.assign(person,{name:'王五',age:20})
}
watch(person,(newPerson,oldPerson)=>{ //reactive默认开启深度监视
   	console.log('new',newPerson)
    console.log('old',oldPerson)
})

watchEffect

​ 加载就直接运行,不需要指明监视的数据。

let height = ref(0)
let temp = ref(0)
function addHeight() {
    height.value+=10
}
function addTemp() {
    temp.value+=10
}
watchEffect(()=>{
    if(height.value>=80||temp.value>=60){
        console.log('发送请求')
    }
})

props作用

用于父组件给子组件传递参数。

1.只接收不做限制

​ 父可以传一堆给子,子可以不要

//父
<Person a=1 :list="personList" />
let personList:Array<PersonInter> = [
    {id:"001", name: '张三', age: 18},
    {id:"002",name: '李四', age: 20},
    {id:"003",name: '王五', age: 22}
]
//子
import {defineProps} from 'vue'
let x = defineProps(['a','list','b']) //这里子接收了父没有传的b,子拿到的是无。
let persons = x.list//这种方式就能拿到父传过来的数据进行一些业务操作 
1.做了类型限制。
//父
<Person :a="a" :list="personList" />
let a = 1;
let personList:Array<PersonInter> = [
    {id:"001", name: '张三', age: 18},
    {id:"002",name: '李四', age: 20},
    {id:"003",name: '王五', age: 22}
]
//子
import { withDefaults } from 'vue'
import {type PersonInter} from '@/types'
defineProps<{list:Array<PersonInter>,a:number,b?:string}>() //加了?父可以不传 

//如果父没传,子需要默认值
withDefaults(defineProps<{list:Array<PersonInter>,a:number,b?:string}>(),{
	b:'123'
})

生命周期函数

  • 创建
  • 挂载
  • 更新
  • 销毁

vue2:

  • 创建前:beforeCreate
  • 创建完成:created
  • 挂载前:beforeMounte
  • 挂载完成:mounted
  • 更新前:beforeUpdate
  • 更新完成:updated
  • 销毁前:beforeDestroy
  • 销毁完成:destroyed

vue3:

  • 创建阶段只有:setup()
import { ref,onBeforeMount,onMounted,
        onBeforeUpdate,onUpdated,
       onBeforeUnmount,onUnmounted,} from 'vue'
onBeforeMount(() => {
    console.log('onBeforeMount')
})
onMounted(() => {
    console.log('onMounted')
})
onBeforeUpdate(() => {
    console.log('onBeforeUpdate')
})
onUpdated(() => {
    console.log('onUpdated')
})
onBeforeUnmount(() => {
    console.log('onBeforeUnmount')
})
onUnmounted(() => {
    console.log('onUnmounted')
})

父子组件生命周期

子先挂载

子先销毁

hooks,模块编程

在目录下创建一个hooks文件夹,里面存放各种useXxx.ts

各种模块需要暴露模块自身需要暴露的数据和方法

import {ref} from 'vue'

export default function () {
    let count = ref(0);
    function add() {
        count.value++;
    }
    return {count,add}
}
//在需要引入的地方引入
<script setup lang="ts">
    import useSum from '@/hooks/useSum'
    const { count, add } = useSum()
</script>

概念

1.一组key-value的对应关系

2.路由个数多,需要路由器去管理路由。

流程

  • 导航区、展示区
  • 路由器
  • 配置规则
  • 组件

路由安装

npm i vue-router

路由使用

  1. 根文件夹下创建router文件夹。
  2. 创建index.ts文件。
  3. 在index.ts文件编写路由规则。
  4. 在main.ts中引入路由使用路由。
  5. 在App.vue中写路由标签。

index.ts

//创建一个路由器并暴露

// 引入路由组件
import { createRouter,createWebHistory } from 'vue-router'

// 引入组件
import Home from '@/components/Home.vue'
import About from '@/components/About.vue'
import PersonManage from '@/components/PersonManage.vue'

// 创建路由
const router = createRouter({
    history:createWebHistory(), // 路由模式
    routes:[ // 路由规则
        {
            path:'/',
            component:Home
        },
        {
            path:'/pm',
            component:PersonManage
        },
        {
            path:'/about',
            component:About
        }
    ]
})

// 暴露路由
export default router

main.ts

//引入createApp用于创建应用
import { createApp } from "vue";
//引入App.vue根组件
import App from "./App.vue";
//引入路由器
import router from "./router";

//创建应用
const app = createApp(App);
//app.use(router);
app.use(router);
//挂载应用
app.mount("#app");

App.vue

<template>
    <div class="div1">
        <h1>Vue路由测试</h1>
        <!--导航区-->
        <div class="nav-class">
            <RouterLink to="/" >首页</RouterLink>|
            <RouterLink to="/pm" >人员管理</RouterLink>|
            <RouterLink to="/about" >关于我们</RouterLink>|//或者 :to={path:"/about"}
        </div>
        
        <!-- 内容区-->
        <div class="content-class">
            <RouterView/>
        </div>
    </div>
</template>

<script lang="ts">
    export default {
        name: 'App', // 组件名
        components: {  }
    }
</script>
<script setup lang="ts"> 
    import { RouterView } from 'vue-router';
</script>

<style scoped>
    .nav-class{
        background-color: rgb(241, 225, 225);
        padding: 10px;
        text-align: center;
    }
    .content-class{
        background-color: rgb(241, 223, 223);
        padding: 10px;
        margin-top: 10px;
        height: 500px;
        text-align: center;
    }
    .div1{
        width: 500px;
        margin-left: 30%;
        height: 100%;
        background-color: aliceblue;
    }
    a{
        text-align: center;
    }
    h1 {
        color: rgb(201, 79, 79);
        background-color: antiquewhite;
        text-align: center;
    }
</style>

路由的好处:

​ 在当前界面只会有当前路由组件,其他组件会卸载。

history

  1. vue2: mode:'history'
  2. vue3: history:createWebHistory()
  3. React: BrowserRouter

url更美观没有#。缺点:后期项目上线,需要配合服务端处理路径问题

hash

  1. vue2: mode:'hash'
  2. vue3: history:createWebHashHistory()
  3. React: BrowserHashRouter

兼容性好,有#号

路由名称

routers:{
	{
        path:'/',
        name:'home',
        component:Home
    }
}
{
    path:'/pm',
    component:PersonManage,
    children:[
          {
               path:'/pmcontent',
               component:PmContent
     	  }
     ]
}

路由传参

query

<RouterLink :to="`/pm/content?name=${item.name}&id=${item.id}`">{{item.name}}</RouterLink>
//或者
<RouterLink :to="{name:'PmContent',query:{name:item.name,id:item.id}}">{{item.name}}</RouterLink>
<script setup lang="ts">
    import { useRoute } from 'vue-router';
    let route = useRoute();
    let name = route.query.name
</script>

params

<RouterLink :to="`/pm/content/1/test`">{{item.name}}</RouterLink>

需要在路由器中的路由规则中配置

{
    path:'/pm',
    component:PersonManage,
    children:[
        {
            path:'content/:id/:name',
            component:PmContent
        }
    ]
}
<script setup lang="ts">
    import { useRoute } from 'vue-router';
    const route = useRoute();
</script>
children:[
    {
    	path:'content/:name/:id',
        component:PmContent,
        props:true
    }
]
<script setup lang="ts">
    import { useRoute } from 'vue-router';
    const route = useRoute();
    defineProps(['name','id'])
</script>

push

一个栈

replace

不做记录。不会返回上一页

默认push,修改成replace

<RouterLink replace to="/" >首页</RouterLink>|

方式

​ 场景,在代码中主动跳转到另一个路由。

<script setup lang="ts"> 
    import { useRouter } from 'vue-router';
    const router = useRouter();//拿到路由器 注意不是路由useRoute
    onMounted(()=>{
        setTimeout(()=>{
            console.log('@')
            router.push('/pm') //主动跳转
        },2000)
    })
</script>

重定向

在路由器中配置规则

{
    path:'/',
    redirect:'/home'主界面重定向到/home路由对应的规则
}

Pinia

集中式状态(数据)管理 | redux vuex pinia

  • 新建一个store文件夹
  • 创建vue组件对应的.ts
  • 引入和创建

main.ts

//引入createApp用于创建应用
import { createApp } from "vue";
//引入App.vue根组件
import App from "./App.vue";
//引入pinia
import { createPinia } from "pinia";
//创建应用
const app = createApp(App);
//app.use(router);
app.use(createPinia());
//挂载应用
app.mount("#app");

count.ts

import { defineStore } from "pinia";

export const useCountStore = defineStore("count", {
  state: () => {//状态 存储数据的容器
    return {
      count: 0,
    };
  },
  getters: { //计算属性
    int: (state) => state.count * 2,
  },
  actions: { //方法
    increment() {
      this.count++;
    },
  },
});

使用

<script setup lang="ts"> 
	import {useCountStore} from '@/store/count'
    const countStore = useCountStore()
</script>

组件之间相互传递数据。

props

  • 父传子
//父组件
let car = ref('奔驰')
<Child :car="car"/>

//子组件
defineProps(['car']);
  • 子传父
//父亲声明一个带参的方法,子组件调用
function recieveFun(value:string){
	console.log('父接收到子数据:',value)
}
<Child :recieveFunc="recieveFun"/>

//子调用传递
let toy = ref('奥特曼')
let props = defineProps(['recieveFun']);
//调用
<button @clike="recieveFun(toy)">调用传递</button>

自定义事件

​ 子传父

  • 概念
<button @clike="test(1,$event)">调用传递</button>   //$event 事件对象

function test(x:number,y:Event){
    console.log(x,y)
}
  • 示例
//父组件定义函数,并且给子组件绑定事件。
<Child @haha="func"/>
function func(value:number){
    
}

//子组件需要声明事件。触发事件,触发事件之后就可以给父组件传递参数
const emit = defineEmits(['haha'])
<button @click="emit('haha',666)" >点击</button>

mitt

订阅发布。任意组件通信

//引入mitter
import mitt from 'mitt'
const emitter = mitt();
export default emitter
//定义事件
emitter.on("emit1",(value)=>{
	console.log(value)
})

//触发事件
emitter.emit("emit1(123)")

//解绑事件
emitter.off("emit1")

//全部解绑
emitter.all.clear()

v-model

自定义组件进行的 v-model

本质是v-bind+@input

<myInput v-model="username"/>

但是自定义的组件myInput处理起来就很麻烦。这里不做示例了。

$attrs

​ 用于实现当前组件的父组件,向当前组件的子组件通信(祖->孙)

父亲通过props传递的参数如果子组件没有使用defineProps声明,传递过来的参数会放在$attrs中。

  • 向下传
//父
let a = ref(0)
let b = ref(2)
let c = ref(3)
<Child :a="a" :b="b" :c="c"/>
    
//子
//defineProps(['a','b'])//直接不接收
//直接把父数据给到孙辈
<GrandChild v-bind="$attrs"/>
    
//孙
defineProps(['a','b','c'])

//甚至重孙
  • 向上传
//父定义方法。

//后辈调用方法

$ref和$parent

$ref 父传子

$ref 子传父

provide_inject

祖孙之间直接通信,不需要中间。

  • 向后代提供数据
//祖
import {provide} from 'vue'
let a = ref(10)
let car = reactive({brand:'本次',price:120})
provide("a",a)
provide("car",car)

//孙
let x = inject('a',0) //这里的0为默认值,如果没有接收到a那么x=0
let car = inject('car',{brand:'未知',price:0}) //设置默认值,防止推断报错

  • 后代向上层传递数据
//祖
import {provide} from 'vue'
let a = ref(10)
let car = reactive({brand:'本次',price:120})
function a_add(value){
    a -= value
}
provide("a_context",{a,a_add}) //a的上下文,这个上下文饱含a和一个a_add函数
provide("car",car)


//孙
lex {a,a_add} = inject("a_context",{a:0,a_add:(value:number)=>{} })
a_add(6)

默认插槽

父

<Child >
	<ui>
    	<li></li>
    </ui>
</Child>

子

<template>
	<div>
		<slot>默认内容</slot>  //如果这里父没有插槽内容,子会默认
	</div>
</template>

有名插槽

父

<Child  >
    <template v-slot:s1> //插槽1 或者 #s1
    	<h2></h2>
    </template>
    <template v-slot:s2> //插槽2 或者 #s2
		<ui>
    		<li>111</li>
    	</ui>
	</template>
</Child>

子

<template>
	<div>
        <slot name="s1">默认内容</slot>
		<slot name="s2">默认内容</slot>  //如果这里父没有插槽内容,子会默认
	</div>
</template>

作用域插槽

子组件需要给父组件传递参数

子

父

<Child  >
    <template v-slot="params"> 
    	<h2></h2>
    </template>
</Child>

默认暴露

  • 定义:默认暴露是指一个模块只能有一个默认导出,使用 export default 关键字来导出一个变量、函数或类。导入时可以使用任意名称来接收这个默认导出。

  • 适用场景:当一个模块只需要暴露一个接口时,使用默认暴露可以简化导入过程,增强代码的可读性和可维护性。

分别暴露

  • 定义:分别暴露是指一个模块可以导出多个变量、函数或类,使用 export 关键字。导入时需要使用 {} 来指定要导入的具体内容。

  • 适用场景:当一个模块需要暴露多个接口时,分别暴露可以清晰地列出所有可用的导出,便于管理和使用。

Vue project

项目目录

新建的项目目录缺少很多必要的目录和文件

要在src文件夹补充以下文件夹和文件

  • router文件夹,配置路由信息。
  • store文件夹,pinia和vuex相关内容
  • utils文件夹,工具类比如axios的二次封装request
  • components文件夹,一些公共的组件,比如文件上传,icon选择等。
  • assets文件夹,存放矢量文件svg和其他静态文件。
  • api文件夹,存放具体请求的方法,对外暴露。
  • views文件夹,最重要的文件夹,所有的路由组件都放在这个地方。
  • directive文件夹,存放自定义指令用来判断权限对菜单的展示是否隐藏
  • permission.ts文件,在src目录下与main.ts同级。用于判断是否登录,做路由守卫。

要做根文件夹添加开发和测试生产环境相关信息

  "scripts": {
    "dev": "vite",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build"
  }

.env.development文件

# 页面标题
VUE_APP_TITLE = demo1

# 开发环境配置
ENV = 'development'

# 
VUE_APP_BASE_API = '/dev-api'

# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

.env.production文件

# 页面标题
VUE_APP_TITLE = demo1

# 开发环境配置
ENV = 'production'

# 
VUE_APP_BASE_API = '/api'

步骤1

npm create vue@latest

步骤2

配置项目相关内容

创建一个空白的项目

vue-project-1

vue-project-2

cd demo01
npm install
npm run dev

vscode安装Vue 3 Snippets

安装

npm install element-plus --save
yarm add element-plus
pnpm install element-plus

项目引入

在main.ts引入

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)

使用

<template>
  <div>
    <h1>Home</h1>
    <div class="button-row">
      <el-button>Default</el-button>
      <el-button type="primary">Primary</el-button>
      <el-button type="success">Success</el-button>
      <el-button type="info">Info</el-button>
      <el-button type="warning">Warning</el-button>
      <el-button type="danger">Danger</el-button>
    </div>
  </div>
</template>

安装

npm i -D vite-plugin-windicss windicss

配置404界面

{ 
    path: '/:pathMatch(.*)*',
    name: '404', 
    component: () => import('@/views/404/index.vue') 
}
npm install @element-plus/icons-vue

登录界面代码

<template>
    <div class="container">
        <el-col :sm="12" :lg="6" :xl="4">
            <el-form :model="form" :rules="rules" ref="formRef" label-width="120px"><!--ref="formRef"中formRef表示el-form为整个对象-->
                <el-form-item label="Username" prop="username">
                    <el-input v-model="form.username" placeholder="Enter username"></el-input>
                </el-form-item>
                <el-form-item label="Password" prop="password">
                    <el-input v-model="form.password" placeholder="Enter password" type="password"></el-input>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" @click="submitForm">Login</el-button>
                </el-form-item>
            </el-form>
        </el-col>
    </div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { reactive } from 'vue'
import { ref } from 'vue'
const formRef = ref()

const router = useRouter()
const form = reactive({
    username: '',
    password: ''
})
const rules = reactive({
    username: [
        { required: true, message: 'Please enter username', trigger: 'blur' }
    ],
    password: [
        { required: true, message: 'Please enter password', trigger: 'blur' }
    ]
})
const submitForm = async () => {
    await (formRef.value as any).validate()
    if (form.username === 'admin' && form.password === '123456') {
        router.push({ name: 'Home' })
    } else {
        ElMessage.error('Login failed')
    }
}
</script>
<style scoped>
.container {
    display: flex;
    justify-content: center;
    align-items: center;
}
</style>

  • 数据双向绑定v-model="form.username"和form数据对象中的username数据双向绑定
  • :model="form" 表示这个el-form和对象form绑定
  • 表单验证,:rules="rules"和ref="formRef",rules定义验证规则,formRef 的最终目的拿到了 el-form 的实例,调用它提供的 API 方法实现:validate() : 触发表单验证(检查必填项、格式等)。resetFields() : 重置表单项,将其值重置为初始值。clearValidate() : 移除表单项的校验结果。
const form = reactive({
    username: '',
    password: ''
})

<el-form :model="form" >
    <el-form-item label="Username" prop="username">
        <el-input v-model="form.username" placeholder="请输入账号"></el-input>
    </el-form-item>
    <el-form-item label="Password" prop="password">
         <el-input v-model="form.password" placeholder="请输入密码" type="password"></el-input>
    </el-form-item>
    <el-form-item>
         <el-button type="primary" @click="submitForm">登录</el-button>
    </el-form-item>
</el-form>

安装

npm install axios

封装

新建utils文件夹,新建axios.ts文件暴露request

import axios from 'axios'

const request = axios.create({
    baseURL: "http://localhost/proj1",
    timeout: 5000
})
export default request

配置跨域

在项目根目录下的vite.config.ts文件中新增配置

  server: {
    proxy: {
      '/proj1': {
        target: 'http://localhost',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/proj1/, ''),
      },
    },
  },

同步等待

async function getUser(){
	try{
		await axios.get("/info?id="+id)
	}catch(err){
	
	}
}

使用

  • get
request.get("/test")
.then((result)->{ //这里已经有base-url了会拼接在一起
	console.log(result.data)//真正返回的结果
})
.catch((err)->{ //异常处理
    console.log(err)
})
  • get + param
//方式一
request.get("/test?id=5")
.then((result)->{ //这里已经有base-url了会拼接在一起
	console.log(result.data)//真正返回的结果
})
.catch((err)->{ //异常处理
    console.log(err)
})
//方式二
request.get("/test",{
	params:{id=5}
})
.then((result)->{ //这里已经有base-url了会拼接在一起
	console.log(result.data)//真正返回的结果
})
.catch((err)->{ //异常处理
    console.log(err)
})
  • post->form

  • post->json

请求拦截

// 官方用例
// 1.添加请求拦截器
axios.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    return config;
  }, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  });

响应拦截

// 2.添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 对响应数据做点什么
    return response;
  }, function (error) {
    // 对响应错误做点什么
    return Promise.reject(error);
  });

前置守卫

  1. 在src文件夹新建permission.ts文件
  2. 在main.ts中引入这个文件
import { ElMessage } from "element-plus";
import router from "@/router/routes";

router.beforeEach((to, from, next) => {
  if (to.name !== 'Login' && !localStorage.getItem('token')) {
    // next({ name: 'Login' })
    next({path: '/login'})
    ElMessage({
      message: '请先登录',
      type: 'warning',
    })
  }
  //防止重复登录
  if(to.name === 'Login' && localStorage.getItem('token')){
    ElMessage({
      message: '您已登录,无需重复登录',
      type: 'warning',
    })
    next({path: '/'})
  }
  next()
})
import './permission'
//回车事件
function onKeyDown(e){
    if(e.key === 'Enter'){
        submitForm()
    }
}
// 组件挂载时添加事件监听
onMounted(() => {
    document.addEventListener('keydown', onKeyDown)
})
// 组件卸载时移除事件监听
onBeforeUnmount(() => {
    document.removeEventListener('keydown', onKeyDown)
})

自定义指令

  1. 根目录下创建directives。

  2. directives目录创建permission.ts文件。

    export default{
    	install(app){//传入app
    		app.directive('permission', {
                mounted(el,binding){
                    console.log(el,binding)
                    //拿到值
                    binding.value
                    for(let item of binding.value){
                		console.log(item)
                		if(item !== 'admin'){//没有权限移除掉
                    		el.parentNode.removeChild(el)
                		}
            		}
                }
    		})
    	}
    }
    
  3. 在需要的DOM元素中添加指令

    <template>
      <p v-permission="['admin']">This sentence is important!</p>
    </template>
    

表格

<el-table :data="tableData" border >
  <el-table-column prop="name" label="姓名" width="100px"/>
  <el-table-column prop="email" label="邮箱"  />
  <el-table-column prop="phone" label="手机号"  />
  <el-table-column prop="role" label="角色" />
</el-table>

分页

<el-pagination
  v-model:current-page="currentPage"
  v-model:page-size="pageSize"
  :page-sizes="[10, 20, 30, 40]"
  :size="size"
  :disabled="disabled"
  :background="background"
  layout=" prev, pager, next,jumper, ->,total, sizes"
  :total="total"
  @size-change="handleSizeChange"
  @current-change="handleCurrentChange"
/>

<script setup lang="ts">
import { ref,reactive } from 'vue'

const currentPage = ref(1) //当前页码
const pageSize = ref(10) //每页显示条数
const size = ref('') //分页器尺寸大小
const disabled = ref(false) //是否禁用分页器
const background = ref(true) //是否显示背景颜色
const total = ref(400) //总条数

const tableData = reactive([
  {
    name: '张三',
    email: 'zhangsan@example.com',
    phone: '13800000000',
    role: '管理员'
  },
  {
    name: '李四',
    email: 'lisi@example.com',
    phone: '13800000001',
    role: '普通用户'
  }
])
</script>

Dialog

1

第一层App.vue中的

第二层layout.vue

看routes中layout下面的子路由,children这才是正儿八经的第二层路由。路由嵌套的正确执行方式。

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'layout',
      component: () => import('@/layout/index.vue'),
      redirect: '/home', // 访问根目录时默认重定向到 home
      children: [
        {
          path: 'home',
          name: 'home',
          component: () => import('@/views/home/index.vue'),
        }
      ]
    },
    { 
      path: '/:pathMatch(.*)*',
      name: '404', 
      component: () => import('@/views/404/index.vue') 
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/login/index.vue'),
    },
    {
      path: '/register',
      name: 'register',
      component: () => import('@/views/register/index.vue'),
    },
  ],
})

代码

app.vue

<script setup lang="ts">

</script>

<template>
  <router-view></router-view>
</template>

<style scoped></style>

layout/index.vue

<template>
  <div class="app-wrapper">
    <el-container class="layout-container">
      <!-- 左侧边栏 -->
      <el-aside width="220px" class="sidebar-container">
        <div class="logo">
          <h2>SaaS RBAC Admin</h2>
        </div>
        <el-menu
          default-active="/home"
          class="el-menu-vertical"
          background-color="#304156"
          text-color="#bfcbd9"
          active-text-color="#409EFF"
          router
        >
          <el-menu-item index="/home">
            <el-icon><House /></el-icon>
            <span>首页</span>
          </el-menu-item>
          <el-menu-item index="/user">
            <el-icon><User /></el-icon>
            <span>用户管理</span>
          </el-menu-item>
          <el-menu-item index="/role">
            <el-icon><Setting /></el-icon>
            <span>角色管理</span>
          </el-menu-item>
        </el-menu>
      </el-aside>

      <!-- 右侧主体内容 -->
      <el-container class="main-container">
        <!-- 顶栏 -->
        <el-header class="header">
          <div class="header-left">
            <!-- 占位,可放折叠侧边栏按钮或面包屑 -->
            <span>后台管理系统</span>
          </div>
          <div class="header-right">
            <!-- 占位,可放用户头像、下拉菜单等 -->
            <el-dropdown>
              <span class="el-dropdown-link">
                管理员 <el-icon class="el-icon--right"><arrow-down /></el-icon>
              </span>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item>个人中心</el-dropdown-item>
                  <el-dropdown-item divided>退出登录</el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </div>
        </el-header>

        <!-- 内容展示区 -->
        <el-main class="app-main">
          <!-- 针对layout的子路由嵌套 -->
          <router-view />
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
app.use(ElementPlus)
// 注册 Element Plus 图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

在src同级目录下新增.env.development和.env.production以及.env.test

在package.json中添加

"scripts": {
    "dev": "vite --mode development",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build",
    "build:prod": "vite build --mode production",
    "build:test": "vite build --mode test"
}

这里的文件名可以改短,.env.dev和.env.prod,只需要在package.json中修改配置即可

"scripts": {
    "dev": "vite --mode dev",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build",
    "build:prod": "vite build --mode prod",
    "build:test": "vite build --mode test"
}
# 开发环境配置 必须以VITE_开头
NODE_ENV = development
VITE_APP_TITLE = 管理系统
VITE_API_URL = /
VITE_SERVER_URL = http://localhost:8080/proj1

使用环境变量中内容

const appTitle = import.meta.env.VITE_APP_TITLE
console.log(appTitle)

用来模拟后端借口,有后端了,不需要使用mock

在src文件夹同级创建mock

  • 在src目录下创建stores文件夹。

  • 创建index.ts文件用于创建pinio

index.ts

import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

main.ts引用

// 注册 Pinia
import pinia from './stores/index.ts'
app.use(pinia)
  • 创建各种store

useUserStore 一般以use开头Store结尾

import { defineStore } from 'pinia' //引入

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {}
  }),
  actions: {
    setToken(token: string) {
      this.token = token
    },
    setUserInfo(userInfo: any) {
      this.userInfo = userInfo
    }
  }
})

前端路由权限控制方案一般有两种

  • 动态路由
  • 全量路由 +后端权限

动态路由

如果对代码体积或功能保密性要求高,也可以采用“动态添加路由”方案:

  • 前端只保留登录页、404 等静态路由。
  • 登录后,根据权限,向后端请求当前用户的路由配置(路由路径、组件路径、权限等)。
  • 前端拿到后,动态解析并调用 router.addRoute() 添加。
  • 菜单也根据这个动态数据直接生成。

全量路由

  • 加载路由:前端初始化时,就加载定义好的所有路由(静态路由+所有动态业务路由)。
  • 获取权限:用户登录后,立即请求后端接口,获取其权限列表,比如 ['user:add', 'order:view', 'report:export']。
  • 动态生成菜单:前端根据权限列表,递归过滤路由配置,只留下有权限的路由来渲染侧边栏菜单。
  • 注册全部路由:关键在于,所有路由其实都已经注册到路由实例中了。这才能让你在路由守卫里判断权限时,直接匹配到目标路由并检查其所需权限。
  • 路由守卫拦截:在每次跳转前,检查目标路由是否需要特定权限。如果需要,就验证用户是否有该权限;如果没有,就跳转到 403 页面。

方式一:直接在路由前置拦截中判断菜单是否有权限

在permission.ts中添加内容,

import { ElMessage } from "element-plus";
import router from "@/router/routes";
router.beforeEach((to, from, next) => {
  // 从 localStorage 中获取菜单
  const menu = localStorage.getItem('menu')||'[]'
  if (!menu&&to.name!=='login') {
    ElMessage({
      message: '请先登录',
      type: 'warning',
    })
    return next({ path: '/login' })
  }

  const menuList = JSON.parse(menu)
  // 遍历菜单,判断是否有权限访问当前路由
  let hasPermission = false
  menuList.forEach((item:string) => {
    if (item===to.path) {
      hasPermission = true
    }
  })
  console.log(hasPermission)
  if (!hasPermission&&to.name!=='login') {
    ElMessage({
      message: '您没有权限访问该页面',
      type: 'warning',
    })
    return next(false)
  }
  // 正常放行
  next()
})
router.afterEach((to, from) => {
  console.log(to, from)
})

方式二:自定义指令

Git

编辑etc\gitconfig文件,也有些windows系统是存放在C:\Users\Administrator\.gitconfig路径或安装盘符:\Git\mingw64\etc\gitconfig,在文件末尾增加以下内容:

[gui]  
    encoding = utf-8  
    # 代码库统一使用utf-8  
[i18n]  
    commitencoding = utf-8  
    # log编码  
[svn]  
    pathnameencoding = utf-8  
    # 支持中文路径  
[core]
    quotepath = false 
    # status引用路径不再是八进制(反过来说就是允许显示中文了)

清空暂存区

  • 将已经git add的文件变成没有add
git reset HEAD

清空文件

  • 将没有git add的清空
git clean -fd  # 删除所有未跟踪的文件和目录

二者结合就能删除某次提交

恢复未提交的删除

git checkout -- filename.txt

SSH秘钥

  1. 在 Ubuntu 上生成 SSH 密钥对:

-t 指定加密算法,-C 添加注释(通常填邮箱)

ssh-keygen -t ed25519 -C "你的邮箱@example.com" -f ~/.ssh/id_ed25519




执行命令后,一路按回车即可(**注意**:建议不设置 passphrase,否则每次使用 SSH 仍需输入密码)。这会在 `~/.ssh/` 目录下生成 `id_ed25519`(私钥,绝不能泄露)和 `id_ed25519.pub`(公钥)两个文件。

2. **将公钥添加到你的 Git 服务器**:以 GitHub 为例,登录后进入 **Settings** -> **SSH and GPG keys**,点击 **New SSH key**,将 `~/.ssh/id_ed25519.pub` 文件中的内容完整粘贴进去并保存。

3. **修改远程仓库 URL**:这是最关键的一步。你需要将仓库的远程 URL 从 HTTPS 格式切换为 SSH 格式。

bash

```bash
# 查看当前 URL
git remote -v
# 如果显示是 https://... 开头,则需修改
git remote set-url origin git@gitee.com:wei-yuliu/c-language-learning.git

之后执行 git pull 等操作就会自动使用 SSH 密钥进行认证,无需再输入密码。

直接保存账号密码token

# 默认缓存 15 分钟
git config --global credential.helper cache
# 自定义缓存时间,例如 1 小时 (3600 秒)
git config --global credential.helper 'cache --timeout=3600'
#永久存储:凭证会永久保存到磁盘上的一个明文文件 (~/.git-credentials) 中
git config --global credential.helper store

其他

处理历史提交中的大文件导致推送失败

适用场景:本地历史中曾提交过大文件(超过平台限制),后续虽然有新提交,但历史记录仍携带这些文件,导致推送被拒绝。需要从所有历史中彻底移除这些大文件,同时保留后续的正常提交。

所需工具:git-filter-repo(安装:sudo apt install git-filter-repo 或 pip install git-filter-repo)


第一步:重写历史,删除指定的大文件/目录

git filter-repo --path path/to/large-file --path path/to/large-dir/ --invert-paths --force
  • --path:指定要保留的路径(可重复使用)。
  • --invert-paths:反转含义,即排除这些路径,从所有历史提交中删除它们。
  • --force:当本地仓库不是全新克隆时,强制重写历史。

示例:若需要删除根目录下的 large.zip 和 bin/ 整个目录,则写为 --path large.zip --path bin/ --invert-paths


第二步:清理无用对象,使删除真正生效

git reflog expire --expire=now --all
git gc --prune=now --aggressive

作用:重写历史后,旧的大文件对象仍残留在 .git/objects 中,并被 reflog 引用。这两条命令强制清空所有 reflog 记录,并立即修剪不可达的对象,物理删除这些大文件,从而真正缩小仓库体积。若不执行此步,推送时仍会尝试上传已删除的大文件,导致失败。


第三步:检查并重新添加远程仓库(若丢失)

git remote -v                     # 查看现有远程配置
git remote add origin <仓库地址>   # 若缺失则重新添加
git remote -v                     # 确认

注:filter-repo 可能会清除远程配置,必要时重新设置。


第四步:强制推送重写后的分支

git push origin <分支名> --force

使用 --force 是因为本地历史已被重写,需要覆盖远程分支。


注意事项:

  • 操作前建议备份 .git 目录(cp -r .git /tmp/backup.git)。
  • 强制推送会改变远程历史,若多人协作,需提前通知团队成员。
  • 如果还有其他大文件,可在第一步中一并列出。

分支影响:默认重写全仓库历史(所有分支和标签)。若只想测试特定分支,可加 --refs <分支名>,但这样无法彻底回收大文件空间,后续合并时易复发。建议在操作前备份全仓库,并统一处理所有分支。

Godot

节点(Node)

  • Godot 中最基本的构建块,一切皆节点。
  • 每个节点具有属性、方法,并能接收回调(如 _ready()、_process())。
  • 节点可以添加子节点,形成树状结构(场景树)。

2. 场景(Scene)

  • 由一组节点构成的、可保存为 .tscn 文件的组合。
  • 场景可被实例化(复制)到其他场景中,实现复用。
  • 一个游戏通常由多个场景组成(如玩家、敌人、UI、关卡)。

3. 场景树(Scene Tree)

  • 游戏运行时所有节点的层次结构。
  • 根节点通常是 Window(或 Main 场景),所有活动节点都挂在它下面。
  • 场景树管理节点的生命周期、处理顺序和分组。

4. 信号(Signals)

  • Godot 内置的事件系统,用于节点间解耦通信。
  • 节点可以发出信号(如“按钮被按下”),其他节点可以连接该信号并执行回调。
  • 通过 signal_name.connect(callable) 连接,避免直接引用节点。

5. 资源(Resources)

  • 数据容器,如纹理、音频、脚本、动画、字体、自定义数据等。
  • 资源可被多个节点共享,节省内存,并支持导入/导出。
  • 常见资源类型:Texture、AudioStream、Script、Animation、PackedScene。

6. 脚本(Scripts)

  • 附加到节点上的代码,定义节点行为。
  • Godot 支持 GDScript(官方语言)、C#、C++(通过 GDExtension)、VisualScript 等。
  • 脚本继承自节点类型,可覆盖虚函数(如 _process())并添加自定义逻辑。

7. 物理系统

  • 提供 2D/3D 物理模拟,包括刚体(RigidBody)、静态体(StaticBody)、角色体(CharacterBody)、碰撞形状等。
  • 通过 PhysicsServer 或节点(如 Area2D、RigidBody2D)使用。

8. 动画系统

  • 使用 AnimationPlayer 节点和 Animation 资源制作关键帧动画。
  • 可动画化节点属性(位置、旋转、颜色等),并支持混合、过渡、调用方法等。

9. 输入处理

  • 通过 Input 单例或节点中的 _input(event) 回调处理用户输入。
  • 支持“输入映射”(Input Map),可定义动作(如“跳跃”)并绑定按键/手柄。

10. 用户界面(UI)

  • 基于 Control 节点及其子类(如 Button、Label、Panel、Container)构建。
  • 使用锚点(anchors)和容器(containers)实现响应式布局。
  • UI 场景通常单独设计,并与游戏逻辑分离。

11. 自动加载(Autoload / Singleton)

  • 在项目设置中注册的场景或脚本,自动成为全局单例。
  • 适合存放全局数据、游戏管理器、音频管理器等。

12. 组(Groups)

  • 给节点添加标签(如“enemy”、“collectible”),便于批量获取或调用方法。
  • 使用 add_to_group() 和 get_tree().get_nodes_in_group() 操作。

13. 着色器(Shaders)

  • 用 Godot 着色器语言(类似 GLSL)编写自定义渲染效果。
  • 可应用于材质(Material)或后处理,实现复杂视觉效果。

14. 场景实例化与继承

  • 实例化:将一个场景作为子节点添加到另一个场景,形成组合。
  • 继承:一个场景可以继承另一个场景,并覆盖或扩展其节点属性,类似面向对象的继承。
extends Sprite2D

func _init() -> void:
	print("hello world!")
	pass

extends Sprite2D:声明脚本继承自

func :函数定义符

_init():函数名

-> void: 一个无返回值类型的

pass:跳过

  • _init():会在节点创建的时候执行一次
func _init() -> void:
	pass
  • _process每一帧执行一次

    delta 参数代表从上一次调用 _process() 函数到这一次调用所经过的时间,单位是秒。它是一个浮点数。

    60 FPS = 0.0167s

func _process(delta: float) -> void:
	pass
  • **_physics_process(delta)**固定每秒调用次数(默认60次fps)
func _physics_process(delta: float) -> void:
	pass

节点在场景里面。具有一定的属性。

最常用的就是position.x/y

@export var speed: float = 200.0

@export 在属性列表里面可以直接修改。比较智能

_input(event) 是 Godot 主动通知你“刚刚发生了什么”。 Input 是你主动询问 Godot“现在按键处于什么状态”。


Java

CI CD jenkins

前端配置

pipeline {
    agent any
    environment {
        GIT_REPO = "http://YH:YH123456@gitlab.yuhecloud.com/tool/jiangqie_ow_free.git"
        GIT_CREDENTIALS = credentials('YHGIT')
        SSH_PASSWORD = 'yh608217yh'
        REMOTE_HOST = '192.168.120.253'
        REMOTE_DIR = '/usr/local/newtest/'
    }

    stages {
        stage('Clean Workspace') {
            steps {
                cleanWs()
            }
        }

        stage('Checkout Code') {
            steps {
                checkout([
                    $class: 'GitSCM',
                    branches: [[name: 'dev']],
                    userRemoteConfigs: [[
                        url: GIT_REPO,
                        credentialsId: 'YHGIT'
                    ]]
                ])
            }
        }

        stage('Build Java Project') {
            steps {
                dir('server'){
                    sh 'mvn clean install package'
                }
            }
        }

        stage('Archive Artifacts') {
            steps {
                // 归档所有需要的构建产物
                archiveArtifacts artifacts: 'server/target/*.jar', fingerprint: true
            }
        }

        stage('Deploy Applications') {
            steps {
                // 部署第一个应用
                script {
                    def infraJar = 'server/target/*.jar'
                    sh """
                        sshpass -p '${SSH_PASSWORD}' scp -o StrictHostKeyChecking=no ${infraJar} root@${REMOTE_HOST}:${REMOTE_DIR}
                        sshpass -p '${SSH_PASSWORD}' ssh -o StrictHostKeyChecking=no root@${REMOTE_HOST} 'cd /usr/local/ && ./jiangqie_deploy.sh'
                    """
                }
                // 可以继续添加更多应用的部署
            }
        }
    }

    post {
        success {
            echo 'Build and deployment succeeded!'
        }
        failure {
            echo 'Build or deployment failed!'
        }
    }
}

安装

wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -
sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
sudo apt-get update
sudo apt-get install jenkins

jenkis默认安装路径:/usr/lib/jenkins/:jenkins安装目录,war包会放在这里。

/etc/sysconfig/jenkins:jenkins配置文件,“端口”,“JENKINS_HOME”等都可以在这里配

/var/lib/jenkins/:默认的JENKINS_HOME。

/var/log/jenkins/jenkins.log:jenkins日志文件。

jenkins默认拉取git代码存放路径:/var/lib/jenkins/workspace

安装2

直接java -jar jenkins.war
然后所有文件都在%user%/.jenkins目录。linux和windows都是

nginx外部映射:

server{
	listen 80;
	server_name jenkins.openso.top;
	# 精确匹配 /docker 路径
	location / {
		# 反向代理到内网 Jenkins
		proxy_pass http://10.147.17.85:8080;  
		# WebSocket 支持 - 用于实时日志输出
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        
        # 超时设置
        proxy_connect_timeout 90;
        proxy_send_timeout 90;
        proxy_read_timeout 90;
	}
}

+增加构建步骤

在配置项中增加构建步骤Build Steps

echo "切换到黑马商城项目"
cd ./hm-shopping/hmall
echo "开始进行编译构建"
mvn clean package
echo "编译构建完成"

echo "复制gateway模块到远程docker服务器(10.147.17.85)"
sshpass -p 20000316 \
scp -v -o StrictHostKeyChecking=no \
/var/lib/jenkins/workspace/hmall/hm-shopping/hmall/gateway/target/gateway-1.0.0.jar wyl@10.147.17.85:/home/wyl/docker/java_app/hmall/gateway.jar
echo "复制gateway模块成功"

echo "复制user模块到远程docker服务器(10.147.17.85)"
sshpass -p 20000316 \
scp -v -o StrictHostKeyChecking=no \
/var/lib/jenkins/workspace/hmall/hm-shopping/hmall/user-service/target/user-service-1.0.0.jar wyl@10.147.17.85:/home/wyl/docker/java_app/hmall/user-service.jar
echo "复制user模块成功"

echo "复制item模块到远程docker服务器(10.147.17.85)"
sshpass -p 20000316 \
scp -v -o StrictHostKeyChecking=no \
/var/lib/jenkins/workspace/hmall/hm-shopping/hmall/item-service/target/item-service-1.0.0.jar wyl@10.147.17.85:/home/wyl/docker/java_app/hmall/item-service.jar
echo "复制item模块成功"

echo "复制cart模块到远程docker服务器(10.147.17.85)"
sshpass -p 20000316 \
scp -v -o StrictHostKeyChecking=no \
/var/lib/jenkins/workspace/hmall/hm-shopping/hmall/cart-service/target/cart-service-1.0.0.jar wyl@10.147.17.85:/home/wyl/docker/java_app/hmall/cart-service.jar
echo "复制cart模块成功"

echo "远程执行Docker构建命令"
sshpass -p 20000316 \
ssh -o StrictHostKeyChecking=no wyl@10.147.17.85 \
"cd /home/wyl/docker/java_app/hmall && docker build -t gateway:1.0 ."
echo "Docker构建完成"

Exe4j

1

2

3

4

4-2

5

6

6-2

6-3

接下来就是一直下一步

JavaTCP编程 从入门到精通

Netty

Mqtt broker

mqtt数据包

固定头(Fixed header )、可变头(Variable header)、消息体(payload)三部分构成。

┌───────────────────────────┐
│ Fixed Header 固定头       │ 每个报文都有
├───────────────────────────┤
│ Variable Header 可变头    │ 不同报文结构不同
├───────────────────────────┤
│ Payload 载荷              │ 某些报文有
└───────────────────────────┘
MQTTX启动连接
    │
    │ TCP三次握手
    ▼
initChannel()
    │ 安装MqttDecoder、MqttEncoder、Handler
    ▼
channelActive()
    │ 此时只是TCP连接成功
    │ MQTT还没有CONNECT
    ▼
客户端发送CONNECT字节
    ▼
MqttDecoder
    │ ByteBuf → MqttConnectMessage
    ▼
channelRead0(CONNECT)
    ▼
handleConnect()
    │ 保存Client ID、Keep Alive等信息
    │ 生成CONNACK
    ▼
MqttEncoder
    │ MqttConnAckMessage → ByteBuf
    ▼
客户端收到CONNACK
    │
    │ 后续发送PUBLISH、SUBSCRIBE、PINGREQ
    ▼
channelRead0(...)
    │
    │ 客户端发送DISCONNECT或者TCP异常断开
    ▼
channelInactive()

流程

服务器管理组

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

ServerBootstrap bootstrap = new ServerBootstrap();

bootstrap.group(bossGroup, workerGroup)
	.channel(NioServerSocketChannel.class)
	.childHandler(new MqttBrokerChannelInitializer())
	.childOption(ChannelOption.TCP_NODELAY, true)
	.childOption(ChannelOption.SO_KEEPALIVE, true);

Channel serverChannel = bootstrap.bind(port).sync().channel();
serverChannel.closeFuture().sync();

MqttBrokerChannelInitializer

/** 为每一条设备连接创建独立的MQTT Pipeline和连接上下文。 */
public final class MqttBrokerChannelInitializer
        extends ChannelInitializer<SocketChannel> {

    /** Lesson11暂时把单个MQTT报文限制为1 MiB。 */
    public static final int MAX_MQTT_MESSAGE_SIZE = 1024 * 1024;

    @Override
    protected void initChannel(SocketChannel channel) {
        MqttConnectionContext.attach(channel);//这里创建的是“TCP连接上下文”,不是MQTT持久会话。

        ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast(
                "mqttDecoder",
                new MqttDecoder(MAX_MQTT_MESSAGE_SIZE)); //mqtt解码器,解析TCP中的mqtt内容
        pipeline.addLast("mqttEncoder", MqttEncoder.INSTANCE);//mqtt编码器,java方便使用
        pipeline.addLast(
                "mqttMessageHandler",
                new MqttBrokerMessageHandler());//自定义的流水线节点
    }
}

MQTT上下文

package psn.wyl.mqtt.broker;

import io.netty.channel.Channel;
import io.netty.handler.codec.mqtt.MqttConnectMessage;
import io.netty.util.AttributeKey;

/**
 * 一条Channel独享的MQTT连接状态。
 *
 * <p>它先放在Channel Attribute中,因此不会被其他连接共享。后续课程会在
 * 这个对象上继续加入认证状态、订阅、会话和QoS状态机。</p>
 */
public final class MqttConnectionContext {

    public static final AttributeKey<MqttConnectionContext> ATTRIBUTE_KEY =
            AttributeKey.valueOf(
                    MqttConnectionContext.class,
                    "connectionContext");

    private final String channelId;
    private final String remoteAddress;
    private final long tcpConnectedAtMillis;

    private long lastPacketAtMillis;
    private long receivedPacketCount;
    private boolean mqttConnected;
    private boolean disconnected;
    private String clientId;
    private String protocolName;
    private int protocolLevel;
    private int keepAliveSeconds;
    private boolean cleanSession;

    private MqttConnectionContext(Channel channel) {
        this.channelId = channel.id().asLongText();
        this.remoteAddress = String.valueOf(channel.remoteAddress());
        this.tcpConnectedAtMillis = System.currentTimeMillis();
        this.lastPacketAtMillis = tcpConnectedAtMillis;
    }

    public static MqttConnectionContext attach(Channel channel) {
        MqttConnectionContext created =
                new MqttConnectionContext(channel);
        MqttConnectionContext existing =
                channel.attr(ATTRIBUTE_KEY).setIfAbsent(created);
        return existing == null ? created : existing;
    }

    public static MqttConnectionContext get(Channel channel) {
        MqttConnectionContext context =
                channel.attr(ATTRIBUTE_KEY).get();
        return context == null ? attach(channel) : context;
    }

    public void recordPacket() {
        receivedPacketCount++;
        lastPacketAtMillis = System.currentTimeMillis();
    }

    public void recordConnect(MqttConnectMessage message) {
        clientId = message.payload().clientIdentifier();
        protocolName = message.variableHeader().name();
        protocolLevel = message.variableHeader().version();
        keepAliveSeconds =
                message.variableHeader().keepAliveTimeSeconds();
        cleanSession = message.variableHeader().isCleanSession();
        mqttConnected = true;
        disconnected = false;
    }

    public void recordDisconnect() {
        mqttConnected = false;
        disconnected = true;
    }

    public String channelId() {
        return channelId;
    }

    public String remoteAddress() {
        return remoteAddress;
    }

    public long tcpConnectedAtMillis() {
        return tcpConnectedAtMillis;
    }

    public long lastPacketAtMillis() {
        return lastPacketAtMillis;
    }

    public long receivedPacketCount() {
        return receivedPacketCount;
    }

    public boolean mqttConnected() {
        return mqttConnected;
    }

    public boolean disconnected() {
        return disconnected;
    }

    public String clientId() {
        return clientId;
    }

    public String protocolName() {
        return protocolName;
    }

    public int protocolLevel() {
        return protocolLevel;
    }

    public int keepAliveSeconds() {
        return keepAliveSeconds;
    }

    public boolean cleanSession() {
        return cleanSession;
    }
}

MqttBrokerMessageHandler

package psn.wyl.mqtt.broker;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.DecoderResult;
import io.netty.handler.codec.mqtt.MqttConnectMessage;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageBuilders;
import io.netty.handler.codec.mqtt.MqttMessageType;

/**
 *
 * <p>MqttDecoder只负责把ByteBuf转换为MqttMessage;真正的协议状态检查、
 * 登录认证、主题路由和QoS必须由Broker Handler实现。本课只建立分发骨架。</p>
 */
public final class MqttBrokerMessageHandler
        extends SimpleChannelInboundHandler<MqttMessage> {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        MqttConnectionContext connection =
                MqttConnectionContext.get(ctx.channel());
        ctx.fireChannelActive();
    }

    @Override
    protected void channelRead0(
            ChannelHandlerContext ctx,
            MqttMessage message) {

        MqttConnectionContext connection =
                MqttConnectionContext.get(ctx.channel());
        connection.recordPacket();

        DecoderResult decoderResult = message.decoderResult();
        if (decoderResult.isFailure()) {
            Throwable cause = decoderResult.cause();
            System.err.printf(
                    "[MQTT] decode failed channel=%s cause=%s%n",
                    connection.channelId(),
                    cause == null ? "unknown" : cause.getMessage());
            connection.recordDisconnect();
            ctx.close();
            return;
        }

        MqttMessageType messageType =
                message.fixedHeader().messageType();
        switch (messageType) {
            case CONNECT -> handleConnect(
                    ctx,
                    connection,
                    (MqttConnectMessage) message);
            case PINGREQ -> handlePingReq(ctx, connection);
            case DISCONNECT -> handleDisconnect(ctx, connection);
            default -> System.out.printf(
                    "[MQTT] received type=%s channel=%s; handler will be implemented later%n",
                    messageType,
                    connection.channelId());
        }
    }

    private void handleConnect(
            ChannelHandlerContext ctx,
            MqttConnectionContext connection,
            MqttConnectMessage message) {

        connection.recordConnect(message);
        System.out.printf(
                "[MQTT] CONNECT channel=%s clientId=%s protocol=%s/%d keepAlive=%ds cleanSession=%s%n",
                connection.channelId(),
                connection.clientId(),
                connection.protocolName(),
                connection.protocolLevel(),
                connection.keepAliveSeconds(),
                connection.cleanSession());

        ctx.writeAndFlush(
                MqttMessageBuilders.connAck()
                        .returnCode(
                                MqttConnectReturnCode.CONNECTION_ACCEPTED)
                        .sessionPresent(false)
                        .build());
    }

    private void handlePingReq(
            ChannelHandlerContext ctx,
            MqttConnectionContext connection) {

        System.out.printf(
                "[MQTT] PINGREQ channel=%s%n",
                connection.channelId());
        ctx.writeAndFlush(MqttMessage.PINGRESP);
    }

    private void handleDisconnect(
            ChannelHandlerContext ctx,
            MqttConnectionContext connection) {

        System.out.printf(
                "[MQTT] DISCONNECT channel=%s clientId=%s%n",
                connection.channelId(),
                connection.clientId());
        connection.recordDisconnect();
        ctx.close();
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        MqttConnectionContext connection =
                MqttConnectionContext.get(ctx.channel());
        connection.recordDisconnect();
        System.out.printf(
                "[MQTT] TCP disconnected channel=%s clientId=%s packets=%d%n",
                connection.channelId(),
                connection.clientId(),
                connection.receivedPacketCount());
        ctx.fireChannelInactive();
    }

    @Override
    public void exceptionCaught(
            ChannelHandlerContext ctx,
            Throwable cause) {

        MqttConnectionContext connection =
                MqttConnectionContext.get(ctx.channel());
        connection.recordDisconnect();
        System.err.printf(
                "[MQTT] exception channel=%s cause=%s%n",
                connection.channelId(),
                cause.getMessage());
        ctx.close();
    }
}

Server

项目路径:

E:\_1\java\project\java-project-learning\java-study\netty-std

git地址:

https://gitee.com/wei-yuliu/java-project-learning

代码

EventLoopGroup bossGroup = new NioEventLoopGroup(1); //负责接收连接一个线程就能处理
// workerGroup:负责处理已建立连接的读写,线程数默认:cpu核心*2
EventLoopGroup workerGroup = new NioEventLoopGroup();

ServerBootstrap bootstrap = new ServerBootstrap();//理解为服务器装配器
bootstrap.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)//基于Java NIO实现的服务器Channel
    .option(ChannelOption.SO_BACKLOG, 128)       // 等待连接的队列大小
    .childOption(ChannelOption.SO_KEEPALIVE, true) // 保持长连接
    .childHandler(new ChannelInitializer<SocketChannel>() {
    	@Override
    	protected void initChannel(SocketChannel ch) {
        	ch.pipeline().addLast(new EchoServerHandler());
    	}
	});

class EchoServerHandler extends io.netty.channel.ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(io.netty.channel.ChannelHandlerContext ctx, Object msg) {
        String message = (String) msg;
        System.out.println("[服务端] 收到客户端消息: " + message);
        // 原样发回给客户端(回声)
        ctx.writeAndFlush("ECHO: " + message);
    }
}

ChannelFuture future = bootstrap.bind(8080).sync();
future.channel().closeFuture().sync();
Channel 分成两种,和NIO类似
NioServerSocketChannel//代表整个监听端口,例如:0.0.0.0:8080
NioSocketChannel//客户端的信息,ip和端口
option 和 childOption,配置Socket参数
  • ChannelOption.SO_BACKLO:表示操作系统等待服务器接受连接的队列容量。

  • ChannelOption.SO_KEEPALIVE:开启TCP KeepAlive。长连接。

option       → NioServerSocketChannel
childOption  → NioSocketChannel
初始化每条连接的Pipeline

childHandler: 每来一个新连接,都会执行一次 initChannel,创建处理对象,每个连接都有自己的pipeline。

这里SocketChannel是接口,按道理应该写成NioSocketChannel,但是后面如果Linux环境换成:EpollSocketChannel

.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(new EchoServerHandler());
    }
});
向操作系统申请绑定8080端口。

异步操作,立即返回,所以要改成同步sync()

ChannelFuture future = bootstrap.bind(8080).sync();

等待服务器 socket 关闭

future.channel().closeFuture().sync();

future.channel()拿到的是服务器Channel:NioServerSocketChannel

closeFuture()还是异步的,改成同步sync()。一般除非主动关,不然不会执行这个。

完成Lesson 1后:

  1. Boss负责接受连接,Worker负责连接读写。
  2. ServerBootstrap负责装配服务器。
  3. NioServerSocketChannel代表监听端口。
  4. 每个客户端连接拥有自己的Channel和Pipeline。
  5. Inbound数据从Pipeline前向后传播。
  6. Outbound数据从Pipeline后向前传播。
  7. Decoder将字节转换成业务对象。
  8. Encoder将业务对象转换成字节。
  9. bind()是异步操作,sync()负责等待结果。
  10. closeFuture().sync()让服务器持续运行。
  11. shutdownGracefully()负责释放Netty线程资源。
NettyJava NIO含义
NioServerSocketChannelServerSocketChannel监听服务器端口
NioSocketChannelSocketChannel表示一条已经连接的TCP连接
NioEventLoopSelector + Thread轮询并处理I/O事件
NioEventLoopGroup多个Selector和线程管理多个EventLoop
ChannelPipeline原生NIO没有处理事件的流水线
ChannelHandler原生NIO中的业务判断代码处理连接、读取、写入等事件
ByteBufByteBuffer保存网络字节
ChannelFuture原生NIO没有直接对应表示异步操作结果
ServerBootstrap手动创建、配置和注册Channel服务器启动器

创建和001课程一致pipeline主要是处理器

handlerAdded
    ↓
channelRegistered  //代码没写
    ↓
channelActive
    ↓
channelRead            可能执行多次
    ↓
channelReadComplete    可能执行多次
    ↓
channelInactive
    ↓
channelUnregistered //代码没写
    ↓
handlerRemoved

代码

class LifecycleHandler extends SimpleChannelInboundHandler<String> {

    // 每个连接独有一个实例,所以这两个计数器是"这条连接"自己的计数
    private int readCount = 0;
    
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        // 触发时机:Handler 被加入 pipeline 时(通常是连接建立的极早期)
        System.out.println("① handlerAdded  -> 连接到来,装配 Handler,remote=" + ctx.channel().remoteAddress());
    }
    
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 触发时机:TCP 连接真正建立完成(每条连接只触发 1 次)
        System.out.println("② channelActive -> 连接已建立(只此一次)");
        // 连接建立后,可以主动给客户端发一句欢迎语
        ctx.writeAndFlush("欢迎连接 Echo 服务器!随便说点什么,我给你回声~\r\n");
    }
    
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        // 触发时机:每收到一条数据就调用一次(可触发 N 次)
        readCount++;
        System.out.println("③ channelRead   -> 第 " + readCount + " 次收到消息: " + msg);
        ctx.writeAndFlush("ECHO: " + msg + " (这是服务器回显)\r\n");
    }
    
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        // 触发时机:Netty 一次批量读取动作结束后触发(通常紧跟在若干 channelRead 之后)
        // 作用:把累积缓冲区里没刷出去的数据一次性刷到网络
        System.out.println("④ channelReadComplete -> 本轮读取完成,刷新缓冲区");
        ctx.flush();
    }
    
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 触发时机:连接断开(客户端关闭 / 网络中断),每条连接只触发 1 次
        System.out.println("⑤ channelInactive -> 连接已断开(只此一次),共收到 " + readCount + " 条消息");
    }
    
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) {
        // 触发时机:Handler 从 pipeline 中移除(连接销毁的收尾阶段)
        System.out.println("⑥ handlerRemoved  -> Handler 被移除,释放资源");
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // 触发时机:链路中任何 Handler 抛出未捕获异常
        System.err.println("⚠ exceptionCaught -> 发生异常: " + cause.getMessage());
        // 一般做法:打印异常后关闭连接,避免脏连接
        ctx.close();
    }
}

客户端连接发送信息测试

① handlerAdded  -> 连接到来,装配 Handler,remote=/127.0.0.1:61014
② channelActive -> 连接已建立(只此一次)
③ channelRead   -> 第 1 次收到消息: 321
④ channelReadComplete -> 本轮读取完成,刷新缓冲区
④ channelReadComplete -> 本轮读取完成,刷新缓冲区
⑤ channelInactive -> 连接已断开(只此一次),共收到 1 条消息
⑥ handlerRemoved  -> Handler 被移除,释放资源

SimpleChannelInboundHandler

它提供两个便利:

  1. 自动筛选T消息。
  2. channelRead0()执行完成后自动释放接收到的消息。

channelRead0

客户端发送一次不保证服务端只触发一次channelRead0()。需要使用半包和粘包处理这个问题。

每个客户端连接有一条Pipeline,Pipeline里放多个Handler。完成mqtt的登录认证,连接成功判断,退出登录判断等等。

Pipeline传播方向

Worker EventLoop接收到的如果是耗时任务,这时候交给一个线程池去处理这个任务。最主要的是如何让Worker EventLoop或者设备知道已经处理完了,或者说Worker EventLoop提交耗时任务给线程池是要携带上下文过去,让这个线程池处理完任务的时候能通知Worker EventLoop或设备。

流程

Worker EventLoop
→ 提取并复制业务上下文
→ 提交Business Executor
→ 立即继续处理其他连接
→ Business Executor完成
→ 通过Future/Callback通知
→ 将完成任务重新提交给Channel EventLoop
→ EventLoop检查连接状态
→ 编码并发送响应
→ 设备收到协议响应

ex

Worker EventLoop

负责网络I/O:

Selector轮询
Socket读取
ByteBuf读取
协议解码
Pipeline事件
Socket写出
连接关闭
心跳事件

点:

  • 和Channel绑定
  • 一个EventLoop管理多个Channel
  • 必须快速执行
  • 不能长时间阻塞
  • 保证同一Channel的事件顺序

Business Executor

负责慢业务:

MySQL查询
同步Redis调用
远程HTTP请求
文件读写
复杂计算
同步等待Kafka结果

特点:

  • 不负责监听Socket
  • 不负责Selector
  • 不直接拥有Channel
  • 用来隔离慢任务
  • 可以有独立线程数和任务队列
  • 处理完成后把结果交还给Channel

对比:

项目Worker EventLoopBusiness Executor
是否管理Channel是否
是否管理Selector是否
是否处理Socket读写是否
是否可以长时间阻塞不应该可以有限阻塞
是否保证连接I/O顺序是需要设计
线程数量依据CPU和网络负载数据库、外部服务和阻塞比例
主要职责网络和协议业务处理

一个连接被Accept以后,会分配给WorkerGroup中的某一个EventLoop。通常在连接整个生命周期内,都由这个EventLoop负责网络读写和默认Pipeline事件。

生命周期

Netty是一个由java编写的异步的,基于事件驱动的网络应用框架。

Netty业务代码
    ↓
Netty EventLoop / NioChannel
    ↓
Java NIO API(Selector、SocketChannel)
    ↓
JVM JNI Native方法
    ↓
操作系统系统调用(epoll / IOCP / kqueue)
    ↓
操作系统内核 TCP/IP协议栈
    ↓
网卡硬件

第一层:Netty(应用框架层)

Netty 是基于 Java NIO 封装的高性能网络框架,不直接操作系统调用,把 NIO 的繁琐 API 做了封装:

  1. EventLoop:事件循环线程,一个线程绑定一个 Selector,循环做:轮询 IO 事件、处理连接、读写数据、执行任务。
  2. NioServerSocketChannel:服务端通道,对应服务端 Socket;
  3. NioSocketChannel:客户端连接通道;
  4. ChannelPipeline:责任链,读写事件在多个 Handler 之间传递(编解码、业务逻辑);
  5. ByteBuf:Netty 自研缓冲区,替代 Java NIO ByteBuffer,优化内存池、零拷贝。

Netty 底层就是调用 java.nio 包的类,Netty 本身不直接发起操作系统 syscall。

第二层:Java NIO(JDK 封装层,java.nio)

Java NIO 是 JDK 提供的非阻塞 IO 库,是 Java 程序和操作系统之间的中间层。 核心组件:

  1. Selector(多路复用器)
    • Linux:JDK NIO Selector 默认底层封装 epoll;
    • Windows:JDK NIO Selector 底层封装 IOCP;
    • MacOS:JDK NIO Selector 底层封装 kqueue;
  2. ServerSocketChannel:非阻塞服务端 Socket,监听端口;
  3. SocketChannel:非阻塞客户端通信通道;
  4. ByteBuffer:NIO 原生字节缓冲区。

Java NIO 的所有操作,最终都会通过 JVM 的 Native 方法(JNI),调用操作系统 C 库,再触发系统调用。

第三层:操作系统系统调用(OS 内核层)

系统调用:用户态程序向操作系统内核请求服务的入口。

Linux(epoll 模型)

Netty 在 Linux 下可以切换 epoll 原生实现:Netty 自带 EpollEventLoopGroup,不使用 JDK NIO Selector,直接通过 JNI 调用 Linux epoll 系统调用,跳过 JDK NIO 封装,性能更高。

  1. epoll_create:创建 epoll 实例;
  2. epoll_ctl:注册 / 删除 socket 到 epoll;
  3. epoll_wait:阻塞等待 IO 就绪事件(可读、可写、连接到来);
  4. socket() 创建套接字;
  5. bind() 绑定端口;
  6. listen() 开启监听;
  7. accept() 接收新连接;
  8. send() / recv() 收发 TCP 数据。

Windows(IOCP 完成端口)

Windows 没有 epoll,使用 IOCP 异步 IO 模型:

  1. WSASocket 创建 Windows 网络套接字;
  2. CreateIoCompletionPort 创建完成端口;
  3. WSAAccept 接收连接;
  4. WSASend / WSARecv 异步收发数据;
  5. GetQueuedCompletionStatus 获取 IO 完成事件。

MacOS(kqueue)

类 epoll 的多路复用模型:

  1. kqueue() 创建队列;
  2. kevent() 注册事件、等待事件;
  3. socket、bind、listen、accept、send/recv 基础网络系统调用。

Buffer 缓冲读写数据,内存中

ByteBuffer使用最多,其中有比较重要的概念和方法。

  • capacity(容量):Buffer的最大数据容量,创建时设置,不可改变
  • limit(限制):第一个不应该读取或写入的索引
  • position(位置):下一个要读取或写入的索引
  • mark(标记):一个备忘位置,通过mark()标记,reset()返回

Buffer的实现类

  • ByteBuffer
    • mappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

模式切换

假设buffer的capacity(容量)为10.

初始状态下

position=0    下一个写入位置
limit=10      可以写入最大位置
capacity=10   总容量
mark = -1     没有标记,标记用于拷贝之类的功能
写模式

可以通过put((byte)'A');方法写入一个字符。这时候

position=1 写入了一个指向1这个下标
limit=10 还能写9个
capacity = 10
mark = -1
读模式

执行flip()方法操作,将其他模式切换成读模式

limit = position;    // 将limit设为当前position(3)
position = 0;        // 将position重置为0
mark = -1;           // 清除标记
结果:
position = 0    ✓ 下一个读取位置
limit    = 1    ✓ 只能读到position=3的位置(之前写入的数据量)
capacity = 10   ✓ 总容量不变

Channel双向数据通道

​ 可以将数据读入buffer,也可以从buffer读取数据。

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

Selector

配合一个线程管理多个Channel上发生的事件。

同步阻塞IO

上下文切换过程:

  1. 用户态 → 内核态:调用 read() 系统调用
  2. 内核态:等待数据(进程挂起,不占用 CPU)
  3. 内核态:数据从网卡复制到内核缓冲区(DMA 直接内存访问)
  4. 内核态:数据从内核缓冲区复制到用户缓冲区
  5. 内核态 → 用户态:返回调用结果

特点:2 次上下文切换 + 2 次数据复制

用户进程              内核
   |                   |
   | 调用read()        |
   |----------------->|
   |                   |
   | 等待数据准备      |
   | (进程阻塞)        |--- 等待数据包到达 ---
   |                   |      ↓
   |                   | 数据到达网卡
   |                   |      ↓
   |                   | 数据复制到内核缓冲区
   |                   |      ↓
   | 数据复制完成      |
   |<-----------------|
   |                   |
   | 复制数据到用户空间|
   | (内核到用户复制)  |
   |                   |
   | 返回结果          |
   |<-----------------|

同步非阻塞IO

上下文切换过程:

  1. 用户态 → 内核态:发起系统调用
  2. 内核态 → 用户态:立即返回(无数据)
  3. 重复步骤 1-2 进行轮询(多次上下文切换)
  4. 用户态 → 内核态:数据就绪时调用
  5. 内核态:数据从内核复制到用户空间
  6. 内核态 → 用户态:返回结果

特点:多次上下文切换 + 2 次数据复制,CPU 消耗高

用户进程              内核
   |                   |
   | 调用recv()        |
   |----------------->|
   |                   |
   | 立即返回EWOULDBLOCK|
   |<-----------------|
   |                   |
   | 不断轮询recv()    |
   |----------------->|
   | 返回EWOULDBLOCK   |
   |<-----------------|
   |      ...          |
   | 第N次调用recv()   |
   |----------------->|
   |                   |
   | 数据已准备好      |
   | 开始复制到用户空间 |
   |<-----------------|
   | 复制完成          |
   |<-----------------|

多路复用

NIO+Selector对路复用器

用户进程              内核
   |                   |
   | 调用select()      |
   |----------------->|
   |                   |
   | 监视多个fd        |
   | (进程阻塞)        |--- 等待任意fd就绪 ---
   |                   |      ↓
   |                   | 有fd就绪(如socket1)
   |<-----------------|
   |                   |
   | 遍历就绪fd        |
   | 调用recv(socket1) |
   |----------------->|
   |                   |
   | 复制数据到用户空间 |
   |<-----------------|

上下文切换过程(以 select 为例):

应用程序               内核
   |                    |
   | select() 系统调用  | 1. 用户态→内核态
   |------------------>|
   |                    |
   | 遍历所有fd         | 2. 在内核遍历所有fd
   |                    |
   | 有fd就绪时返回     | 3. 内核态→用户态
   |<------------------|
   |                    |
   | 遍历就绪fd         | 4. 在用户空间遍历
   |                    |
   | 对每个就绪fd:      |
   |   recv()系统调用   | 5. 用户态→内核态
   |------------------>|
   |   数据复制         | 6. 内核到用户复制
   |<------------------| 7. 内核态→用户态

epoll 的改进:

应用程序               内核
   |                    |
   | epoll_create()     |
   |------------------>|
   |                    |
   | epoll_ctl(ADD)     | 添加fd到红黑树
   |------------------>|
   |                    |
   | epoll_wait()       | 1. 用户态→内核态
   |------------------>|
   |                    |
   | 等待就绪事件        | 2. 进程阻塞
   |                    |    ↓
   |                    | 事件就绪队列
   |<------------------| 3. 返回就绪事件
   |                    |    (只返回就绪的fd)
   |                    |
   | 处理就绪事件        |
   | recv(就绪fd)       | 4. 用户态→内核态
   |------------------>|
   |                    | 5. 数据复制
   |<------------------| 6. 内核态→用户态

特点:

  • select/poll:每次调用都要传递所有 fd,内核遍历所有 fd
  • epoll:内核维护事件表,只返回就绪的 fd

异步阻塞IO(少用)

AIO,linux中对的异步实际上是假的AIO,底层实现还是多路复用模拟的AIO

在windows中实现了真正的异步AIO(IOCP)。

上下文切换:

类似同步阻塞,但操作发起是非阻塞的,等待完成是阻塞的。

用户进程              内核
   |                   |
   | 发起异步操作      |
   |----------------->|
   |                   |
   | 立即返回          |--- 异步处理 ---
   |<-----------------|      ↓
   |                   | 等待操作完成
   |                   |      ↓
   | 调用阻塞等待      | 完成时设置状态
   |----------------->|
   |                   |
   | 等待完成          | 阻塞等待信号
   |<-----------------|

异步非阻塞IO

数据流程(Linux AIO):

用户进程              内核
   |                   |
   | io_submit()       | 1. 用户态→内核态
   |----------------->|
   |                   |
   | 立即返回          | 2. 内核态→用户态
   |<-----------------|
   |                   |
   | 继续执行其他任务   |--- 内核异步处理 ---
   |                   |      ↓
   |                   | 等待数据到达
   |                   |      ↓
   |                   | 数据复制到用户缓冲区
   |                   |      ↓
   | 收到信号/回调     | 3. 内核发送信号
   |<-----------------|
   |                   |
   | 处理完成事件       |

Windows IOCP 流程:

应用程序              内核
   |                   |
   | WSARecv()         | 1. 提交异步请求
   |----------------->|
   |                   |
   | 立即返回          | 2. 返回正在处理
   |<-----------------|
   |                   |
   | 继续执行          |--- 内核处理 ---
   |                   |      ↓
   | GetQueuedCompletionStatus()|
   |----------------->| 3. 等待完成端口
   |                   |      ↓
   |                   | 操作完成时
   |                   | 数据已复制到用户缓冲区
   |<-----------------| 4. 返回完成状态

数据复制差异:

  • 同步 I/O:用户进程调用时,数据才从内核复制到用户空间
  • 异步 I/O:内核自动完成数据复制,完成后通知用户进程

信号驱动

不完善,基本不用

对比总结

模型用户态→内核态切换次数数据复制时机等待方式
同步阻塞1次(等待+复制)调用时复制进程阻塞
同步非阻塞多次轮询数据就绪时复制轮询
多路复用2次(select+recv)就绪后复制单线程阻塞等待多个
异步阻塞2次(提交+等待)完成时已复制阻塞等待完成
异步非阻塞1次(提交)内核自动复制回调/信号

数据复制过程:

  1. DMA 复制:网卡数据 → 内核缓冲区(由 DMA 控制器完成,不占用 CPU)
  2. CPU 复制:内核缓冲区 → 用户缓冲区(需要 CPU 参与)

概念

**Zero-Copy 指的是:**在数据在内核空间中“就地传输”,无需拷贝至用户空间,避免中间缓冲区的重复搬运。

核心目标:

  • 最少拷贝:尽可能避免用户空间和内核空间之间的数据复制

  • 最少上下文切换:减少 CPU 参与传输过程

  • 高性能吞吐:提升 I/O 吞吐量与系统响应速度

这篇文章,带你从最简单的tcp服务器入手。再到NIO 服务器,学会用netty高性能框架搭建各种基于Tcp协议的服务器。

该过程全部针对的是java Tcp服务器,所以,TCP客户端使用工具即可(我这里使用的是友善串口助手)。

1.java最简单的Tcp服务:

代码:
public class TcpDemo01 {

	public static void main(String[] args) throws IOException {
		// 1.创建Tcp服务器并绑定端口
		ServerSocket serverSocket = new ServerSocket(8111);
		// 2.等待连接
		Socket socket = serverSocket.accept();
		// 3.打印连接内容
		System.out.println(socket);
	}

}

解释:

java创建tcp服务器是极其简单的。

  1. new ServerSocket(8111)就能创建一个Tcp服务器。
  2. 执行到serverSocket.accept()方法。代码会卡(阻塞)在这个方法,不执行后面的代码。当客户端连接进来时,该方法就不卡(阻塞)。同时会返回客户端连接信息。
  3. 打印的信息,addr客户端的ip地址,port客户端的端口,localport前面设置的服务器端口。

2.保持连接,接收客户端信息

代码:
public class TcpDemo02 {

	public static void main(String[] args) throws IOException {
		// 1.创建Tcp服务器并绑定端口
		ServerSocket serverSocket = new ServerSocket(8111);
		// 2.等待连接
		Socket socket = serverSocket.accept();
		// 3.打印连接内容
		System.out.println(socket);
		// 4.拿到输入流
		InputStream inputStream = socket.getInputStream();
		// 5.等待客户端发信息过来
		int readData = inputStream.read();
		System.out.println((char) readData);
	}

}
  1. 当TcpServer和TcpClient连接完成之后,他们之间就会有通道。我们拿到输入流是针对Server而言。即输入到Server的流。

  2. inputStream.read();方法也会阻塞,当客户端发送数据过来的时候才会取消阻塞。

  3. demo02-test

  4. 客户端发送的是ascii的123,所以服务器要将ascii转成字符(char) readData。通过测试我们发现,客户端发送的是123,但服务只打印了1。read()方法读取流中的数据是挨个读的。读到一个数据1后,代码继续执行打印,后面没有代码了,服务器就断开连接了流也断开了。所以只读到一个。

  5. 解决读取客户端数据不完整问题:

    1. read()有带参数的方法重载read(byte b[])。设置byte b[]数组的大小就能设置每次读取多少个数据。代码:

      public class TcpDemo02 {
      
      	public static void main(String[] args) throws IOException {
      		// 1.创建Tcp服务器并绑定端口
      		ServerSocket serverSocket = new ServerSocket(8111);
      		// 2.等待连接
      		Socket socket = serverSocket.accept();
      		// 3.打印连接内容
      		System.out.println(socket);
      		// 4.拿到输入流
      		InputStream inputStream = socket.getInputStream();
      		byte bs[] = new byte[3];
      		// 5.等待客户端发信息过来
      		int readData = inputStream.read(bs);
      		System.out.println(new String(bs));
      	}
      
      }
      

      ​ 但显然这种方式不合理,虽然接收到了客户端完整123信息,但只能接收到一次,服务器还是会断开。而且我们是不知道客户端要传多少信息过来的。当,接收大小 < 发送大小。数据还是会发生丢失。

    2. 循环读取,代码:

      public class TcpDemo02 {
      
      	public static void main(String[] args) throws IOException {
      		// 1.创建Tcp服务器并绑定端口
      		ServerSocket serverSocket = new ServerSocket(8111);
      		// 2.等待连接
      		Socket socket = serverSocket.accept();
      		// 3.打印连接内容
      		System.out.println(socket);
      		// 4.拿到输入流
      		InputStream inputStream = socket.getInputStream();
      		while (true) {
      			// 5.等待客户端发信息过来
      			int readData = inputStream.read();
      			System.out.println((char) readData);
      			// 客户端发送完成时read()方法会返回-1
      			if (readData == -1) {
      				System.out.println("接收完成断开连接");
                      break;
      			}
      		}
      	}
      
      }
      

      ​ 通过一个循环,一直等待read()方法返回数据,这样子就能一直接收到客户端传来的信息了。有数据就执行打印,并且判断read()返回的数据是否是-1,-1表示客户端发送完成。循环读取,每次只读一个字符,如果客户端发送的数据量大,那么性能就会下降。结合上面两种接收方式做以下改进。

    3. 结合优化

      public class TcpDemo02 {
      
      	public static void main(String[] args) throws IOException {
      		// 1.创建Tcp服务器并绑定端口
      		ServerSocket serverSocket = new ServerSocket(8111);
      		// 2.等待连接
      		Socket socket = serverSocket.accept();
      		// 3.打印连接内容
      		System.out.println(socket);
      		// 4.拿到输入流
      		InputStream inputStream = socket.getInputStream();
      		byte bs[] = new byte[1024];
      		while (true) {
      			// 5.等待客户端发信息过来
      			int readData = inputStream.read(bs);
      			System.out.println(new String(bs));
      			// 客户端断开连接时read()方法会返回-1
      			if (readData == -1) {
      				System.out.println("客户端断开连接");
                      break;
      			}
      		}
      	}
      
      }
      

3.服务器支持多个客户端

​ 至此,就得到了一个能接收到单个客户端信息的Tcp服务器。这时候再打开一个tcp客户端工具,就会发现只能第一个连接的客户端发送消息能在服务器打印。这是为什么呢?

//根据上面的代码,观察这3行代码
Socket socket = serverSocket.accept();
InputStream inputStream = socket.getInputStream();
int readData = inputStream.read(bs);

​ 不难发现,我们只维护了第一个socket,第一个输入流。后面连接进来的也没有覆盖第一个也没有进行管理。我们的服务器只能接收一个请求,这显然不合理。进一步优化!

​ 首先我们要明确,如何才能使得两个客户端发送过来的消息都能进行处理。我们发现如果只在当前线程的话,只能接收处理一个连接。这时候引入多线程去做处理就能解决这些问题。(当然还有其他方法可以做到,后面讨论)

代码:
public class TcpDemo03 {

	public static void main(String[] args) throws IOException {
		// 1.创建Tcp服务器并绑定端口
		ServerSocket serverSocket = new ServerSocket(8111);
		while (true) {
			// 2.等待连接
			Socket socket = serverSocket.accept();
			// 3.打印连接内容
			System.out.println(socket + "连接成功");
			// 4.每次有连接,创建一个线程这个连接管理
			new Thread(() -> {
				try {
					InputStream inputStream = socket.getInputStream();
					byte bs[] = new byte[1024];
					while (true) {
						// 5.等待客户端发信息过来
						int readData = inputStream.read(bs);
						// 客户端断开连接时read()方法会返回-1
						if (readData == -1) {
							System.out.println(socket + "断开连接");
							break;
						}
						System.out.println(socket + "收到信息:" + new String(bs));
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}).start();

		}
	}
}

解释:

​ 每次有连接进来的时候,创建一个线程(lambda表达式),每个连接都单独处理,互不影响。这样子我们就能支持多个客户端的连接了。每次连接都会创建一个线程,客户端数量上来了就会卡顿,服务器扛不住这么多连接。既然如此再优化!

​ 设置最大连接数量,并且我们不主动创建线程,由线程池去管理线程。

public class TcpDemo04 {
	private static final int MAX_CONNECT = 200;// 设置最大连接数
	private static ExecutorService threadPool = Executors.newFixedThreadPool(MAX_CONNECT);
	public volatile static int connectedCount = 0;

	public static void main(String[] args) throws IOException {
		// 1.创建Tcp服务器并绑定端口
		ServerSocket serverSocket = new ServerSocket(8111);
		while (true) {
			// 2.等待连接
			Socket socket = serverSocket.accept();
			connectedCount++;
			if (connectedCount > MAX_CONNECT) {
				socket.close();
                connectedCount--;//连接关闭,连接数量-1
				System.out.println("连接数量超过设定数据,阻止连接");
			} else {
				// 3.打印连接内容
				System.out.println(socket + "连接成功");
				threadPool.execute(new HandleThread(socket));
			}
		}
	}
}

class HandleThread implements Runnable {

	private Socket socket;

	public HandleThread(Socket _socket) {
		this.socket = _socket;
	}

	@Override
	public void run() {
		try {
			InputStream inputStream = socket.getInputStream();
			while (true) {
				byte bs[] = new byte[1024];
				// 5.等待客户端发信息过来
				int readData = inputStream.read(bs);
				// 客户端断开连接时read()方法会返回-1
				if (readData == -1) {
					System.out.println(socket + "断开连接");
                    TcpDemo04.connectedCount--;//连接关闭,连接数量-1
					break;
				}
				System.out.println(socket + "信息:" + new String(bs));
			}
		} catch (Exception e) {

		}
	}
}

​ 连接过来时,线程池管理这些连接。经过测试我们发现,当超出最大连接数量时,后面的连接就会断开。这样子就能防止过多连接影响性能。显然这种方式对超出连接数量后的连接不够友好。它们无论如何都不能连接到服务器,我们提供的服务器用户多了就会卡。明显我们要解决这个问题。

解决问题的方式就有:

  1. 大力飞砖:又称纵向扩展,即升级配置,增强服务器的硬件配置(如增加CPU、内存、存储等)来提升其性能。

  2. 负载均衡:又称横向扩展,即增加服务器数量,一个服务器不够用那就搞多几个服务器。

  3. 优化:充分利用电脑的性能,减少连接开销,减少处理开销。创建线程过多,线程上下文切换,以及每个线程都会占用一定的栈空间。serverSocket.accept(),socket.getInputStream().read()都是阻塞的,阻塞过程中CPU都不工作.

前面两种都是要钱的。那就只能尽量通过优化的方式去解决问题。

下面我们引进nio(非阻塞io)

ServerSocketChannel.open()创建一个服务器的socket,并且建立管道Channel。这个方式跟我们一个开始使用new ServerSocket(8080);没有太大区别。

public class NioTcpDemo01 {
    public static void main(String[] args) throws Exception{
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        SocketChannel accept = ssc.accept();
        System.out.println(accept);
    }
}

⬇接着是接收客户端发来的消息,跟前面没有用Channel是一个性质的。

public class NioTcpDemo01 {
    public static void main(String[] args) throws Exception{
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        SocketChannel accept = ssc.accept();
        System.out.println(accept);
        accept.read(buffer);
        System.out.println(new String(buffer.array()));
    }
}

⬇设置为非阻塞模式,充分利用系统的函数进行优化

public class NioTcpDemo01 {
	//设置为非阻塞
    public static void main(String[] args) throws Exception {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);
        while(true){
            SocketChannel accept = ssc.accept();//重点为非阻塞
            if(accept != null)
            	System.out.println(accept);
        }
    }
}

⬆设置为非阻塞之后accept()不再阻塞,如果没有连接进来,会返回null。通过主线程while循环进行不断轮询可以知道是否有连接进来。

将SocketChannel也设置为非阻塞。即当客户端连接进来时不阻塞read事件.

public static void main(String[] args) throws Exception {
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);
        List<SocketChannel> socketChannels = new ArrayList<>();
        while(true){
            SocketChannel accept = ssc.accept();//重点为非阻塞
            if(accept != null){
                System.out.println(accept);
                accept.configureBlocking(false); //设置SocketChannel为非阻塞
                socketChannels.add(accept);
            }

            for (SocketChannel socketChannel : socketChannels){

                int read = socketChannel.read(buffer);//这里不阻塞了,如果读到数据,则返回数据长度,否则返回0
                if (read > 0){
                    buffer.flip();
                    byte[] data = new byte[read];
                    buffer.get(data);
                    System.out.println(new String(data));
                    buffer.clear();
                    socketChannel.write(ByteBuffer.wrap(data));
                }
                if (read == -1) { //read==-1就断开连接了
                    // 客户端断开连接
                    System.out.println("客户端断开连接: " + socketChannel);
                    socketChannel.close();
                    socketChannels.remove(socketChannel);
                    continue;
                }
            }
        }
    }

selector和channel建立连接

public class NioTcpDemo02 {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);//设置为非阻塞

        //创建多路复用
        Selector selector = Selector.open();
        //ssc注册到多路复用器,监听连接事件,selector管理和监听这个Channel
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        while(true){
            int select = selector.select(); //阻塞,等待事件触发
            if(select > 0){
                Set<SelectionKey> selectionKeys = selector.selectedKeys(); //获取所有事件
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()){
                    SelectionKey key = iterator.next();
                    if(key.isAcceptable()){//判断是否是连接事件
                        //连接事件的channel必定是ServerSocketChannel
                        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                        //接受连接
                        SocketChannel socketChannel = channel.accept();
                        System.out.println(socketChannel);
                        socketChannel.configureBlocking(false);
                        //socketChannel也交给Selector管理和监听
                        socketChannel.register(selector,SelectionKey.OP_READ);
                        System.out.println("连接成功");
                        iterator.remove(); // 时间处理完成移除事件
                    }
                    if(key.isReadable()){
                        SocketChannel socketChannel = (SocketChannel) key.channel();
                        byte[] buffer = new byte[1024];
                        int read = socketChannel.read(ByteBuffer.wrap(buffer));
                        if(read > 0){
                            System.out.println(new String(buffer));
                        }
                        socketChannel.write(ByteBuffer.wrap(buffer));
                        iterator.remove();
                    }
                }
            }
        }
    }
}

boss-worker

但实际上我们只用到了一个线程,一个selector,不能充分使用多核cpu的优势。现在我们创建多个selector。我们规定boss-selector只负责建立连接,worker-selector负责业务处理。每次连接创建一个线程,这个线程中创建worker-selector,这个线程中的selector负责处理其他事件。而且规定创建的worker数量。

连接事件->boss
读写事件->worker

假设worker最大数量=4个
当一个连接进来,我们创建一个worker,有4个连接进来,创建4个worker后,第5个连接进来时会复用原来的worker

多Reactor

Http协议基于Tcp协议。

相对于TCP协议:

  • 规定客户端需要发过来什么格式的信息,服务器需要返回什么格式的信息,通过格式服务器或者客户端就能根据规定解析得到对方传递过来的内容。

  • 短连接,客户端连接服务器,服务器返回信息后主动断开连接。

最简单的Http服务器

​ 1.首先,通过浏览器访问我们的服务器。

public class HttpServerDemo01 {
	public static void main(String[] args) throws IOException {
		ServerSocket serverSocket = new ServerSocket(8111);
		Socket accept = serverSocket.accept();
        OutputStream outputStream = accept.getOutputStream();
        //真实内容
        String content = "<html><body><h1>Hello World!</h1></body></html>";
        //按照协议,拼接返回内容
        String response = "HTTP/1.1 200 OK\r\n" + 
            			"Content-Type: text/html; charset=utf-8\r\n"  +
            			"Content-Length: "+content.getBytes().length + "\r\n"  +
            			"\r\n"+
            			content
		outputStream.write(buildHttpResponse().getBytes());
	}
}

返回内容解析

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 47

<html><body><h1>Hello World!</h1></body></html>
  • 协议版本:HTTP1.1。
  • 响应码:200。200为成功,我们常见的404响应码,可以通过改动这个地方。
  • 返回的数据类型:Content-Type: text/html; 这个数据类型表示html,浏览器会解析为html并且渲染在界面上。
  • 字符集:charset=utf-8。
  • 空行: 这个空行将上面协议内容和真正的数据隔离开。不可删除
  • 返回的数据:这个就是服务器真正返回给浏览器的数据。
  • 要注意的点:上面的返回内容,一行都是使用回车换行(\r\n)。

Java日志框架

Java日志框架系统学习教程(完善版)

第一部分:日志基础与核心概念

  1. 日志的重要性与使用场景
    • 为什么需要日志框架
    • 日志的五个核心作用(调试、监控、审计、统计分析、故障排查)
    • 良好日志实践的标准
  2. 日志级别详解
    • TRACE、DEBUG、INFO、WARN、ERROR、FATAL
    • 级别选择策略与应用场景
    • 动态调整日志级别

第二部分:Java日志发展历史与架构演进

  1. 第一代:JDK自带日志
    • java.util.logging (JUL) 的诞生与设计
    • 优点与局限性
  2. 第二代:Log4j 1.x的统治时代
    • Apache Log4j 1.x的设计理念
    • 配置文件格式与基本使用
    • 性能问题与停止维护
  3. 日志门面的出现
    • 为什么需要门面模式
    • Commons Logging (JCL) vs SLF4J
    • 门面模式的优势与实现原理
  4. 第三代:现代日志框架
    • Logback:SLF4J的官方实现
    • Log4j 2.x:性能与功能的飞跃
    • 异步日志的演进

第三部分:主流日志框架深度使用

  1. java.util.logging (JUL)
    • 基础配置与Handler使用
    • 自定义Formatter与Filter
    • 在无依赖项目中应用
  2. Log4j 2.x
    • 架构:API与Core分离
    • 配置文件详解(XML、JSON、YAML、Properties)
    • Appenders:Console、File、RollingFile、Socket、Async等
    • Layouts:PatternLayout、JSONLayout、CSVLayout
    • Filters:Threshold、Burst、Time等
    • 异步日志:AsyncLogger与AsyncAppender
    • Lookups与自定义组件
    • 性能优化与监控
  3. Logback
    • 架构与组件:Logger、Appender、Layout
    • 配置文件详解:logback.xml、logback-spring.xml
    • Appender:ConsoleAppender、FileAppender、RollingFileAppender
    • 条件配置与变量使用
    • MDC(Mapped Diagnostic Context)使用
    • 性能特性与自动重载配置

第四部分:日志门面深入理解

  1. SLF4J深入
    • 绑定机制与桥接器
    • MDC与标记(Marker)高级使用
    • 参数化日志与性能优化
    • 桥接旧日志系统(jcl-over-slf4j, jul-to-slf4j, log4j-over-slf4j)
  2. 日志统一管理策略
    • 多模块项目日志统一
    • 第三方库日志接管
    • 避免日志冲突与依赖地狱

第五部分:Spring生态集成

  1. Spring Framework日志集成
    • Spring的日志抽象
    • 与JCL、SLF4J的集成方式
    • 在Spring MVC/WebFlux中的日志实践
  2. Spring Boot日志配置
    • 默认Logback配置与自动配置原理
    • 配置文件优先级:logback-spring.xml > application.properties
    • Profile-specific配置
    • 彩色输出与banner日志
  3. Spring Boot切换日志框架
    • 切换到Log4j2:排除logback,添加log4j2依赖
    • 切换到JUL:配置与注意事项
    • 多日志框架并存策略

第六部分:高级特性与最佳实践

  1. 性能优化
    • 异步日志的性能影响
    • 合理的日志级别设置
    • 避免日志中的性能陷阱
  2. 结构化日志
    • JSON格式日志输出
    • 与ELK/EFK栈集成
    • 在微服务中的结构化日志实践
  3. 分布式追踪
    • TraceId与SpanId的传递
    • 集成Sleuth/Brave
    • 日志与OpenTelemetry
  4. 日志监控与告警
    • 关键错误告警配置
    • 日志量监控
    • 基于日志的Metrics收集

第七部分:项目实战与案例分析

  1. 单应用日志配置实战
    • 开发/测试/生产环境不同配置
    • 日志文件切割与归档策略
    • 敏感信息过滤
  2. 微服务架构日志方案
    • 集中式日志收集架构
    • 日志规范与标准化
    • 跨服务调用链追踪
  3. 常见问题排查
    • 日志丢失问题定位
    • 性能问题排查
    • 配置不生效排查流程

第八部分:扩展与未来趋势

  1. 新兴日志框架了解
    • tinylog
    • Log4j 2.x最新特性
  2. 云原生环境日志
    • 容器环境日志最佳实践
    • Kubernetes日志方案
    • Serverless函数日志
  3. 日志即数据理念
    • 日志分析平台搭建
    • 基于日志的机器学习应用

大纲

  • 基本使用
  • 日志级别
  • 日志处理器Handler
  • 日志格式化器Formater
  • 日志的层级关系

基本使用

import java.util.logging.Logger;
public class Demo01 {
    private final static Logger log = Logger.getLogger(Demo01.class.getName());
    public static void main(String[] args) {
        log.info("hello world");
    }
}
2月 02, 2026 11:44:58 上午 w.Demo01 main
信息: hello world

解释

  • 代码

​ jul正常定义日志定义成private只在一个类中使用,final修饰。通过调用Logger的静态方法**getLogger()**将类的名称作为参数传进去.

  • 输出

​ 时间格式默认为:mm月 xx, yyyy年 am/pm 类全限定名 方法 信息:xxxxx

高版本的jdk比如17,默认配置文件在安装目录下的/conf目录的logging.properties下。

日志级别

​ 需要在配置文件logging.properties中设置

  • log.severe("严重级别");
  • log.warning("警告级别");
  • log.info("信息级别"); //默认
  • log.config("配置级别");
  • log.fine(" 细微级别");
  • log.finer("更细微级别");
  • log.finest("最细微级别");
public class Demo02 {
    private static final Logger log = Logger.getLogger(Demo02.class.getName());
    public static void main(String[] args) {
        log.severe("严重级别");
        log.warning("警告级别");
        log.info("信息级别"); //默认

        log.config("配置级别");
        log.fine(" 细微级别");
        log.finer("更细微级别");
        log.finest("最细微级别");
    }
}

Handler

public class Demo03 {
    private static final Logger log = Logger.getLogger(Demo03.class.getName());
    public static void main(String[] args) throws IOException {
        log.setUseParentHandlers(false); //必须关闭父级处理器
        
        ConsoleHandler consoleHandler = new ConsoleHandler();
        consoleHandler.setLevel(java.util.logging.Level.ALL);
        log.addHandler(consoleHandler);
        log.severe("严重级别");

        FileHandler fileHandler = new FileHandler("D://demo03.log", true);
        fileHandler.setLevel(java.util.logging.Level.ALL);
        log.addHandler(fileHandler);
        log.info("信息级别");
    }
}

JUL有两种处理器,控制台ConsoleHandler和FileHandler。创建处理器并且加入到日志对象中就完成功能。注意:必须要关闭父级管理器,java启动默认有一个处理器默认配置就是处理器中相关的内容。

Formatter

public class Demo04 {
    private static final Logger log = Logger.getLogger(Demo04.class.getName());
    public static void main(String[] args) throws IOException {

        log.setUseParentHandlers(false);//必须关闭父级处理器

        SimpleFormatter formatter = new SimpleFormatter();

        FileHandler fileHandler = new FileHandler("D://demo04.log", true);
        fileHandler.setLevel(Level.ALL);
        fileHandler.setFormatter(formatter); //设置日志格式
        log.addHandler(fileHandler);
        log.info("信息级别");
    }
}

控制台默认的日志格式就是SimpleFormatter,文件输出的是xml格式,所以只要把FileHandler的日志格式改成SimpleFormatter。

JVM

默认栈大小堆大小收操作系统,不同虚拟机,硬件情况,等因素影响

栈溢出

栈分成两种

  • 虚拟机栈
  • 本地方法栈(jni调用)

栈溢出原因主要是太多东西入栈,每次调用方法都是一次入栈,每次调用生成的栈帧入栈后占用一些空间。当出现无限递归时会溢出,当栈深度过深时也会出现溢出。

OOM

OutOffMemeryError有很多种出现情况,OOM一般分为:堆区,方法区,常量池。

堆区:

  • 一直创建且不让回收:一直往一个集合里面塞新建的对象。

  • 创建大规模数组

方法区:

  • 动态生成大量类

常量池:

  • jdk1.7后字符串常量放到了堆中:使用intern()方法导致。

Maven

Maven 多模块项目结构解析

项目父模块 POM 配置分析

以下是一个典型的多模块 Maven 项目的父 POM 配置示例:

xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <!-- 项目基本信息 -->
    <groupId>org.example</groupId>
    <artifactId>wyl-iot</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    
    <!-- 继承 Spring Boot 父项目 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>
    
    <!-- 模块定义 -->
    <modules>
        <module>iot-common</module>
        <module>iot-gateway</module>
        <module>iot-mqtt</module>
    </modules>
    
    <!-- 统一属性配置 -->
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <hutool.version>5.8.11</hutool.version>
        <mqtt.version>1.2.5</mqtt.version>
    </properties>
    
    <!-- 依赖版本管理 -->
    <dependencyManagement>
        <dependencies>
            <!-- MQTT 客户端依赖管理 -->
            <dependency>
                <groupId>org.eclipse.paho</groupId>
                <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
                <version>${mqtt.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
    
    <!-- 子模块共享依赖 -->
    <dependencies>
        <!-- Lombok 注解处理器 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        
        <!-- Hutool 工具库 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
    </dependencies>
</project>

父 POM 关键特性解析

1. 继承机制

  • 通过继承 spring-boot-starter-parent,项目自动获得 Spring Boot 的默认配置和依赖管理

  • 子模块可以无需指定版本号直接使用 Spring Boot 相关依赖,例如:

    xml

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    

2. 依赖管理

  • <dependencyManagement> 节用于统一管理依赖版本,确保多模块间版本一致性

  • 声明的依赖不会直接引入,需要在子模块中显式引用

  • 子模块引用时无需指定版本号:

    xml

    <dependency>
        <groupId>org.eclipse.paho</groupId>
        <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
    </dependency>
    

3. 共享依赖

  • 在 <dependencies> 节中定义的依赖会被所有子模块继承
  • 这些依赖会通过依赖传递机制自动提供给子模块使用

子模块 POM 配置示例

以下是一个子模块的典型 POM 配置:

xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <!-- 指定父模块 -->
    <parent>
        <groupId>org.example</groupId>
        <artifactId>wyl-iot</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    
    <!-- 子模块标识 -->
    <artifactId>iot-mqtt</artifactId>
    
    <!-- 模块特定依赖 -->
    <dependencies>
        <!-- 引用同一项目中的其他模块 -->
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>iot-common</artifactId>
            <version>${project.version}</version>
        </dependency>
        
        <!-- Spring Boot 相关依赖(无需版本号) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- 父模块依赖管理中定义的依赖 -->
        <dependency>
            <groupId>org.eclipse.paho</groupId>
            <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
        </dependency>
        
        <!-- 其他第三方依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>
    
    <!-- 构建配置 -->
    <build>
        <plugins>
            <!-- Spring Boot 打包插件 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

子模块关键特性解析

1. 属性继承

  • 子模块可以引用父模块中定义的所有属性
  • 例如:${maven.compiler.source} 引用父模块中定义的 Java 版本

2. 依赖管理

  • 子模块自动继承父模块的依赖管理配置
  • 对于父模块中已通过 <dependencies> 定义的依赖(如 Lombok、Hutool),子模块无需显式声明即可使用
  • 对于父模块中仅在 <dependencyManagement> 定义的依赖,子模块需要显式声明依赖但无需指定版本号
  • Spring Boot 相关依赖也无需指定版本号,由继承的 Spring Boot 父 POM 管理

3. 模块间依赖

  • 子模块可以依赖同一项目中的其他模块
  • 建议使用 ${project.version} 确保版本一致性

maven中的坑

  • 依赖管理问题

​

父POM

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
</parent>

<!-- 通过import scope导入BOM -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2021.0.3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

子模块:

<parent>
    <groupId>org.example</groupId>
    <artifactId>wyl-iot</artifactId>
    <version>1.0-SNAPSHOT</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId><!-- 不需要指定版本 -->
        
        <!--nacos 服务注册发现-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!--负载均衡-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>

        <!--网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
    </dependency>
</dependencies>

​ 如果版本管理中引入的spring-cloud-dependencies中没有使用import。子模块会找不到版本号。例如,这里如果去掉了。下面的:nacos 服务注册发现、负载均衡、网关统统找不到。但是,如果加了一次版本号,构建后去掉版本号又能找到了。所以这里有个坑。

原因分析:

​ 已经使用了spring-boot-starter-parent作为parent,不能再有另一个parent。所以对于Spring Cloud的依赖管理,只能通过import scope来导入。

​ 总结:Spring Boot starters不需要指定版本是因为版本管理通过parent继承,而Spring Cloud组件需要通过import scope导入BOM来进行版本管理。

scope说明
compile默认值。表示依赖在编译、测试、运行阶段都可用。这类依赖会被打包进最终的 artifact(如 JAR/WAR)。
provided表示依赖在编译和测试时需要,但在运行时由容器或 JDK 提供,不会被打包。典型场景:Servlet API、JSP API 等。
runtime表示依赖在编译阶段不需要,但在运行和测试时需要。例如 JDBC 驱动,编译时仅通过接口编程,运行时才需要具体实现。
test表示依赖仅在测试编译和执行时有效,不会传递到其他模块,也不会被打包。例如 JUnit、Mockito。
system类似 provided,但依赖不是从 Maven 仓库获取,而是通过 <systemPath> 显式指定本地文件路径。通常用于引入系统路径中的 JAR,不推荐使用。
import仅用于 <dependencyManagement> 中,且 type 必须为 pom。它表示将目标 POM 的 <dependencyManagement> 内容合并到当前项目的依赖管理中,常用于统一管理多个模块的版本(如 Spring Boot BOM、Spring Cloud BOM)。

Maven注意事项

  • 一个项目不能直接依赖parent模块,例如
<?xml version="1.0" encoding="UTF-8"?>
<project>
    <parent>
        <artifactId>java-study</artifactId>
        <groupId>psn.wyl</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>http-ota</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId> <!--这里直接依赖行不通-->
            <version>2.7.18</version>
            <scope>import</scope>
        </dependency>
    </dependencies>
</project>
  • 解决方法
<?xml version="1.0" encoding="UTF-8"?>
<project>
    <parent>
        <artifactId>java-study</artifactId>
        <groupId>psn.wyl</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>http-ota</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId> <!--不依赖parent-->
            <version>2.7.18</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

坑2

父pom

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis.plus.version}</version>
        </dependency>
        <!-- 缺少 mybatis-plus-core 的版本管理 -->
    </dependencies>
</dependencyManagement>
子pom
<dependencies>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-core</artifactId>
        </dependency>
</dependencies>

这时候,项目是拿不到mybatis-plus-core依赖包的

只有这种写法可以

<dependencies>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
    </dependency>
</dependencies>

如果已经有还想依赖spirngboot

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.7.18</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
        <version>2.9.11</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type><!--BOM 文件以 POM 形式存在的,它不包含实际的代码,只包含了各种依赖的版本声明-->
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Spring+Ai

ChatClient和ChatModel

ChatModel常用的消息发到LLM,LLM返回

ChatClient,基于ChatModel构建,支持prompt,格式输出,参数交互,聊天记忆,工具调用,RAG

ChatClient

不支持自动注入,必须手动注入

@Bean
public ChatClient chatClient(ChatModel chatModel) {
    return ChatClient.builder(chatModel).build();
}

或者

@RestController
public class ChatClientController {
    private final ChatClient chatClient;

    public ChatClientController(ChatModel chatModel){
        this.chatClient = ChatClient.builder(chatModel).build();
    }
    @GetMapping("/chat/client")
    public Flux<String> chat2(@RequestParam(value = "message") String message) {
        return chatClient.prompt().user(message).stream().content();
    }
}

ollama本地部署

下载ollama

https://ollama.com

直接下载按照,ollama网站选模型就可以了用的是

ollama run qwen3.5:4b

ollama的指令和docker差不多,可以借鉴一下docker

run之后直接就能用了,ollama提供了一个对话界面窗口。

提示词(prompt)

System,User

Springcloud相关

一个完整的后端SpirngCloud项目大致如下:

maven项目:

​ 网关模块:gateway 做路由,验证等功能

​ 各种服务模块:xxx-service 各个服务之间一般通过OpenFeign远程调用

​ 以及公共模块:common

这个maven项目的pom.xml文件打包方式为pom,其他的模块打包方式为jar,服务模块可以打包成war部署在tomcat之类的服务器。

maven pom.xml文件做依赖管理

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.heima</groupId>
    <artifactId>hmall</artifactId>
    <packaging>pom</packaging>
    <version>1.0.0</version>
    <modules>
        <module>item-service</module>
        <module>gateway</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.12</version>
        <relativePath/>
    </parent>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <org.projectlombok.version>1.18.20</org.projectlombok.version>
        <spring-cloud.version>2021.0.3</spring-cloud.version>
        <spring-cloud-alibaba.version>2021.0.4.0</spring-cloud-alibaba.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
        <mysql.version>8.0.23</mysql.version>
    </properties>

    <!-- 对依赖包进行管理 -->
    <dependencyManagement>
        <dependencies>
            <!--spring cloud-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!--spring cloud alibaba-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- 数据库驱动包管理 -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>
            <!-- mybatis plus 管理 -->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <!-- lombok 管理 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${org.projectlombok.version}</version>
        </dependency>
        <!--单元测试-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.8.1</version>
                    <configuration>
                        <source>17</source> <!-- depending on your project -->
                        <target>17</target> <!-- depending on your project -->
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

微服务拆分之后,项目的之间使用通常有两种:

1.提供远程调用的服务自己去管理对外的远程接口,新建一个独立的子模块专门存放对外的远程调用。其他需要调用的客户端引入这个模块即可实现远程调用

2.一个独立的api模块。所有需要远程调用的模块都引入这个模块。

示例

  • hmall
    	- user-service 用户模块
    		- user-api 用户模块下api模块
    			- dto  api模块下的dto
    			- vo   api模块下的vo
    			- client  api模块下的远程调用接口
    		- user-biz 用户模块下的业务功能
    		
    	- card-service
    		- card-api 购物车模块下api模块
    			- dto  api模块下的dto
    			- vo   api模块下的vo
    			- client  api模块下的远程调用接口
    		- card-biz 购物车模块下的业务功能
    		
    	- item-service
    		- item-api 商品模块下api模块
    			- dto  api模块下的dto
    			- vo   api模块下的vo
    			- client  api模块下的远程调用接口
    		- item-biz 商品模块下的业务功能
    	- gateway  网关
    
  • hmall
    	- user-service 	用户模块
    		- domain
    			- dto
    			- vo
    			- po
    			- ...
    		- mapper
    		- service
    		- controller
    		- ...
    	- card-service 	购物车模块
    	- item-service 	商品模块
    	- gateway	   	网关
    	- openfeign-api 远程服务模块
    		- dto
    		- vo
    		- client
    

一般来说推荐第一种,各模块的耦合度没有那么高。

依赖引入

网关也是微服务,也需要注册到nacos

需要引入gateway依赖

需要引入loadbalancer依赖,用于负载均衡

<dependencies>
    <!--nacos 服务注册发现-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId>
    </dependency>
</dependencies>

启动类

需要开启服务发现

@SpringBootApplication
@EnableDiscoveryClient // 启用服务发现客户端
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

配置文件

spring:
  application:
    name: api-gateway  #服务名称
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 # Nacos 服务器地址
    gateway:
      routes:
        - id: item-service
          uri: lb://item-service # 使用lb负载均衡
          # uri: http://localhost:8081 # 不使用负载均衡
          predicates:
            - Path=/items/**
		  filters:
		  	- RewritePath= 
        - id: card-service
          uri: lb://card-service # 使用lb负载均衡
          predicates:
            - Path=/card/**

配置文件解析:

  • spirng.gateway.routes:

    每个 - 后面都是一个路由。

    id: 为这个路由的编号,具有唯一性。

    uri: 为要访问的服务器地址,可以写死,如果使用了负载均衡必须要引入nacos依赖和loadbalancer依赖。

    predicates: 断言,匹配路径 - Path=/items/**,意思是如果是这个通配符下的路径全部负载均衡到上面uri配置的服务地址。

    # 1. Path 路径匹配:匹配以 /api/ 开头的请求
    - Path=/api/**
    
    # 2. Method 方法匹配:匹配 GET 或 POST 请求
    - Method=GET, POST
    
    # 3. Header 请求头匹配:请求头必须包含 X-Request-Id,且值符合正则表达式
    - Header=X-Request-Id, \d+
    
    # 4. Cookie 匹配:请求必须包含名为 loginId 的 cookie,其值符合正则表达式
    - Cookie=loginId, .+
    
    # 5. Host 主机名匹配:匹配 Host 为 `**.example.com` 的请求
    - Host=**.example.com
    
    # 6. Query 参数匹配:请求必须包含名为 `token` 的查询参数,值可选(也可以像Header一样指定值正则)
    - Query=token
    # - Query=token, abc.  # 表示必须有token参数,且值匹配正则 `abc.`
    
    # 7. After/Before/Between 时间匹配(基于ZonedDateTime)
    - After=2023-01-20T17:42:47.789-07:00[America/Denver]
    - Before=2023-01-21T17:42:47.789-07:00[America/Denver]
    - Between=2023-01-20T17:42:47.789-07:00[America/Denver], 2023-01-21T17:42:47.789-07:00[America/Denver]
    
    # 8. RemoteAddr 远程地址匹配:匹配来自指定IP段(CIDR表示法)的请求
    - RemoteAddr=192.168.1.1/24
    

    filters: 过滤,可以直接放行一些路径比如swagger文档之类的

    # 1. Path 重写:将请求路径中的 `/user-service` 替换成空
    # 原始请求:/user-service/api/users -> 转发后的请求:/api/users
    - RewritePath=/user-service/?(?<segment>.*), /$\{segment}
    # 2. 添加请求头:在转发前添加一个请求头 `X-Gateway-Flag: true`
    - AddRequestHeader=X-Gateway-Flag, true
    
    # 3. 添加响应头:在收到下游响应后,给客户端响应添加一个头 `X-Response-From: gateway`
    - AddResponseHeader=X-Response-From, gateway
    
    # 4. 剥离路径前缀:去掉路径的前面一部分(例如这里去掉1级路径)
    # 原始请求:/api/user/1 -> 转发后的请求:/user/1
    - StripPrefix=1
    
    # 5. 请求参数处理:添加一个参数 `source=gateway`
    - AddRequestParameter=source, gateway
    
    # 6. 熔断器(Hystrix/Resilience4j):集成熔断,提供降级能力
    - name: Hystrix
      args:
        name: fallbackcmd
        fallbackUri: forward:/fallback # 熔断时转发到网关内的 /fallback 端点
    
    # 7. 重试机制:对特定情况(如5xx错误)进行重试
    - name: Retry
      args:
        retries: 3
        statuses: BAD_GATEWAY, INTERNAL_SERVER_ERROR # 遇到这些状态码才重试
        methods: GET # 只对GET方法重试
    
    # 8. 请求体缓存(通常用于需要读取请求体的过滤器,如修改请求体)
    - CacheRequestBody=REQUEST_BODY_CACHE
    
    # 9. 修改请求路径:简单直接地设置新路径
    - SetPath=/api/v2/$\{segment} # 需要与其他提取参数的Predicate/Filter配合使用
    # 10. 限流(RequestRateLimiter):通常配合Redis使用,基于令牌桶算法
    - name: RequestRateLimiter
      args:
        redis-rate-limiter.replenishRate: 10 # 每秒允许的请求数
        redis-rate-limiter.burstCapacity: 20 # 每秒最大处理的请求数(突发流量)
        key-resolver: "#{@userKeyResolver}" # 指定限流键的解析器Bean(例如按用户、IP限流)
    

SSM相关

SpringMVC

1.入口控制:通过DispatcherServlet作为入口控制器,负责接收请求和分发请求。自己写Servlet需要再web.xml中配置。通过请求路径确定分发到指定的地方。

2.参数直接绑定对象。前端参数直接可以通过DTO直接绑定对象数据。

3.ioc容器,不需要自己创建对象。

4.提供了拦截器,异常处理器。

5.支持识图解析。

执行流程

从浏览器再回到浏览器。

  1. 前端访问一个路径
  2. 通过一系列的过滤器链(Filter),过滤器执行顺序与过滤器在servlet容器注册顺序决定。
  3. 来到DispatcherServlet,通过httpServletRequest拿到访问路径(uri),通过路径获取要对应的执行器(controller)。
  4. 拦截到达执行器(controller)前的拦截,preHandle拦截器开始作用。
  5. controller处理业务,DispatcherServlet调用 ViewResolver视图解析器拿到前端上报的数据,也可以通过HttpServletRequest拿到前端请求携带的数据。
  6. 拦截处理完的结果,postHandler执行。
  7. 渲染视图,结合thymeleat之类的视图框架将各种再ModelAndView或者respose中的数据,解析并且放到html文件。
  8. 后置拦截器afterCompletion负责清理各种这次请求产生的资源和日志。
  9. 再次通过,过滤器链(Filter),过滤器执行顺序与过滤器在servlet容器注册顺序决定。
  10. 回到浏览器。

模板引擎

前后端不分离,使用后端直接整合前端css,js,html。一般使用模板引擎框架,有以下技术栈:

jsp(需要引入额外依赖),thymeleaf,FreeMarker,或者直接写原生。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

如果没有引进thymeleaf依赖。springboot也支持访问静态资源。

/static
/public
/resources
/META-INF/resources

但是访问不到

/templates/index.html 
MqttClient mqttClient;

设置主题回调(所有主题都走这个接口实现)

mqttClient.setCallback(new MqttAllTopicCallback());

public class MqttAllTopicCallback implements MqttCallback{

}

设置主题回调(规定什么主题走什么接口实现)

mqttClient.subscribe("sys/#", 1,new MqttRuleListner()); //规定sys/#主题走MqttRuleListner
public class MqttRuleListner implements IMqttMessageListener{
	
}
//注意这种方式不能走共享订阅,$queue和$share

Timer

Timer timer = new Timer();
timer.schedule(new TimerTask(){
    @Override
    public void run(){
        System.out.println("执行完成");
    }
},1000);//一秒钟后执行一次

明确一点,是MP还是仅仅mybatis。

MP

直接添加一个bean进去就可以了,3.4.0以上版本使用MybatisPlusInterceptor,3.4.0以下使用PaginationInterceptor。

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 添加分页插件,指定数据库类型为 MySQL
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
}
  • 使用
@GetMapping("/list")
public Result<IPage<SysTenant>> list(@RequestParam(defaultValue = "1") Long pageNum,
                                         @RequestParam(defaultValue = "10") Long pageSize) {
    Page<SysTenant> page = new Page<>(pageNum, pageSize);
    IPage<SysTenant> tenantPage = sysTenantService.page(page);
    return Result.success(tenantPage);
}

mybatis

必须要引进PageHelper分页插件

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper</artifactId>
    <version>5.3.2</version>
</dependency>
  • 配置类
@Configuration
public class MyBatisConfig {
    @Bean
    public PageInterceptor pageInterceptor() {
        PageInterceptor pageInterceptor = new PageInterceptor();
        Properties properties = new Properties();
        // 数据库
        properties.setProperty("helperDialect", "mysql");
        // 合理化分页(页码<1 查第一页,>总页数 查最后一页)
        properties.setProperty("reasonable", "true");
        pageInterceptor.setProperties(properties);
        return pageInterceptor;
    }
}
  • 使用
// 第一行:开启分页(紧跟在查询前面!)
PageHelper.startPage(1, 10);

// 第二行:正常查询
List<User> list = userMapper.selectList();

// 包装成分页结果
PageInfo<User> pageInfo = new PageInfo<>(list);
mybatis-plus:
  mapper-locations: classpath*:mapper/**/*.xml #所有的mapper文件,所有包含的模块 
  type-aliases-package: s.iot.**.entity #所有的包别名
  configuration:
  	log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: s.iot.core.entity,s.iot.system.entity #所有的包别名
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

classpath 只扫描当前模块的类路径;

classpath* 会扫描所有依赖 JAR 包及当前模块中匹配的路径(即全模块扫描)。

配置包扫描

@MapperScan("s.iot.**.mapper")   // 扫描所有模块的 mapper 包

1. SPU(Standard Product Unit,标准产品单元)

  • 定义:描述一类商品的标准化信息,不包含具体的销售属性(如颜色、尺寸)。
  • 特点:它代表“商品系列”或“产品款式”,同一SPU下的商品具有相同的基本属性(如品牌、型号、功能)。
  • 举例:iPhone 15 Pro Max 是一个 SPU。它不区分颜色和内存,只代表这个型号。

2. SKU(Stock Keeping Unit,库存量单位)

  • 定义:描述一个具体可销售的商品,包含了所有销售属性(如颜色、尺寸、版本)。
  • 特点:它代表“具体的库存单品”,每个SKU对应唯一的条码或编码,用于库存、价格、订单管理。
  • 举例:iPhone 15 Pro Max,黑色,512GB 就是一个 SKU。

关系与区别

维度SPUSKU
含义商品系列/款式具体单品
是否区分颜色/尺寸不区分区分
库存管理不用于库存用于精确库存
举例“华为P50手机”“华为P50,白色,8+256GB”
编码通常无唯一码或共用码每个SKU有唯一条码

简单记忆:

  • SPU = 商品的“模板”或“型号”
  • SKU = 货架上的“具体哪一件”

认证流程

1.从登录接口执行认证:登录接口中创建UsernamePasswordAuthenticationToken

使用流程(最简单)

这是最简单的方式,基于cookie和session的方式,用security原生表单登录。

  • POM文件
<!-- springboot父工程 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
</parent>

<dependencies>
    <!-- 实体类参数校验 -->
	<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
    <!--security起步依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
    <!--token生成-->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
    </dependency>
</dependencies>
  • 实现UserDetailsService接口
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = new User("admin",passwordEncoder.encode("123456"), Collections.emptyList()); //这里第三个参数不能传null
        return user;
    }
}
  • 配置加密方式
@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

使用流程2

  • 实现UserDetailsService接口
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = new User("admin",passwordEncoder.encode("123456"), Collections.emptyList()); //这里第三个参数不能传null
        return user;
    }
}
  • 配置类
@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { //springboot自动装配配置了这个AuthenticationConfiguration,使得创建这个AuthenticationManager,@Bean时能注入进来
    	return authConfig.getAuthenticationManager();
	}
}
  • 自定义登录接口
@PostMapping("/login")
public String login(@RequestBody LoginRequest loginRequest) {
    // 执行认证
    Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
            )
    );

    // 从认证结果中提取 UserDetails
    UserDetails userDetails = (UserDetails) authentication.getPrincipal();
    return jwtUtil.generateToken(userDetails);
}

Security+JWT

  • 登录认证,生成token返回给前端,token包含了实现UserDetails的LoginUser信息

    • 认证,UsernamePasswordAuthenticationToken会将当前前端传递过来的用户名密码设置为一个认证token放到认证上下文setContext(authenticationToken)。
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    AuthenticationContextHolder.setContext(authenticationToken);
    
    • 校验,AuthenticationManager的authenticate()方法会调用UserDetailsServiceImpl实现的loadUserByUsername,判断这次登录的账号密码是否正确。
    • 缓存登录信息,校验成功之后把用户信息放入到缓存。
    • 生成token,jwt工具类将使用实现UserDetails的LoginUser生成token。
  • 后续访问

    • 携带token,token认证过滤器JwtAuthenticationFilter中拿到token
    • 拿到token中LoginUser信息,判断缓存是否有该信息。
    • UsernamePasswordAuthenticationToken生成认证信息设置到认证上下文SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    

JWT 工具类

创建一个工具类,用于生成和解析 JWT:

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    // 从 token 中提取用户名
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 提取过期时间
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // 生成 token
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        // 可以添加额外信息,比如角色
        claims.put("roles", userDetails.getAuthorities());
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    // 验证 token
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

在 application.yml 中配置密钥和过期时间:

jwt:
  secret: "your-256-bit-secret-key-here-must-be-long-enough"
  expiration: 3600000   # 1小时,单位毫秒

自定义 JWT 认证过滤器

创建一个过滤器,继承 OncePerRequestFilter,对每个请求进行 Token 校验:

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        // 提取 Bearer Token
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtUtil.extractUsername(jwt);
        }

        // 如果有用户名且当前未认证
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(jwt, userDetails)) {
                // 创建认证对象
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // 设置到安全上下文
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}

Spring Security 配置

配置 SecurityFilterChain,禁用 Session 和 CSRF,添加自定义过滤器,并设置权限规则:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // 禁用 CSRF(无状态 API)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()   // 登录、注册接口放行
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

登录接口

创建一个控制器,接收用户名密码,认证成功后返回 JWT:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @PostMapping("/login")
    public String login(@RequestBody LoginRequest loginRequest) {
        // 1. 认证authenticationManager.authenticate方法会调用UserDetailsService的实现做密码校验
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                loginRequest.getUsername(),
                loginRequest.getPassword()
            )
        );

        // 2. 认证成功后生成 token
        UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername());
        String token = jwtUtil.generateToken(userDetails);

        return token;
    }
}

// 简单请求体
class LoginRequest {
    private String username;
    private String password;
    // getters/setters
}

跨域

@Bean
public CorsFilter corsFilter() {
    CorsConfiguration config = new CorsConfiguration();
    config.addAllowedOriginPattern("*");
    config.setAllowCredentials(true);
    config.addAllowedMethod("*");
    config.addAllowedHeader("*");
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    return new CorsFilter(source);
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
            .csrf().disable()
            .cors().disable()
           .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests()
            .antMatchers("/auth/**").permitAll()
            .antMatchers("/menu/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}

其实就是注册bean,通过配置文件的方式注册bean到spring容器中。

在 Spring Boot 2.7 及更高版本中, META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件取代了旧版 spring.factories 中的自动装配配置,成为了注册自动配置类的标准方式。

com.wyl.common.redis.configure.RedisConfig
com.wyl.common.redis.service.RedisService

多个模块的项目下,如果引入其他模块的bean,这个bean由于是其他模块的,包不一样,比如smart-system模块引入smart-core模块下的一个RedisConfig。这时候由于springboot包扫码扫码不到这个对象,不会初始化这个bean,就需要引入自动装配配置。

1. JDBC基本使用

1.1 引入必要依赖

<dependencies>
   <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-core</artifactId>
        <version>3.3.2</version> <!-- 更高的mp版本依赖于spring框架,这里只能用3.3.2 -->
    </dependency>
        <!-- sqlite-jdbc依赖 -->
    <dependency>
        <groupId>org.xerial</groupId>
        <artifactId>sqlite-jdbc</artifactId>
        <version>3.49.1.0</version>
    </dependency>
    
        <!--mysql连接 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    
    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.42</version>
    </dependency>
    
    <!-- druid数据库连接池-->
	<dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.28</version>
    </dependency>
</dependencies>

注意点:

  • mybatis-plus-core核心依赖的版本不能太高,高版本还依赖部分spring相关功能。
  • sqlite-jdbc依赖

1.2 使用

  • 按照jdbc的标准,首先要配置数据源。
//数据源连接地址。sqlite其实就是路径,例:D://te.db
// 或者内存数据库"jdbc:sqlite::memory:";//直接往内存中读写数据。
private static String dbUrl = "jdbc:sqlite:test.db"; 

//sqlite数据源 实现了DataSource接口,一般jdbc数据源实现这个接口,mysql也是
SQLiteDataSource dataSource = new SQLiteDataSource(); 
dataSource.setUrl(dbUrl);
  • 接下来就是jdbc部分的内容。不管数据源是什么玩意,只要你实现了我jdbc的接口规范。就能使用jdbc操作数据库,接下来编写测试。

    • 注意:

      Statement,每次执行都编译,❌ 不支持占位符,❌ 易受攻击,适用场景DDL、静态 SQL。

      PreparedStatement,预编译,可复用,✅ 支持 ? 占位符,✅ 防止注入,适用场景DML、DQL、动态参数。

public class SqliteDemo001 {
    private static String dbUrl = "jdbc:sqlite:test.db";
    private static SQLiteDataSource dataSource = new SQLiteDataSource();

    public static void main(String[] args) {
        dataSource.setUrl(dbUrl);

    }
    private static void createTable() throws Exception{
        String sql = "CREATE TABLE IF NOT EXISTS user (" +
                "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                "name TEXT NOT NULL, " +
                "age INTEGER, " +
                "email TEXT, " +
                "deleted INTEGER DEFAULT 0" +
                ")";
        //建立jdbc连接,这一步是与数据库建立连接
        Connection connection = dataSource.getConnection();
        //设置自动提交为false,表示不自动提交,自己处理事务,只对本次连接
        connection.setAutoCommit(false);
        //创建statement对象,用于执行sql语句
        Statement statement = connection.createStatement();
        //执行sql语句
        boolean execute = statement.execute(sql);
        //提交事务,不提交不会执行这一次的事务
        connection.commit();
        //关闭连接 自动提交
        statement.close();
        connection.close();
    }
    private static void insertData() throws Exception{
        String sql = "INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com')";

        //获取连接
        Connection connection = dataSource.getConnection();
        connection.setAutoCommit(false);
        PreparedStatement statement = connection.prepareStatement(sql);
        int i = statement.executeUpdate();
        System.out.println("插入了" + i + "行数据");
        //提交事务,不提交不会执行这一次的事务
        connection.commit();
        //关闭连接
        statement.close();
        connection.close();
    }
}

2. 结合mybatis-plus

新建实体类

@Data
@TableName("user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private Integer age;
    private String email;
    @TableLogic
    private Integer deleted;
}
public class SqliteDemo002 {
    private static String dbUrl = "jdbc:sqlite:test.db";
    private static SQLiteDataSource dataSource = new SQLiteDataSource();
    public static void main(String[] args) throws Exception{
        dataSource.setUrl(dbUrl);

        //mybatis-plus相关配置
        // 事务工厂
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        // 环境配置  "development"参数随便即可
        //一般用于区分不同环境(开发、测试、生产)
        Environment environment = new Environment("development", transactionFactory, dataSource);

        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setEnvironment(environment);

        //开启驼峰映射user_name->userName
        configuration.setMapUnderscoreToCamelCase(true);

        // 添加Mapper,比较重要,orm映射关系,配置
        // spring框架启动时,会自动扫描mapper接口,会做这个配置,这里我们手写
        configuration.addMapper(UserMapper.class);

        //全局策略配置类,逻辑删除、主键生成策略等。 这3行代码可要可不要。
        GlobalConfig globalConfig = new GlobalConfig();
        globalConfig.setBanner(false); //启动mp时,不打印mp启动信息
        configuration.setGlobalConfig(globalConfig);//关联全局策略

        // 创建 SqlSessionFactory,这个是操作数据库的工厂类
        MybatisSqlSessionFactoryBuilder builder = new MybatisSqlSessionFactoryBuilder();
        SqlSessionFactory sqlSessionFactory = builder.build(configuration);
        //从工厂中创建连接器
        SqlSession sqlSession = sqlSessionFactory.openSession();

        // 获取Mapper
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        List<User> users = userMapper.selectAll();
        System.out.println("用户列表: " + users);

//        User user = new User("张三", 20,"lisi@email.com");
//        int insertResult = userMapper.insert(user);
//        System.out.println("插入结果: " + insertResult);

        sqlSession.commit(); // 提交事务
    }
}

3. 连接池

3.1 基本使用

public class SqliteDemo003 {
    private static String dbUrl = "jdbc:sqlite:test.db";
    public static void main(String[] args) throws SQLException {
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(dbUrl);
        druidDataSource.setDriverClassName("org.sqlite.JDBC");
        druidDataSource.setInitialSize(1);// 初始连接数
        druidDataSource.setMaxActive(10);// 最大连接数
        druidDataSource.setMinIdle(1);// 最小连接数
        druidDataSource.setMaxWait(3000);// 最大等待时间3000ms

        druidDataSource.setValidationQuery("select 1");//用来确定是否真正连接成功。以及保持连接
        
        DruidPooledConnection connection = druidDataSource.getConnection();
        PreparedStatement preparedStatement = connection.prepareStatement("select * from `user`");
        ResultSet resultSet = preparedStatement.executeQuery();
        while (resultSet.next()) {
            System.out.println(resultSet.getInt(1) + " " + resultSet.getString(2) + " " + resultSet.getInt(3) + " " + resultSet.getString(4));
        }
        resultSet.close();
        preparedStatement.close();
        connection.close();
    }
}

3.2 数据库连接池+mybatis-plus

public class SqliteDemo004 {
    private static String dbUrl = "jdbc:sqlite:test.db";
    public static void main(String[] args) {
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(dbUrl);
        druidDataSource.setDriverClassName("org.sqlite.JDBC");
        druidDataSource.setInitialSize(1); // 初始连接数
        druidDataSource.setMaxActive(10); // 最大连接数
        druidDataSource.setMinIdle(1); // 最小连接数
        druidDataSource.setMaxWait(3000); // 最大等待时间3000ms
        druidDataSource.setValidationQuery("select 1"); // 验证

        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        // 环境配置  "development"参数随便即可
        //一般用于区分不同环境(开发、测试、生产)
        Environment environment = new Environment("development", transactionFactory, druidDataSource);

        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setEnvironment(environment);

        //开启驼峰映射user_name->userName
        configuration.setMapUnderscoreToCamelCase(true);

        // 添加Mapper,比较重要,orm映射关系,配置
        // spring框架启动时,会自动扫描mapper接口,会做这个配置
        configuration.addMapper(UserMapper.class);

        //全局策略配置类,逻辑删除、主键生成策略等。
        GlobalConfig globalConfig = new GlobalConfig();
        globalConfig.setBanner(false);
        configuration.setGlobalConfig(globalConfig);//关联全局策略

        // 创建 SqlSessionFactory,这个是操作数据库的工厂类
        MybatisSqlSessionFactoryBuilder builder = new MybatisSqlSessionFactoryBuilder();
        SqlSessionFactory sqlSessionFactory = builder.build(configuration);
        //从工厂中创建连接器
        SqlSession sqlSession = sqlSessionFactory.openSession();

        // 获取Mapper
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

        while(true){
            List<User> users = userMapper.selectAll();
            System.out.println("用户列表: " + users);
            sqlSession.commit();
            Thread.sleep(1000); // 休眠1秒
        }
    }
}

多数据源

import com.alibaba.druid.pool.DruidDataSource;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.TransactionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.ibatis.mapping.Environment;

import java.util.List;

public class SqliteDemo004 {
    // 两个数据库的URL
    private static final String DB_URL1 = "jdbc:sqlite:test.db";
    private static final String DB_URL2 = "jdbc:sqlite:test2.db";

    public static void main(String[] args) throws Exception {
        // 创建第一个数据源(test.db)
        DruidDataSource dataSource1 = createDataSource(DB_URL1);
        // 创建第二个数据源(test2.db)
        DruidDataSource dataSource2 = createDataSource(DB_URL2);

        // 为第一个数据库创建 SqlSessionFactory
        SqlSessionFactory sqlSessionFactory1 = createSqlSessionFactory(dataSource1, UserMapper.class);
        // 为第二个数据库创建 SqlSessionFactory
        SqlSessionFactory sqlSessionFactory2 = createSqlSessionFactory(dataSource2, UserMapper.class);

        // 循环查询两个数据库
        while (true) {
            // 查询第一个数据库
            try (SqlSession session = sqlSessionFactory1.openSession()) {
                UserMapper mapper = session.getMapper(UserMapper.class);
                List<User> users1 = mapper.selectAll();
                System.out.println("test.db 用户列表: " + users1);
            }

            // 查询第二个数据库
            try (SqlSession session = sqlSessionFactory2.openSession()) {
                UserMapper mapper = session.getMapper(UserMapper.class);
                List<User> users2 = mapper.selectAll();
                System.out.println("test2.db 用户列表: " + users2);
            }

            Thread.sleep(1000); // 每秒查询一次
        }
    }

    /**
     * 创建并配置 Druid 数据源
     */
    private static DruidDataSource createDataSource(String url) {
        DruidDataSource dataSource = new DruidDataSource();
        dataSource.setUrl(url);
        dataSource.setDriverClassName("org.sqlite.JDBC");
        dataSource.setInitialSize(1);
        dataSource.setMaxActive(10);
        dataSource.setMinIdle(1);
        dataSource.setMaxWait(3000);
        dataSource.setValidationQuery("select 1");
        return dataSource;
    }

    /**
     * 构建 SqlSessionFactory
     */
    private static SqlSessionFactory createSqlSessionFactory(DruidDataSource dataSource, Class<?> mapperClass) {
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        Environment environment = new Environment("development", transactionFactory, dataSource);

        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setEnvironment(environment);
        configuration.setMapUnderscoreToCamelCase(true);
        configuration.addMapper(mapperClass);

        GlobalConfig globalConfig = new GlobalConfig();
        globalConfig.setBanner(false);
        configuration.setGlobalConfig(globalConfig);

        MybatisSqlSessionFactoryBuilder builder = new MybatisSqlSessionFactoryBuilder();
        return builder.build(configuration);
    }
}

主要是讲解SSE使用和websocket使用

WebSocket

独立协议ws,wss,可以发送文本和二进制。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

STOMP协议

类似订阅发布模式,通过主题可以广播,点对点。

  • 核心配置类
@Configuration
@EnableWebSocketMessageBroker // 启用WebSocket消息代理
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {

    // 注册STOMP端点
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 客户端通过 "/ws" 路径连接WebSocket服务器
        registry.addEndpoint("/ws") // 端点
                .setAllowedOriginPatterns("*") // 允许跨域,生产环境请指定具体域名
                .withSockJS(); // 启用SockJS作为备用方案,以支持不支持WebSocket的旧浏览器
    }

    // 配置消息代理
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 设置消息代理的前缀,客户端订阅的地址需以此开头,如 /topic/public
        registry.enableSimpleBroker("/topic");
        // 设置应用程序前缀,客户端发送消息的地址需以此开头,如 /app/send
        registry.setApplicationDestinationPrefixes("/app");
    }
}
  • 业务控制器
@Controller
public class WebSocketController {

    // 前端发送消息至 /app/sendMessage,将自动路由至此方法
    @MessageMapping("/sendMessage")
    // 方法返回值将被发送给所有订阅了 /topic/public 的客户端
    @SendTo("/topic/public")
    public String processMessage(String message) throws Exception {
        // 你可以在这里进行消息处理,例如存储到数据库
        return "服务端回显: " + message;
    }
}
WebSocketHandler (原始处理器)

比较灵活,需要自定义双方数据交换模式

  • 配置类
@ServerEndpoint("/websocket")
public class WebSocketServer {
 
    @OnOpen
    public void onOpen(Session session) {
        System.out.println("Connection opened: " + session.getId());
        sessions.add(session);
    }
 
    @OnMessage
    public void onMessage(Session session, String message) throws IOException {
        System.out.println("Received message: " + message);
        session.getBasicRemote().sendText("Server received: " + message);
    }
 
    @OnClose
    public void onClose(Session session) {
        System.out.println("Connection closed: " + session.getId());
        sessions.remove(session);
    }
 
    private static final Set<Session> sessions = Collections.synchronizedSet(new HashSet<Session>());
}
  • 启用配置
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new WebSocketServer(), "/websocket").setAllowedOrigins("*");
    }
}

SSE

比较轻量级的-只有服务端往客户端发送数据的一种协议,基于http,发送文本。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • SseEmitter单体
@RestController
public class SseController {
    private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();

    @GetMapping("/sse/connect/{userId}")
    public SseEmitter connect(@PathVariable String userId) {
        SseEmitter emitter = new SseEmitter(60_000L); // 超时时间60秒
        emitterMap.put(userId, emitter);

        // 连接关闭或超时后的清理
        emitter.onCompletion(() -> emitterMap.remove(userId));
        emitter.onTimeout(() -> emitterMap.remove(userId));
        emitter.onError(e -> emitterMap.remove(userId));

        // 发送初始连接成功消息(可选)
        try {
            emitter.send(SseEmitter.event().name("init").data("connected"));
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
        return emitter;
    }

    // 主动推送数据
    public void pushToUser(String userId, Object data) {
        SseEmitter emitter = emitterMap.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event().name("message").data(data));
            } catch (IOException e) {
                // 发送失败表示连接已断开,移除
                emitterMap.remove(userId);
            }
        }
    }
}
  • 分布式

保证分布式sse稳定运行。

连接:当用户携带唯一id连接sse时,负载均衡到一个后端业务服务器进行连接,连接完成之后,将用户信息和服务器ID存入redis。

消息推送到前端:定时推送,定时服务实例拿到需要推送的sse连接。通过openfein调用指定的后端业务服务器,实现消息精确发送。

@Component
public class SseManager {
    private final Map<String, SseEmitter> localEmitters = new ConcurrentHashMap<>();
    private final RedisTemplate<String, String> redisTemplate;
    private final String instanceId;

    public SseManager(RedisTemplate<String, String> redisTemplate, 
                      @Value("${instance.id}") String instanceId) {
        this.redisTemplate = redisTemplate;
        this.instanceId = instanceId;
    }

    public SseEmitter connect(String userId) {
        SseEmitter emitter = new SseEmitter(120_000L);
        localEmitters.put(userId, emitter);
        redisTemplate.opsForHash().put("sse:user:node", userId, instanceId);
        redisTemplate.expire("sse:user:node", Duration.ofMinutes(2));

        emitter.onCompletion(() -> {
            localEmitters.remove(userId);
            redisTemplate.opsForHash().delete("sse:user:node", userId);
        });
        emitter.onTimeout(() -> {
            localEmitters.remove(userId);
            redisTemplate.opsForHash().delete("sse:user:node", userId);
        });
        return emitter;
    }

    public boolean pushToUser(String userId, Object data) {
        String targetInstance = (String) redisTemplate.opsForHash().get("sse:user:node", userId);
        if (targetInstance == null) return false;
        if (targetInstance.equals(instanceId)) {
            SseEmitter emitter = localEmitters.get(userId);
            if (emitter != null) {
                try {
                    emitter.send(data);
                    return true;
                } catch (IOException e) {
                    localEmitters.remove(userId);
                    redisTemplate.opsForHash().delete("sse:user:node", userId);
                }
            }
        } else {
            // 远程调用目标实例的推送接口(如 RestTemplate)
            callRemoteInstance(targetInstance, userId, data);
        }
        return false;
    }

    @Scheduled(fixedRate = 30000)
    public void heartbeat() {
        localEmitters.forEach((userId, emitter) -> {
            try {
                emitter.send(":heartbeat\n\n");
            } catch (IOException e) {
                localEmitters.remove(userId);
                redisTemplate.opsForHash().delete("sse:user:node", userId);
            }
        });
    }
}

静态代理

这个Proxyer代理类需要用户手动去创建,一般来说一个需要代理的类一个Proxyer。

AOP之类的都用到了动态代理

  • 目标类接口
public interface UserService{
	String getUsername(int id);
}
  • 目标类具体实现
public class UserServiceImpl implements UserService{
	public String getUsername(int id){
		System.out.println("张三:"+id);
		return "张三:"+id;
	}
}
  • 代理类
public class Proxyer{
	private final UserService userService;
	public Proxyer(UserService userService){
		this.userService = userService;
	}
	public String getUsername(int id){
		System.out.println("目标方法执行--------前");
		String username =userService.getUsername(id);
		System.out.println("目标方法执行--------后");
		return username;
	}
}
  • 测试类
public class Test {
    public static void main(String[] args) {
        UserService userService = new UserServiceImpl();//直接将具体实现传入如果要代理这个接口的其他实现,也可以new其他的实现传入
        Proxyer proxyer = new Proxyer(userService);
        proxyer.getUserName(1);
    }
}

动态代理

  • 目标类接口
与上面静态代理的一样
  • 目标类具体实现
与上面静态代理的一样
  • 代理类
public class MyInvocationHandler implements InvocationHandler {
	private final Object target;

	public MyInvocationHandler(Object target) {
		this.target = target;
	}

	@Override
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		System.out.println("目标方法执行--------前");
        Object invoke = method.invoke(target, args);
        System.out.println("目标方法执行--------后");
        return invoke;
	}
}
  • 测试类
public class Test {
	public static void main(String[] args) throws Exception {
		UserService target = new UserServiceImpl();
		MyInvocationHandler handler = new MyInvocationHandler(target);
		UserService proxy = (UserService) Proxy.newProxyInstance(
				target.getClass().getClassLoader(),
				target.getClass().getInterfaces(),
				handler);
		// 调用目标方法
		proxy.getUserName(1);
	}
}

CGLib动态代理

  • 目标类对象
public class UserService {
    public String getUser(int id) {
        return "wyl:"+id;
    }
}
  • 拦截器
public class UserServiceInterceptor implements MethodInterceptor {

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("目标方法执行--------前");
        Object invoke = methodProxy.invokeSuper(o, objects);
        System.out.println("目标方法执行--------后");
        return invoke;
    }
}
  • 测试
public class CglibProxyer {
    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(UserService.class);
        enhancer.setCallback(new UserServiceInterceptor());
        UserService userService = (UserService) enhancer.create();
        System.out.println(userService.getUser(1));
    }
}
1. smart-common (通用模块)
   
   - 定位 :系统的基石,存放所有模块共用的工具类和基础定义。
   - 包含内容 :
     - 通用工具类 ( DateUtils , StringUtils , IdUtils )
     - 全局异常处理 ( GlobalExceptionHandler )
     - 统一响应对象 ( AjaxResult , R )
     - 基础实体类 ( BaseEntity )
     - 常量与枚举 ( Constants , Enums )
2. smart-framework (框架核心模块)
   
   - 定位 :配置层,整合第三方框架和系统级配置。
   - 包含内容 :
     - Spring Security / Shiro 权限配置
     - WebMvc 配置 (拦截器、跨域设置)
     - MyBatis Plus / JPA 配置
     - Redis 缓存配置
     - Swagger / Knife4j 接口文档配置
3. smart-system (系统管理模块)
   
   - 定位 :负责平台的基础管理功能(参考 RuoYi 或 JetLinks 的系统管理)。
   - 包含内容 :
     - 用户管理 (User)、角色管理 (Role)、菜单管理 (Menu)
     - 部门/组织架构 (Dept)
     - 系统日志 (Log)、字典管理 (Dict)
     - 通知公告
4. smart-module (业务模块)
   
5. smart-admin (启动模块 / Web 入口)
   
   - 定位 :项目的聚合点和启动入口。
   - 包含内容 :
     - Spring Boot 启动类 ( StartApplication )
     - application.yml 核心配置文件
     - Controller 层(如果希望业务逻辑更内聚,Controller 也可以放在各自的模块中,这里只做聚合打包)。

反射使用

java.lang.reflect

获取Class对象的三种方式

public class Demo001 {
    public static void main(String[] args) {
        Class s = Student.class; //方式1
        System.out.println(s.getName());
        System.out.println(s.getSimpleName());
        
        Class c2 = Class.forName("reflect.Student"); //方式2
        System.out.println(c2.getName());
        
        System.out.println(c1==c2); //返回true,说明是同一个对象
        
        Student s = new Student();//方式3
        Class c3 = s.getClass();
        System.out.println(c3==c2); //返回true,也是同一个对象
    }
}

为什么三种对象创建的方式创建出来的对象都是同一个呢?

Class 对象

​ 在 JVM 中,每个类在整个生命周期中只有一个 Class 对象。只有一份字节码。

类加载器 (ClassLoader) → 加载类 → 创建唯一的 Class 对象 → 缓存到 JVM
堆内存 (Heap):
┌─────────────────────────┐
│   Class 对象 (唯一)     │ ← c1, c2, c3 都指向这里
│  - 类名:reflect.Student│
│  - 字段信息             │
│  - 方法信息             │
│  - 构造函数信息         │
└─────────────────────────┘
          ↑
          │ c1, c2, c3 都指向同一个地址

实例对象

Student s1 = new Student();
Student s2 = new Student();
Student s3 = new Student();

这3种都不是同一个对象。

类构造器(构造方法)

Class c1 = Student.class;
Constructor[] constructors = c1.getConstructors();//获取全部构造方法 只能拿public构造方法
Constructor[] declaredConstructors = c1.getDeclaredConstructors();//获取全部构造方法
Constructor ct1 = c1.getConstructor(String.class, int.class); //获取指定参数的构造方法
Constructor ct2 = c1.getDeclaredConstructor(String.class, int.class); //获取指定参数的构造方法

通过类构造器初始化对象

 Constructor constructor = c1.getConstructor(String.class, int.class); //获取指定参数的构造方法
Constructor constructor2 = c1.getDeclaredConstructor(String.class, int.class); //获取指定参数的构造方法

Student s1 = (Student) constructor.newInstance("张三", 18);

Constructor cs4 = c1.getDeclaredConstructor();
cs4.setAccessible( true); //设置访问权限可以访问private构造方法
Student s2 = (Student) cs4.newInstance();

属性

Class c1 = Student.class;
Field[] fields = c1.getFields();//只能拿public字段
for (Field field : fields) {
    System.out.println(field.getName()+"--"+field.getType());
}
Field[] declaredFields = c1.getDeclaredFields();//获取全部字段
for (Field field : declaredFields) {
    System.out.println(field.getName()+"--"+field.getType());
}
//通过名字定位
//        Field name = c1.getField("name"); //拿不到private
//        System.out.println(name.getName()+"--"+name.getType());

Field age = c1.getDeclaredField("age");
System.out.println(age.getName()+"--"+age.getType());

        //为实例对象赋值
Student s = new Student("11",1);
age.setAccessible(true);
age.set(s,18);
System.out.println(s.getAge());

//取值
int ageValue = (int) age.get(s);
System.out.println(ageValue);

方法

public static void testGetMethod() throws Exception {
        Class c1 = Student.class;
        Method[] methods = c1.getMethods();//获取全部方法 只能拿public方法
        for (Method method : methods) {
            System.out.println(method.getName()+"--"+method.getParameterCount() +"---"+method.getReturnType());
        }
        Method[] declaredMethods = c1.getDeclaredMethods();//获取全部方法
        for (Method method : declaredMethods) {
            System.out.println(method.getName()+"--"+method.getReturnType());
        }
        Method getAge1 = c1.getMethod("getAge"); //获取指定参数的构造方法,只能拿public方法
        Method getAge2 = c1.getDeclaredMethod("getAge"); //获取指定参数的构造方法

        Method setAge1 = c1.getDeclaredMethod("setAge", int.class);
        //方法执行
        Student s = new Student("11",1);

        setAge1.invoke(s,18);
        System.out.println(s.getAge());
        Method show = c1.getDeclaredMethod("show");
        show.setAccessible(true);
        show.invoke(s);
}

反射作用

  • 得到一个类的全部成分,然后操作。

  • 破坏封装性,直接可以调用私有的方法。

  • 主要是在java框架使用

注解使用

​ java里的特殊标记,让其他程序根据注解信息来决定怎么执行该程序。

​ 注解本质是一个接口,继承于Annotation接口。

​ 使用@注解的过程就是创建一个实现类对象。

元注解

修饰注解的注解

  • @Target

    @Target(ElementType.TYOE)
    1.TYPE 类,接口
    2.FIELD 成员变量
    3.METHOD 成员方法
    4.PARAMETER 方法参数
    5.CONSTRUCTOR 构造方法
    6.LOCAL_VARIABLE 局部变量
    
  • @Retention 声明注解的保留周期

    1.SOURCE 只在源码阶段,字节码文件不存在
    2.CLASS 默认值,保留到字节码,运行阶段不存在
    3.RUNTIME 常用,保留到运行阶段
    

注解解析

  • ​ 要解析哪个类上面的注解,就要先拿到他。
  • ​ 先拿到Class对象,再解析上面的注解
  • ​ 拿到方法Method对象,在解析上面的注解。
  • ​ 由于Class,Method,Field,Constructor都实现了AnnotatedElement接口,所以都有解析能力。

测试

创建4个注解

import java.lang.annotation.*;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ClassAnnotationTest {
    /**
     * 权限标识(如:system:user:list)
     */
    String value();
    /**
     * 描述
     */
    String description() default "";
}
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldAnnotationTest {
    String value();
}

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MethodAnnotationTest {
    String value();
}

import java.lang.annotation.*;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ParameterAnnotationTest {
    String value();
}

创建使用注解的类

@ClassAnnotationTest(value = "admin",description = "测试")
public class AnnotationTest {
    @FieldAnnotationTest("x前缀-")
    private String name;
    private int age;
    @MethodAnnotationTest("system:user:list")
    public void login(){
        System.out.println("登录成功");
    }
    public void test(@ParameterAnnotationTest("参数前缀-") String name){
        System.out.println(name);
    }
}

运行测试类

public class Demo01 {
    //注解解析
    public static void main(String[] args) throws Exception {
//        getClassAnnotation();

//        getMethodAnnotation();

//        getFieldAnnotation();

        getParameterAnnotation();
    }


    public static void getParameterAnnotation() throws Exception {
        Class demo01Class = AnnotationTest.class;
        Method method = demo01Class.getDeclaredMethod("test", String.class);
        ParameterAnnotationTest annotation = method.getParameters()[0].getAnnotation(ParameterAnnotationTest.class);
        System.out.println(annotation.value());
    }

    /**
     * 获取属性注解
     */
    public static void getFieldAnnotation() throws NoSuchFieldException {
        Class demo01Class = AnnotationTest.class;
        //拿到属性全部注解
        Field[] fields = demo01Class.getDeclaredFields();
        for (Field field : fields) {
            System.out.println(field.getName());
        }
        Field name = demo01Class.getDeclaredField("name");
        //拿到属性上面全部注解
        if (name.isAnnotationPresent(FieldAnnotationTest.class)) {
            FieldAnnotationTest annotation = name.getAnnotation(FieldAnnotationTest.class);
            System.out.println(annotation.value());
        }
    }
    /**
     * 获取方法注解
     */
    public static void getMethodAnnotation() throws NoSuchMethodException {
        Class demo01Class = AnnotationTest.class;

        Method method = demo01Class.getDeclaredMethod("login");
        //拿到方法上面全部注解
        Annotation[] annotations = method.getDeclaredAnnotations();
        for (Annotation annotation : annotations) {
            System.out.println(annotation);
        }

        if(method.isAnnotationPresent(MethodAnnotationTest.class)){
            MethodAnnotationTest annotation = method.getAnnotation(MethodAnnotationTest.class);
            System.out.println(annotation.value());
        }
    }
    public static void getClassAnnotation() {
        Class demo01Class = AnnotationTest.class;
        //拿到类上面全部注解
        Annotation[] annotations = demo01Class.getDeclaredAnnotations();
        for (Annotation annotation : annotations) {
            System.out.println(annotation);
        }

        //判断注解是否存在
        boolean annotationPresent = demo01Class.isAnnotationPresent(ClassAnnotationTest.class);
        System.out.println(annotationPresent);
        if(annotationPresent){
            //拿到指定注解
            ClassAnnotationTest checkPermission = (ClassAnnotationTest) demo01Class.getDeclaredAnnotation(ClassAnnotationTest.class);
            System.out.println(checkPermission.value());
            System.out.println(checkPermission.description());
        }
    }
}

模仿Junit框架

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyTest {
    String value() default "";
}
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class AnnotationTest {
    @MyTest
    public void test1(){
        System.out.println("test1");
    }
    @MyTest
    public void test2(){
        System.out.println("test2");
    }
//    @MyTest
    public void test3(){
        System.out.println("test3");
    }

    public static void main(String[] args) throws Exception {
        AnnotationTest annotationTest = new AnnotationTest();
        Class demo01Class = AnnotationTest.class;
        //拿到所有方法
        Method[] methods = demo01Class.getDeclaredMethods();
        for (Method method : methods) {
            //判断方法是否有@MyTest注解
            if(method.isAnnotationPresent(MyTest.class)){
                //执行方法
                method.invoke(annotationTest);
            }
        }
    }
}

鉴权

结合前面学的反射和注解实现一个简单的鉴权框架

①注解

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Perms {
    /**
     * 权限标识
     */
    String value();
}

②数据库

public class RolePermsDBService {
    /*假设从数据库拿到角色权限*/
    public String[] getRolePerms(){
        String[] rolePerms = {"dept:add","dept:find"};
        return rolePerms;
    }
}

③使用注解的对象

public class DeptController {
    @Perms("dept:add")
    public void addDept(){
        System.out.println("添加部门");
    }
    @Perms("dept:update")
    public void updateDept(){
        System.out.println("修改部门");
    }
    @Perms("dept:delete")
    public void deleteDept(){
        System.out.println("删除部门");
    }

    @Perms("dept:find")
    public void findDept(){
        System.out.println("查询部门");
    }
}

④反射处理对象

public class DeptControllerBean {
    DeptController deptController = new DeptController();
    Class demo01Class = DeptController.class;
    private List<String> rolePerms;

    private DeptControllerBean(){
        //初始化时获取数据库中的权限
        RolePermsDBService rolePermsService = new RolePermsDBService();
        rolePerms = Arrays.stream(rolePermsService.getRolePerms()).toList();
    }
    private static final DeptControllerBean instance = new DeptControllerBean();

    public static DeptControllerBean getInstance(){
        return instance;
    }
    private void invoke(String methodName) {
        try{
            Method addDept = demo01Class.getDeclaredMethod(methodName);
            //判断权限
            Perms annotation = addDept.getAnnotation(Perms.class);
            if(annotation!=null){//存在注解
                if (rolePerms.contains(annotation.value())) {
                    addDept.invoke(deptController);
                    return;
                }else{
                    System.out.println("没有权限");
                    return;
                }
            }else{ //不存在注解
                addDept.invoke(deptController);
            }
            addDept.invoke(deptController);
        }catch (Exception e){
            System.out.println("addDept方法不存在");
        }
    }
    public void addDept(){
        invoke("addDept");
    }

    public void updateDept(){
        invoke("updateDept");
    }
    public void deleteDept(){
        invoke("deleteDept");
    }
    public void findDept(){
        invoke("findDept");
    }
}

⑤启动类

public class RunApplication {
    public static void main(String[] args) {
        DeptControllerBean instance = DeptControllerBean.getInstance();
        instance.addDept();
        instance.updateDept();
        instance.deleteDept();
        instance.findDept();
    }
}

步骤

  • 确定springboot版本,这个很重要,微服务项目springcloud和spirngboot版本有一定关系。
  • 确认JDK版本,springboot和jDK版本有关系
  • springcloud版本确认,通过maven版本管理引入spirngcloud。spring-cloud-dependencies
  • 引入spring-cloud-alibaba-dependencies微服务组件的作用,alibaba有更好的配置中心nacos,熔断限流Sentinel,更好的消息中间件MQ。
  • 子项目中就能直接使用spirngcloud相关的组件,比如alibaba的nacos,springboot本来就支持的gateway,loadbanlace等组件,而且子项目引入相关依赖不需要指定版本。
  • 设置打包项目,版本

顶层父pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>psn</groupId>
    <artifactId>smart-iot</artifactId>
    <version>1.0</version>
    <modules>
        <module>smart-gateway</module>
    </modules>
    <packaging>pom</packaging>

    <!-- 确认springboot版本 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.6</version>
    </parent>

    <!-- 配置项目版本属性 -->
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <mysql.version>8.0.33</mysql.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
        <lombok.version>1.18.20</lombok.version>
        <spring.cloud.version>2024.0.0</spring.cloud.version>
        <alibaba.cloud.version>2023.0.3.2</alibaba.cloud.version>
    </properties>
    <!-- 配置依赖管理 -->
    <dependencyManagement>
        <dependencies>
            <!-- mysql -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>

            <!-- mybatis-plus -->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>

            <!-- spring-cloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring.cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${alibaba.cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <!--所有子项目共有依赖-->
    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
        </dependency>
    </dependencies>

    <!--打包插件-->
    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.8.1</version>
                    <configuration>
                        <source>${maven.compiler.source}</source>
                        <target>${maven.compiler.target}</target>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

子模块pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>smart-iot</artifactId>
        <groupId>psn</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>smart-gateway</artifactId>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>
    <dependencies>
        <!--nacos 注册发现 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--gateway-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!-- 负载均衡 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
    </dependencies>

    <!-- 打包springboot项目 -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Linux common

  • 快捷方式

    • 文件夹
    ln -s /root/abc ./abc/
    
    • 文件
    ln -s /root/.bashrc ./.bashrc
    

    -s : 软链接,不输入这个就是硬链接

    • 删除,直接删文件或者文件夹的链接(不要删源文件)
  • useradd

基本语法:sudo useradd [选项] <用户名>

常用选项:

选项说明重要程度
-m自动创建用户的家目录(如 /home/用户名)强烈推荐
-s指定用户登录后使用的Shell,通常为 /bin/bash强烈推荐
-d自定义家目录的路径可选
-G指定附加用户组,多个组用逗号分隔可选
-r创建一个系统用户(通常用于运行服务,无家目录)特殊用途
find [搜索路径] [表达式]
  • 搜索所有目录下的a-*.txt
find -name "a-*.txt"
  • 查找当前目录下,不区分大小写
find . -iname "a-*.txt"

scp

​ 两个主机之间通过网络复制文件(注意有时候不用-i)

//将当前目录下的a.txt文件复制到192.168.1.101主机的/root目录
scp -i ./a.txt root@192.168.1.101:/root

//将主机192.168.1.101的/root/b.txt文件复制到当前主机的当前目录
scp -i root@192.168.1.101:/root/b.txt ./

报错不允许ssh-rsa

scp -o HostKeyAlgorithms=+ssh-rsa ./a.txt root@192.168.1.101:/root

sshpass(注意有时候不用-i)

sudo apt update
sudo apt install sshpass

//将当前目录下的a.txt文件复制到192.168.1.101主机的/root目录    并且做密码校验
sshpass -p passwrod scp -i ./a.txt root@192.168.1.101:/root
//将主机192.168.1.101的/root/b.txt文件复制到当前主机的当前目录   并且做密码校验
sshpass -p password scp -i root@192.168.1.101:/root/a.txt ./

screen

​ linux创建后台终端命令神器,通过这个命令可以后台启动一个终端,常用于一些后台命令,比如内网穿透工具 frp

// 创建一个名为frp的后台终端并且进入这个终端中
screen -S frp
// 按住ctrl 然后按a 再按d 退出当前的终端。(ps在主终端也能用,会直接断开远程连接)

//查看已经有的screen 终端 (这个命令可以知道是不是在某个终端中)
screen -ls 

screen-ls1

Attached: 你当前正在该screen会话内。
Detached: 你在其他地方,或者没有附加到此 screen 会话。
R (Detached): 分离状态,并且可能无法恢复。
无状态信息: 该 screen 会话的状态不明,极大概率已经结束。
Dead: 极罕见, screen session 已经退出, 但可能残留了一些信息。
//进入一个终端
screen -r frp

脚本第一个行必须是 #!/bin/bash或者 #!/bin/sh

#! 是一个约定的标记,它告诉系统这个脚本需要什么解释器来执行,即使用哪一种 Shell。

变量

定义
name="wyl"  #注释,注意等号两边最好不要添加空格
#和其他语言定义类似,
使用
name="wyl"
age=18

echo $name
echo ${name} #加花括号主要是边界问题比如
echo "my name is ${name}"

echo $age
只读变量
name="wyl"
readonly age=18

echo "my name is ${name}"
name="lisi"
echo "my name is ${name}"
echo $age

编辑 SSH 配置文件:

sudo vim /etc/ssh/sshd_config

找到并修改:

#PermitRootLogin prohibit-password

改为:

PermitRootLogin yes

重启 SSH 服务:

sudo systemctl restart ssh

/etc/rc.local

在这个文件下添加要启动的文件就好,注意要加&符号后台启动

#延迟3s后执行
sleep 3 #3s
#建议先给权限
chmod 755 /home/root/lvgl/lvgl_demo

/home/root/lvgl/lvgl_demo &

使用 nmcli (最推荐)

这是最现代、通用的方法,适用于大多数Linux发行版。

  1. 查看并开启无线功能:

    nmcli radio wifi on   # 开启无线功能[reference:2]
    nmcli dev wifi list   # 扫描可用WiFi网络[reference:3][reference:4]
    
  2. 连接网络(将SSID和PASSWORD替换为实际值):

    sudo nmcli dev wifi connect "SSID" password "PASSWORD"
    
    • 如果需要指定接口,可添加 ifname wlan0。
    • 连接成功后,系统会自动保存配置,下次可自动连接。

使用 wpa_supplicant (通用性强)

  1. 生成配置文件(将SSID和PASSWORD替换):

    wpa_passphrase "SSID" "PASSWORD" | sudo tee /etc/wpa_supplicant.conf
    

    /etc/wpa_supplication.conf文件

    network={
    	ssid="wifi名称"
    	psk="密码"
    }
    
  2. 连接并获取IP(假设接口为wlan0):

    # 1. 确保网卡已启用
    sudo ip link set wlan0 up
    
    # 2. 后台运行 wpa_supplicant 连接(-B 表示后台)
    sudo wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant.conf
    
    # 3. 获取 IP 地址(重点:两个命令试其一)
    sudo dhclient wlan0   # 如果是常见发行版
    # 或者
    sudo udhcpc -i wlan0  # 如果是 BusyBox/嵌入式系统(如 OpenWrt)
    

Linux 解压与压缩专题(常见文件格式)

文件格式解压命令压缩命令说明
.tartar -xvf file.tartar -cvf archive.tar file/仅打包,不压缩
.tar.gz / .tgztar -xzvf file.tar.gz 或 tar -xzvf file.tgztar -czvf archive.tar.gz file/gzip 压缩
.tar.bz2 / .tbz2tar -xjvf file.tar.bz2 或 tar -xjvf file.tbz2tar -cjvf archive.tar.bz2 file/bzip2 压缩(压缩率更高)
.tar.xztar -xJvf file.tar.xztar -cJvf archive.tar.xz file/xz 压缩(压缩率高,较慢)
.tar.Ztar -xZvf file.tar.Ztar -cZvf archive.tar.Z file/古老的 compress 压缩
.gzgunzip file.gz 或 gzip -d file.gzgzip file (原文件会被替换) 保留原文件:gzip -c file > file.gz单文件压缩
.bz2bunzip2 file.bz2 或 bzip2 -d file.bz2bzip2 file (原文件会被替换) 保留原文件:bzip2 -c file > file.bz2单文件压缩
.xzunxz file.xz 或 xz -d file.xzxz file (原文件会被替换) 保留原文件:xz -c file > file.xz单文件压缩
.zipunzip file.zipzip -r archive.zip file/常见跨平台格式,可保留目录结构
.rarunrar x file.rar (需安装 unrar) rar x file.rar (需安装 rar)rar a archive.rar file/ (需安装 rar)商业压缩格式,Linux 需额外软件
.7z7z x file.7z (需安装 p7zip)7z a archive.7z file/高压缩率,开源
.lzlzip -d file.lzlzip -c file > file.lzLZMA 算法,类似 xz
.lz4lz4 -d file.lz4lz4 file (原文件保留在 file.lz4)极快速压缩
.zstunzstd file.zst 或 zstd -d file.zstzstd file (原文件保留) 压缩目录:先 tar 再 zstdZstandard,高压缩比+高速

使用 Netplan (适用于Ubuntu 18.04及以后版本)

这是Ubuntu官方推荐的配置方式。

  1. 编辑配置文件: /etc/netplan/ 目录下的文件名可能不同(如 01-netcfg.yaml, 50-cloud-init.yaml),建议先查看目录下的文件。

    bash

    sudo vi /etc/netplan/01-netcfg.yaml
    

配置

network:
  ethernets:
    ens33:                #网卡名
      dhcp4: false        #no/false  yes/true     关闭DHCP
      addresses: [192.168.1.100/24]
      gateway4: 192.168.1.1
      nameservers:
        addresses: [8.8.8.8, 114.114.114.114]
  version: 2
  renderer: networkd   # 服务器环境用networkd,桌面版可用NetworkManager[reference:21]
  

应用

sudo netplan apply

语法检测

sudo netplan try

重启

sudo systemctl restart systemd-networkd

验证

ip addr show ens33

创建

  1. 检查现状 确认当前无Swap,并确保有足够磁盘空间。
swapon --show
df -h /
  1. 创建Swap文件 创建大小为2GB的Swap文件。
fallocate -l 2G /swap 
  1. 第2步,有些文件系统不支持fallocate指令,使用dd以下命令 2048为2g
dd if=/dev/zero of=/swap bs=1M count=2048 status=progress
  1. 设置权限 锁定文件权限,防止被随意读取。
chmod 600 /swap
  1. 格式化 将文件标记为交换空间格式。
mkswap /swap
  1. 立即启用 激活Swap。
swapon /swap
  1. 验证启用 检查Swap是否成功启用并查看大小。
swapon --show
free -h
  1. 永久生效 配置系统启动时自动挂载此Swap。
echo '/swap none swap sw 0 0' | sudo tee -a /etc/fstab
  1. 设置交换积极性
echo 'vm.swappiness=60' | sudo tee -a /etc/sysctl.conf
  1. 确定交互积极性
sysctl -p

移除Swap

  1. 查看当前状态 确认正在使用的Swap文件路径。
sudo swapon --show
  1. 停用Swap 关闭所有Swap交换空间。
sudo swapoff /swap
  1. 验证已停用 确认Swap已完全关闭。
sudo swapon --show
free -h
  1. 删除自动挂载 从系统配置中移除Swap,使其开机不启动。
打开文件 vim /etc/fstab  
删除这行 /swap none swap sw 0 0
  1. 删除Swap文件 永久删除磁盘上的文件以释放空间。
rm /swap
  1. (可选)清理内核参数 移除之前调整的swappiness优化设置。
打开文件 vim /etc/sysctl.conf
修改vm.swappiness=0
  1. 重启生效
sysctl -p

方式一

ubuntu系统下,环境变量的文件在/etc/profile修改完成之后执行source /etc/profile命令。

vim /etc/profile
export PATH=$PATH:/root/embedded/gcc-arm-none-eabi/bin
source /etc/profile

如果直接暴露 export PATH=$PATH:/root/embedded/sdcc/bin,针对的是每次ssh连接,ssh连接断开之后会删除。

方式二

1.编辑用户目录下的 .bashrc 文件

vim ~/.bashrc

2.在文件末尾添加下面这行:

export PATH=$PATH:/root/embedded/sdcc/bin

3.配置立即生效

source ~/.bashrc

Mqtt相关

version: '3'
services:
  emqx:
    image: emqx/emqx:5.8.6
    container_name: emqx-mqtt
    ports:
      - "1886:1883"    # MQTT 端口
      - "8886:8883"    # MQTT over SSL
      - "8086:8083"    # WebSocket 端口
      - "18086:18083"  # Web 管理后台端口
      - "8087:8084"    # WebSocket SSL 端口
      - "18087:18084"  # Dashboard SSL 端口
    environment:
      - EMQX_ALLOW_ANONYMOUS=off
      - EMQX_HTTP_BASIC_AUTH_USERNAME=admin
      - EMQX_HTTP_BASIC_AUTH_PASSWORD=YH_6688
      - EMQX_DASHBOARD__DEFAULT_PASSWORD=YH_6688
      - EMQX_NAME=emqx
      - EMQX_HOST=localhost
      # ---------- MQTT over SSL (8883) ----------
      - EMQX_LISTENERS__SSL__DEFAULT__BIND=8883
      - EMQX_LISTENERS__SSL__DEFAULT__SSL_OPTIONS__CERTFILE=/etc/emqx/certs/fullchain.pem
      - EMQX_LISTENERS__SSL__DEFAULT__SSL_OPTIONS__KEYFILE=/etc/emqx/certs/privkey.pem
      # WebSocket SSL 配置
      - EMQX_LISTENERS__WSS__DEFAULT__BIND=8084
      - EMQX_LISTENERS__WSS__DEFAULT__SSL_OPTIONS__CERTFILE=/etc/emqx/certs/fullchain.pem
      - EMQX_LISTENERS__WSS__DEFAULT__SSL_OPTIONS__KEYFILE=/etc/emqx/certs/privkey.pem
      # Dashboard HTTPS 配置
      - EMQX_DASHBOARD__LISTENERS__HTTPS__DEFAULT__BIND=18084
      - EMQX_DASHBOARD__LISTENERS__HTTPS__DEFAULT__SSL__CERTFILE=/etc/emqx/certs/fullchain.pem
      - EMQX_DASHBOARD__LISTENERS__HTTPS__DEFAULT__SSL__KEYFILE=/etc/emqx/certs/privkey.pem
    volumes:
      - ./emqx.conf:/etc/emqx/emqx.conf
      - ./emqx_data:/opt/emqx/data
      - ./certs:/etc/emqx/certs   # 挂载证书目录
    restart: unless-stopped
MqttClient mqttClient;

设置主题回调(所有主题都走这个接口实现)

mqttClient.setCallback(new MqttAllTopicCallback());

public class MqttAllTopicCallback implements MqttCallback{

}

设置主题回调(规定什么主题走什么接口实现)

mqttClient.subscribe("sys/#", 1,new MqttRuleListner()); //规定sys/#主题走MqttRuleListner
public class MqttRuleListner implements IMqttMessageListener{
	
}
//注意这种方式不能走共享订阅,$queue和$share

Mqtt基于TCP协议每次信息收发都会有完成的mqtt协议内容,包括:主题,qos状态等等内容。

Mqtt3个对象:

​ 发布者、broker(代理)、订阅者

broker作为中心,负责消息的调度。发布者订阅者连接到broker,broker负责监听二者在线状态,二者通过连接协议中的心跳时间作为连接后的离线判断。发布者发送消息到主题:将该条消息发到broker。broker负责信息推送给其他的订阅者。如果有多个订阅者订阅了这条主题,broker会向这些订阅者推送信息。

Qos 0

根据报文可知,当发送者向broker(代理)时,broker是否收到。或者broker向订阅者发送信息,订阅者是否收到都不关心。

qos0

Qos 1

qos1

QOS 2

qos2

单主题方式

  • json方式上报

​ 设备上报所有内容都走一个主题,这个主题中的内容通过json体里面的code值进行判断要执行操作类型

{"code":400,"status":1}//code 为 400,设备心跳
  • 16进制Hex方式上报

​ 这种一般用于透传,一些485设备,比如温湿度传感器,振动传感器等通信设备使用的协议直接是16进制的方式。这些模块连接wifi透传或者4G透传进行数据上报和采集。通常后端使用定时器进行定时采样,下发的是16进制指令。

多主题方式

Opencv相关

下载

https://opencv.org/releases/

文档

https://docs.opencv.org/4.12.0/

安装配置系统环境

Path 添加 D:\Program Files\opencv\build\x64\vc16\bin

vs使用

选择项目—>属性—>VC++目录—>包含目录—>编辑

001

002

项目—>属性—>链接器—>输入—>附加依赖项—>编辑
  • opencv_world4120.lib:Release 版本,用于最终发布的程序。
  • opencv_world4120d.lib:Debug 版本,用于开发调试阶段。(只需要填这个就好)

003

测试使用

#include <opencv2\opencv.hpp> 
#include <iostream>

using namespace std;
using namespace cv;

int main()
{
	Mat img;
	img = imread("D:/111.png");
	if (img.empty())
	{
		cout << "null" << endl;
		return 0;
	}
	imshow("test", img);
	waitKey(0);
	return 0;
}

POI

感兴趣区域,一般用来狂出物体的边界。

Mask

黑白图

HSV:

H——Hue即色相,就是我们平时所说的红、绿,如果你分的更细的话可能还会有洋红、草绿等等;在HSV模型中,用度数来描述色相,其中红色对应0度,绿色对应120度,蓝色对应240度。

S——Saturation即饱和度,色彩的深浅度(0-100%) ,对于一种颜色比如红色,我们可以用浅红——大红——深红——红得发紫等等语言来描述它(请原谅一个纯理科生的匮乏的颜色系统),对应在画水彩的时候即一种颜料加上不同分量的水形成不同的饱和度。

V——Value即色调,纯度,色彩的亮度(0-100%) ,这个在调整屏幕亮度的时候比较常见。

差值计算

对于突然出现的物品做出处理,

  • MOG2

  • KNN

背景差分

目标跟踪

移动路径

二维码识别

人脸识别

1

特征工程

特征提取

windows安装py和pip

安装yolo

pip install -U ultralytics
C:\Users\24346>yolo --version
WARNING argument '--version' does not require leading dashes '--', updating to 'version'.
8.4.40

使用测试yolo

命令行输入

yolo predict model=yolov8n.pt source="图片路径" project="结果路径" name="结果名称"
  • model标识模型:这个模型文件必须要和打开的命令行存放路径一致。如果没有会主动去拉取文件模型文件。

  • source:图片路径

  • project:结果路径

  • name:输出的文件名

yolo能这些模型能识别80多种常见的物品

使用摄像头

0代表0号摄像头,会在命令行打印识别到的内容

yolo predict model=yolov8n.pt source="0"

实时显示画面

yolo predict model=yolov8n.pt source="0" show=True

参数

model指定模型文件路径model=yolo11n.pt
source指定输入源(图像、视频、文件夹等)source=test.jpg
data指定数据集配置文件(.yaml)data=coco8.yaml
epochs训练轮数epochs=100
imgsz输入图像尺寸imgsz=640
batch批量大小batch=16
conf置信度阈值conf=0.25
device指定运算设备device=cpu 或 device=0
format导出模型格式format=onnx

模型微调 数据微调

  1. 安装环境

    pip install ultralytics  # YOLOv8/v11
    # 或
    git clone https://github.com/ultralytics/yolov5
    pip install -r requirements.txt
    
  2. 准备数据集

    • 格式:每张图片对应一个 .txt 标注文件(YOLO格式:class x_center y_center width height,归一化值)。

    • 目录结构示例:

      dataset/
        images/
          train/  # 训练图8/10
          val/    # 验证图2/10
        labels/
          train/  # 训练图对应标注
          val/    # 验证图对应标注
      
    • 创建 data.yaml:

      yaml

      path: ../dataset
      train: images/train
      val: images/val
      nc: 2  # 类别数
      names: ['cat', 'dog']
      

开始训练

yolo task=detect mode=train data=data.yaml model=yolov8n.pt epochs=100 imgsz=640
参数含义常见值
epochs训练轮数100~300
imgsz输入图片尺寸640
batch批量大小根据显存调整(16, 32, 64)
lr0初始学习率0.01(YOLOv5)
deviceGPU编号0(第一块GPU)
patience早停轮数50~100

OTA

接口与单片机连接。

  • 接口是抽象接口,可以是CAN、uart,也可以其他。
  • 接口对应的上位设备,可以是pc上位机、lora模块、4G模块、wifi模块、蓝牙模块等。

上位设备分成透传设备、需进一步协议处理设备(如,AT指令设备),还有支持二度开发的上位设备。

  • 透传设备,单片机直接处理上位设备发过来的内容,这种只需要规定协议格式就好,比如json格式,比如自定义格式(1标头-2字节是长度-3数据)。
  • 协议处理设备,单片机需要进一步解析的设备,比如AT指令,需要解析AT指令之后拿到真正的数据,再度解析真正数据,需要两步解析。AT指令是比较完善的指令,不会出现粘包,少包问题。
  • 二度开发的设备,这个设备通常需要二度开发,其实就是AT指令的本身,相当于封装了一种AT指令,但是不会定义成AT指令,只会模仿。这种设备通常是一个辅助MCU。

升级方式

方式1

​ 设备上报运行时状态才用MQTT,如果涉及到OTA升级,应该尽量使用HTTP的方式,也就是说,单片机的上位设备模块退出MQTT长连接模式,切换成HTTP短连接模式。

流程:接收到mqtt发送过来的升级指令,或者自己比对版本发现差异,主动断开MQTT连接,切换成HTTP方式。分段下载更新,完成之后,重新建立MQTT连接。

方式2

​ 单片机资源少,ram少,而且又没有外挂flash,上位设备不想进行mqtt和http连接切换。只使用MQTT构建一个自定义的可靠报文传输协议。QoS 1(确保消息至少到达一次),并在设备端做幂等处理。

协议规定

OTA升级协议规定。远程升级。

方式1

​ 这个比较简单,只要把文件传输到oss服务器中就行了,当设备请求后端服务器之后①,返回oss下载链接,通过下载链接分块下载内容。对后端服务器开销很小。第①步记录更新开始,单片机更新完成之后再调用后端接口记录更新结束。

单片机端定义结构体,struct upgrader,包含,第几个块blank,长度firmware_size,crc32,

方式2

​ 全程MQTT,这个比较有挑战。需要后端全程接管,每次处理单片机数据都要从oss中分块拿到数据,再封装返回给单片机。

自定义二进制协议(例如 1 字节类型 + 2 字节块号 + 数据)

**json方式:**1.数据块用数组的方式传输。2.使用base64的方式传输。

​ 数据协议规定:

  • 设备端:
  • 服务端:

要求

管理升级包,根据产品型号。分批更新之类的。

设备端HTTP方式进行OTA

​ 类似OSS服务器

设备端MQTT方式进行OTA

​ 实现自定义可靠传输协议

核心名词

针对单片机

  • 内部flash:单片机的内部代码存储器。

  • 外部flash:外挂的flash。SPI NOR Flash的W25q64、I2C EEPROM的AT24C系列。

  • RAM:单片机的运行内存。

单片机升级方式

bootloader + app启动方式,正常情况下上电启动时bootloader负责判断是否要升级app版本。IAP功能对app分区进行擦除和写入。还有一种情况,app区代码在执行时收到更新指令,直接软件复位重启。

  • 单分区不备份:

    适用范围:内部flash很小,没有外挂flash。

    流程:1.bootloader判断进入升级,2.从串口拿到升级的信息,对app内容擦除写入。

    适用场景:基本没什么使用商业场景,风险还大,用来学习boot+app这个概念,一旦没完成完整的升级就会出现死机,所以必须要求有个存储标志位用来判断是否是完整的app代码。读写flash后部分没有占用的分区页。

  • 单分区+备份:

    适用范围:内部flash够大,没有外挂flash,内部flash比代码大小大起码两倍。

    流程:1.bootloader判断进入升级,2.备份当前app代码到备份区,3.从串口拿到升级信息,对app内容擦除写入。

    适用场景:升级不频繁。

  • 外挂flash备份:

    适用范围:这个需要设计电路时添加外置flash存储器。

    流程:1.app运行是判断是不是要进行升级,2.通过串口拿到升级信息写入到外置flash中,并且版本信息也要写入到外置flash,3.写完之后,软件复位,bootloader通过外置flash中版本信息判断是否要升级,4.bootloader从外置flash中拿到升级信息对app内容擦除写入。

    适用场景:ota升级最容易出现问题就是网络,写到外部的flash,保证升级内容完整,再对现有的内容擦除写入更新不容易出错。

  • 外挂flash多备份:

    与"外挂flash备份"基本一致,就是多了几层备份,会备份多个版本。

  • 直接烧录替换:

    这个不是ota,但是有些c端用户设备,可以提供一个上位机软件,一个比较适合用户操作的界面,让用户通过这个软件对设备进行更新升级。而不是用什么stc-icp之类的软件。这个软件会通过串口拿到设备的版本信息,同时软件从服务器中拿到最新版本的升级bin文件。

    软件设计要求:1.一键安装ch340驱动,把ch340驱动放到软件目录下,软件中一个按钮(安装驱动)点击按钮后拉起ch340驱动软件进行安装。2.自动识别串口,要求用户先拔掉与设备连接的usb,识别到当前串口数量和串口名称信息,用户插入设备连接usb之后,判断新增的串口,打开新增的串口,发送握手协议,拿到设备的信息。剩下的就是烧录了。

    boot和app需求:可以做boot和app分区,也可以不做。

  • 内存卡升级:

    适用范围:要求电路设计时规划内存卡插槽。

    流程:1.插上内存卡。2.手动断电重启。3.bootloader判断是否有内存卡,并且内存卡内容是否正确,防止随意的内存卡都修改app分区,如果内存卡数据有问题1s钟后自动启动app分区内容,不做修改。4.内存卡数据正确,擦除和写入app分区。

    上位机软件要求:内存卡数据应该在前部分数据就说明本次版本信息。比如verson+crc+data这种方式写入内存卡。上位机软件写卡流程,1.从服务器拿到版本信息,拿到bin文件。2.写入内存卡,将版本信息和校验和数据等内容烧到内存卡。

Qt c++

引入qss文件并设置qss样式的方式

创建在资源文件中创建blueButton.qss文件。

QPushButton {
    background-color: white;
    color: #2196F3; /* 蓝色文字 */
    border: 2px solid #2196F3; /* 蓝色边框 */
    border-radius: 25px;
    max-height: 50px;
    min-height: 50px;/*固定按钮大小*/
    font-weight: bold;
}

QPushButton:hover {
    background-color: #e7f3ff; /* 淡蓝色背景 */
}

QPushButton:pressed {/*按下时的颜色*/
    background-color: #cce6ff;
}
QPushButton:checked {/*被选中时的颜色*/
    background-color: #cce6ff;
}

QFile qssFile(":/qss/blueButton.qss");
QString styleSheet;
if(qssFile->open(QFile::ReadOnly|QFile::Text))
{
      QTextStream stream(qssFile);
      styleSheet = stream.readAll();
      qssFile->close();
      Debug() << "样式表加载成功:";
 }else{
      qDebug() << "无法打开样式表文件:" << qssFile->errorString();
 }
btn->setStyleSheet(styleSheet);

多个按钮的情况


注意:

必须要将必要文件放放到Gwing相应目录,include放入QtMqtt头文件 lib放入连接文件 bin放入dll文件

链接:QT5.12编译MQTT 5.13图文详细版_qt 5.12.12对应qwt-CSDN博客

mqtt源码

码云:
https://gitee.com/mathematicsX/qtmqtt/tree/5.12.9/
github:
https://github.com/qt/qtmqtt.git

1.拉下来

git clone https://github.com/qt/qtmqtt.git

mqtt01

2.*切换分支

git checkout 5.12.9

3.qt creator 打开这个项目

mqtt02

4.构建

5.构建完成后

**注意区分是debug构建还是release构建。

**如果构建和引用错了,会连接不上mqtt服务器

mqtt03

6.生成文件使用

1.放到qt目录,后面就能直接使用哦

1.在QT安卓目录下的include新建QtMqtt目录,将,mqtt相关头文件放入这个新建的目录。
	举例:"D:/Qt/Qt5.12.9/5.12.9/mingw73_32/include/QtMqtt"
2.将生成上面图片中6个文件放到QT的bin目录。其实只需要.a和.dll文件即可。
	举例:"D:/Qt/Qt5.12.9/5.12.9/mingw73_32/bin"
3.在项目中引用
	## MQTT包含路径(绝对路径)
	INCLUDEPATH += "D:/Qt/Qt5.12.9/5.12.9/mingw73_32/include/QtMqtt"
	## MQTT链接库(绝对路径)
	LIBS += -L"D:/Qt/Qt5.12.9/5.12.9/mingw73_32/bin" -lQt5Mqtt
	
	#include "QtMqtt/qmqttclient.h"

2.使用绝对路径

1.在要使用qt项目的绝对路径下新建include和lib文件夹。
2.将构建完成的include所有.h文件复制到上面新建的include文件
	例如:D:\Qt\build-qtmqtt-Desktop_Qt_5_12_9_MinGW_32_bit-Debug\include
	复制到 要使用的项目的include文件夹
3.复制上面图片中构建完成的6个文件放入lib目录,其实只需要.a和.dll文件即可。
4.在项目中引用
	INCLUDEPATH += $$PWD/include
	LIBS += -L$$PWD/lib -lQt5Mqtt
	
	#include "QtMqtt/qmqttclient.h"

7.导入(在.pro文件中引入)

## MQTT包含路径(绝对路径)
#INCLUDEPATH += "D:/Qt/Qt5.12.9/5.12.9/mingw73_32/include/QtMqtt"
## MQTT链接库(绝对路径)
#LIBS += -L"D:/Qt/Qt5.12.9/5.12.9/mingw73_32/bin" -lQt5Mqtt

# 添加包含路径 (使用相对路径)
INCLUDEPATH += $$PWD/include

## 添加库路径 (使用相对路径)
LIBS += -L$$PWD/lib -lQt5Mqtt

# 添加库路径 (使用相对路径)
#win32:CONFIG(release, debug|release): LIBS += -L$$PWD/lib -lQt5Mqtt
#else:win32:CONFIG(debug, debug|release): LIBS += -L$$PWD/lib -lQt5Mqtt

第一步:

​ 打开qt的cmd终端界面;

打包1

第二步:

​ 找到项目release的地址信息,注意必须是release,选debug打包会很大

打包2

第三步:

​ 执行打包命令

打包3

Rbac和Saas

rbac权限管理框架

最基本的五个表格

  • 用户

  • 角色

  • 权限(菜单)

  • 用户角色

  • 角色权限

设计

用户表(sys_user)

字段名数据类型注释关系&默认
idint主键id递增,不为空
username用户名
password登录密码
create_time创建时间

角色表(sys_role)

字段名数据类型注释关系&默认
id递增,不为空
name
create_time

权限表(菜单sys_menu)

字段名数据类型注释关系&默认
id
name
perm菜单
parent_id父菜单
route前端路由

用户角色表(sys_user_role)

字段名数据类型注释关系&默认
id
user_id
role_id

角色权限表(sys_role_menu)

字段名数据类型注释关系&默认
id
role_id
perms_id

概念

SaaS(Software as a Service,软件即服务)是一种基于云的软件交付模式。在该模式下,服务提供商将应用程序托管在云端,并通过互联网向用户提供服务。用户无需在本地安装和维护软件,只需通过浏览器或客户端访问即可使用相应功能,通常按订阅制或使用量计费。

SaaS 的核心特点包括:

  • 多租户架构:单一软件实例可为多个客户(租户)服务,实现数据与配置的逻辑隔离。
  • 集中部署与维护:应用由服务商统一更新、维护,用户始终使用最新版本。
  • 按需订阅:用户可根据实际需求选择服务套餐,灵活扩展或缩减功能。
  • 跨平台访问:支持通过互联网在各种设备上使用,促进协作与移动办公。

SaaS 模式广泛用于企业管理、客户关系管理、协同办公、人力资源、财务管理等领域,显著降低了企业的IT投入与运维成本。


系统角色

SaaS 平台通常涉及以下三类核心系统角色,分别对应不同的职责与权限范围:

1. 运维系统管理员

  • 职责说明:作为平台的技术运营方或开发团队代表,拥有系统最高权限。
  • 主要权限:
    • 管理所有租户(企业)账户的生命周期,包括创建、修改、停用等;
    • 配置和维护全局系统参数、安全策略、日志监控等;
    • 根据套餐方案为各企业分配可用的功能模块与菜单权限;
    • 处理系统级别的异常与故障。

2. 企业管理员

  • 职责说明:代表一个租户(企业),通常由运维系统管理员在创建该租户时同步初始化生成。
  • 创建流程:
    • 由运维系统管理员创建租户记录,并关联对应套餐;
    • 系统自动在用户表中生成该企业的管理员账户,并分配专属角色;
    • 该角色权限依据所选套餐预先配置,包含相应的菜单与操作权限。
  • 主要权限:
    • 管理本企业内的用户账号,包括创建、编辑、禁用等;
    • 为企业内部用户分配角色,并控制其可访问的功能范围;
    • 查看本企业的使用数据与操作日志。

3. 企业用户

  • 职责说明:隶属于某一特定租户(企业)的终端使用者。
  • 账号来源:
    • 通常由企业管理员创建并赋予角色;
    • 也支持自主申请加入企业,例如申请成为“运营人员”“开发人员”“普通员工”等角色。
  • 权限获取:
    • 用户角色及相关权限由企业管理员或具备审核权限的用户审批后分配;
    • 获得角色后,即可访问其权限范围内的系统功能。

表格

租户表(tenant)
字段名数据类型注释约束
idbigint主键id不为空
namevarchar(50)租户名称
package_idbigint套餐id外键,关联租户套餐表
租户套餐表(tenant_package)
字段名数据类型注释约束
idbigint主键id不为空
menu_idstext菜单id集合存储格式:逗号分隔或JSON数组
菜单表(menu)
字段名数据类型注释约束
idbigint主键id不为空
namevarchar菜单名称
permvarchar权限标识如:sys:user:list
角色表(role)
字段名数据类型注释约束
idbigint主键id不为空
namevarchar角色名称
tenant_idbigint租户id外键,关联租户表
角色菜单关联表(role_menu)
字段名数据类型注释约束
idbigint主键id不为空
menu_idbigint菜单id
role_idbigint角色id

业务流程说明

一、创建租户

  1. 选择套餐 运营人员在前端为待创建租户选择一个已配置好的套餐(即 tenant_package 中的一条记录)。

  2. 创建租户管理员账号 系统自动创建一个用户账号,该账号默认为当前租户的管理员。

  3. 创建租户管理员角色 系统为当前租户新增一条角色记录,角色名称建议为“租户管理员”或“{租户名称}_admin”,并关联 tenant_id。

  4. 初始化角色菜单权限 根据所选套餐中的 menu_ids(菜单ID集合),系统批量将菜单与角色的关联关系插入 role_menu 表,完成管理员角色的权限初始化。

    说明:租户管理员角色的权限来源于租户套餐,通过角色与菜单的关联实现,为后续权限扩展(如新增子角色)奠定基础。


二、修改套餐配置

  1. 更新套餐定义 运营人员修改 tenant_package 表中的 menu_ids 字段内容(例如新增或移除某些菜单ID)。
  2. 查询受影响的租户 系统查询所有 tenant 表中 package_id 指向该套餐的租户记录。
  3. 遍历租户并同步权限 对每个受影响的租户执行以下操作:
    • 获取该租户的“租户管理员”角色(或所有需要同步的角色)。
    • 获取当前角色的已有菜单权限(从 role_menu 查询)。
    • 将原 menu_ids 与新 menu_ids 进行比对:
      • 新增菜单:将新出现的菜单ID插入 role_menu。
      • 删除菜单:将不再包含的菜单ID从 role_menu 中移除。
    • 提交变更,确保租户管理员权限与套餐定义一致。

三、用户登录与权限加载

  1. 用户登录认证 用户输入账号密码,系统校验身份合法性。

  2. 查询用户角色 根据用户ID关联查询其所属的角色(支持多角色)。

  3. 查询角色菜单权限 根据角色ID集合,关联查询 role_menu 和 menu 表,获取所有授权菜单及权限标识(perm)。

    关键点:权限来源于角色与菜单的关联关系,而非直接关联租户套餐。租户管理员角色的菜单权限已在创建租户时通过套餐初始化至 role_menu,因此登录时只需通过角色即可获取完整权限。

  4. 构建权限数据 系统去重后生成权限标识列表(如 ["sys:user:list", "sys:role:add"])。

  5. 返回前端渲染 将权限列表返回给前端,前端根据权限控制菜单显隐及按钮级别的操作权限。

中间件

Kafka

Scala和java开发的消息中间件

线程之间数据交互

每个线程都有自己的栈内存,但是所有线程共用堆内存。只要把需要共享的数据放到堆内存就可以实现数据交互。在java并发工具包中java.util.concurrent,可以实现线程之间数据共享。

进程之间数据交互

一般通过socket

消息队列的两种模式

点对点模式

消费者主动拉数据,消息收到后清除消息。

producer ----生产---->message queue ----消费---->comsumer---->确认----mq---->mq删除已经消费的消息

发布订阅模式

  • 可以有多个topic主题(浏览,点赞,收藏,评论等)
  • 消费者消费数据之后,不删除数据
  • 每个消费者互相独立,都可以消费到数据

producers----消息---->topic----推送---->comsumers

直接安装

Downloads | Apache Kafka

  • 直接按照下载的版本是3.6.1,需要zookeeper。

  • 解压D:/kafka

  • 在这个目录下新建data目录,以后数据就放在这里。

  • 配置zookeeper

修改
D:/kafka/config/zookeeper.properties
改成
dataDir=D:/kafka/data/zookeeper
  • 启动zookeeper
cd D:/kafka/bin/windows
执行命令
zookeeper-server-start.bat ../../config/zookeeper.properties
  • 配置kafka
修改
D:/kafka/config/server.properties
改成
log.dirs=D:/kafka/data/kafka
  • 启动kafka
cd D:/kafka/bin/windows
执行命令
kafka-server-start.bat ../../config/server.properties

docker安装

services:
  broker:
    image: apache/kafka:latest
    network_mode: "host"
    container_name: broker
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://localhost:9092,CONTROLLER://localhost:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_NUM_PARTITIONS: 3
docker-compose up -d

主题创建、推送、订阅

进入D:\kafka\bin\windows要使用kafka-topics.bat脚本

创建

kafka-topics.bat --bootstrap-server localhost:9092 --topic test --create

查看

kafka-topics.bat --bootstrap-server localhost:9092 --list

详细查看

kafka-topics.bat --bootstrap-server localhost:9092 --topic test --descrip 

修改


删除

kafka-topics.bat --bootstrap-server localhost:9092 --topic test --delete

windows下删除,会出现kafka服务会关闭

订阅

打开两个窗口使用kafka-console-consumer.bat和kafka-console-producer.bat

生产者
kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test

在控制台输入数据后回车

消费者
kafka-console-producer.bat --bootstrap-server localhost:9092 --topic test

生产者

public class Demo001Producer {
    public static void main(String[] args) {
        //创建构建参数
        Map<String,Object> map = new HashMap<>();
        map.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"localhost:9092");
        map.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());//序列化类型
        map.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());//序列化类型

        //创建生产者对象
        KafkaProducer<String,String> producer = new KafkaProducer<>(map);

        //创建数据
        ProducerRecord<String,String> record = new ProducerRecord<>("test", "wyl", "hello kafka");

        //发送数据到kafka-topic
        producer.send(record);

        //关闭生产者对象
        producer.close();
    }
}

消费者

public class Demo001Consumer {
    public static void main(String[] args) {
        //配置map
        Map<String, Object> configs = new HashMap<>();
        configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        configs.put(ConsumerConfig.GROUP_ID_CONFIG, "test"); // 消费者组ID
        configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); // 反序列化类型,字符串类型
        configs.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); // 反序列化类型,字符串类型
        //创建消费者
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(configs);
        consumer.subscribe(Collections.singletonList("test"));
        while (true) {
            consumer.poll(1000).forEach(record -> {
                System.out.println(record.key() + ":" + record.value());
            });
        }
    }
}

POM依赖

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.7.18</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
        <version>2.9.11</version>
    </dependency>
</dependencies>
  • application.yaml文件添加
spring:
  kafka:
    bootstrap-servers: localhost:9092
  • 生产者代码
@Component
public class KafkaProducer {
    @Resource
    private KafkaTemplate<String, String> kafkaTemplate;

    public void sendMessage(String topic, String key, String value) {
        // 创建消息
         ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);
        // 发送消息
         kafkaTemplate.send(record);
    }
}
  • 消费者代码
@Component
public class KafkaConsumer {
    @KafkaListener(topics = "test", groupId = "test-group")
    public void receiveMessage(String message) {
        System.out.println("接收到消息:" + message);
    }
}
  • 生产者生产数据
@RestController
public class TestController {
    @Resource
    private KafkaProducer kafkaProducer;
    @GetMapping("/test")
    public String test() {
        kafkaProducer.sendMessage("test", "test", "test");
        return "hello world";
    }
}

生产环境配置

  • 生产者yaml文件
spring:
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
    producer:
      # 确认机制:all 表示所有副本都写入才确认,最强可靠性
      acks: all
      # 发送失败重试次数,建议大于 0,同时配合 retry.backoff.ms 控制重试间隔
      retries: 10
      # 批量大小,适当调大可提升吞吐(默认 16384)
      batch-size: 16384
      # 发送延迟,为了凑足 batch-size 或时间到达即发送
      linger-ms: 5
      # 缓冲区内存大小
      buffer-memory: 33554432
      # 生产者客户端 ID,用于监控
      client-id: ${spring.application.name}-producer
      # 键值序列化器
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      # 可选:启用事务(如果需要 exactly-once 语义)
      transaction-id-prefix: tx-${spring.application.name}-
    properties:
      # 幂等性(防止重试导致的重复),必须开启(>=0.11)
      enable.idempotence: true
      # 最大请求大小(默认 1MB,如果消息体大需要调大)
      max.request.size: 10485760
  • 生产者
@Component
public class KafkaProducer {
    @Resource
    private KafkaTemplate<String, Object> kafkaTemplate;

    public void sendMessage(String topic, String key, Object value) {
        ProducerRecord<String, Object> record = new ProducerRecord<>(topic, key, value);
        ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(record);
        future.addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {
            @Override
            public void onSuccess(SendResult<String, Object> result) {
                // 记录成功日志或 Metrics
                log.info("消息发送成功 topic={} partition={} offset={}", 
                         result.getRecordMetadata().topic(),
                         result.getRecordMetadata().partition(),
                         result.getRecordMetadata().offset());
            }

            @Override
            public void onFailure(Throwable ex) {
                // 记录失败并告警,必要时进行补偿(如持久化到本地后重试)
                log.error("消息发送失败 topic={} key={}", topic, key, ex);
                // 可触发自定义告警或转存到死信表
            }
        });
    }
}
  • 消费者yaml文件
spring:
  kafka:
    consumer:
      # 消费者组 ID,建议按业务区分
      group-id: ${spring.application.name}-group
      # 自动提交关闭,改为手动提交(保证至少一次)
      enable-auto-commit: false
      # 从何处开始消费:latest / earliest / none
      auto-offset-reset: latest
      # 键值反序列化器
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      # 允许反序列化不信任的包(使用 JSON 时必须)
      properties:
        spring.json.trusted.packages: "*"
      # 每次拉取的最大记录数
      max-poll-records: 50
      # 客户端 ID
      client-id: ${spring.application.name}-consumer
    listener:
      # 手动提交模式:手动调用 Acknowledgment.acknowledge()
      ack-mode: manual
      # 并发消费者数量(对应分区数)
      concurrency: 3
      # 当消费者出现异常时的行为:默认抛出异常停止,可配置重试
    properties:
      # 一次 poll 的最大等待时间(毫秒)
      fetch.max.wait.ms: 500
      # 每次 fetch 的最小数据量(字节)
      fetch.min.bytes: 1024
  • 消费者
@Component
@Slf4j
public class KafkaConsumer {

    @KafkaListener(topics = "test", groupId = "test-group")
    public void receiveMessage(String message, Acknowledgment ack) {
        try {
            // 业务处理
            log.info("接收到消息:{}", message);
            // 处理成功,手动提交偏移量
            ack.acknowledge();
        } catch (Exception e) {
            log.error("处理消息失败,消息内容:{}", message, e);
            // 不提交偏移量,消息会重试
            // 为防止无限重试,可配置重试次数 + 死信队列
            throw e; // 抛出异常触发重试
        }
    }
}

partition

  • 作用:每一个分区在一个broker上存储。把大量数据按照分区切割成一块一块数据存储在多个broker,实现负载均衡。生产者按分区发送数据,消费者按分区消费数据,提高并行度。

分区负载策略

随机策略:生产者每次都发送到随机分区
按key分配:
副本机制:

大体流程

某种主题规则往什么动作推送什么内容

1. 创建连接器

首先要创建连接器去连接kafka

1 2

2. 配置规则

这一步是规定什么主题要发送到kafka的什么主题,kafka必须要有这个主题,不然推送失败

比如接收kafka/# 这一类以kafka开头的主题,#是通配符,表示所有然后推送到kafka的test主题。

首先要创建动作。规定这个kafka/# 主题要做什么动作。比如说推送到kafka的test主题,或者推送到Redis中。

3

3. 测试

4

流程

后端 --> kafka --> emqx --> 设备
  • 后端往kafka推送
  • emqx作为kafka的消费者
  • emqx内置规则推送到设备

EMQX配置

1. 创建连接器

5

6

2. 配置规则

2.1 创建输入规则,接收kafka何种主题输入

7

2.1 创建输出规则,输出到什么地方

8

测试

9

10

配置mqtt主题模板

这样子可以规定发送到设备的内容!

11

Mysql

允许远程登录

UPDATE mysql.user SET host='%' WHERE user='root';

FLUSH PRIVILEGES;

索引概念(index)

​ 一种高效获取数据的数据结构(有序)

​ 索引 = 数据结构(存储键值)+ 指向数据行的指针(或引用)

Btree

B+free

**hash:**只有Memory引擎支持,InnoDB有自适应的hash功能,通过B+tree构建成hash索引(自动)

​ 每一行数据进行hash。将所需要进行索引的列(字段)的值进行内部hash。内部hash生成特定的位置id(hash表中),然后将这个索引的值和行hash存入hash表。如果出现hash冲突,则会像java一样,会生成链表拼接在已有的值的后面。

​ 1.Hash索引只能用于对等比较(=,in)不能用在范围查询(between,>,<...)

​ 2.无法利用索引完成排序操作

​ 3.查询效率高

新增用户,删除用户,修改密码等功能

操作数据库,表,字段

操作数据库

创建数据库

create database mysql_std

先判断是否存在再创建

create database if not exists mysql_std

指定字符集

create database if not exists default charset utf8mb4

删除数据库

drop database mysql_std

删除数据库判断

drop database if not exists mysql_std;

查看所有数据库

show databases; 注意后面有s

查看当前数据库

select database();注意英文括号

操作表结构

列出数据库中表

show tables;

创建表

create table sys_user(

​ id int comment '编号',

​ name varchar(20) comment '姓名'

​ age int comment '年龄',

​ sex varchar(1) comment '性别'

) comment '用户表';

删除表

查询表中字段

修改字段

删除字段

增加字段

DML负责新增、修改、删除表中数据

所有select相关都是用DQL语句

插入优化

  • 批量插入

    insert into tb_user(id,name) values(1,'tom'),(2,'jack'),(3,'jerry');

  • 手动提交事务

    start transaction;

    insert into ...

    insert into ...

    insert into ...

    commit;

  • 主键顺序插入,减少 B+tree 页分裂。

    主键乱序插入:8 1 9 3 7 5

    主键顺序插入:1 2 3 4 5 6

  • 删除或禁用索引

    插入前临时删除非必要索引(或禁用),插入完成后重建。适合大批量数据加载。

  • LOAD DATA INFILE

大批量数据插入

load指令:用于数据迁移,一下子将磁盘文件插入到数据库。

image-20251225165226520

文件里面的数据主键顺序插入,也要比乱序高。

顺序插入高效原因

查询优化

索引优化

  1. 在 WHERE、JOIN、ORDER BY、GROUP BY 涉及的列上建立合适索引。
  2. 使用覆盖索引(索引包含查询的所有列),避免回表。
  3. 避免在索引列上使用函数或计算(如 WHERE DATE(col)=... → 应改为范围条件)。
  4. 区分度低的列(如性别)不单独建索引,可联合索引放在后面。
  5. 联合索引遵循最左前缀法则。
  6. 定期分析并重建索引(OPTIMIZE TABLE)。

SQL 语句优化

  1. 避免 SELECT *,只取需要的列。
  2. 用 EXISTS 代替 IN(子查询数据量大时),或者使用 JOIN 改写。
  3. 用 UNION ALL 代替 UNION(不需要去重时)。
  4. 大分页优化:
    • LIMIT 100000,10 → 改为 WHERE id > 上次最大id LIMIT 10(游标分页)。
    • 或先通过覆盖索引查出主键,再回表取数据。
  5. 避免 OR 导致索引失效,可改用 UNION 或优化为 IN。
  6. 使用 LIKE 时避免前置通配符 '%abc',会索引失效。
  7. 合理使用 EXPLAIN 分析执行计划,关注 type(至少达到 range 或 ref)、rows、Extra 中是否出现 Using filesort / Using temporary。

表结构优化

  1. 字段类型尽量小且固定长度(如用 INT 不用 BIGINT,用 CHAR 代替变长等),减少 I/O。
  2. 适当反范式化(冗余常用字段)减少 Join。
  3. 拆分大表(水平分表/垂直分表)。
  4. 对只读或很少变动的历史数据使用归档表。

其他

  1. 查询缓存(MySQL 5.7 及之前,8.0 已废弃),若使用则避免不适合缓存的查询(带 NOW() 等)。
  2. 限制返回行数:LIMIT 子句尽早加。
  3. 避免在循环中执行 SQL,改成批量或 JOIN。

更新&删除优化

  1. 批量更新/删除 每次操作限定行数(如 LIMIT 1000),配合循环,避免长事务锁住大表。
  2. 利用索引 WHERE 条件必须命中索引,否则行锁升级为表锁(InnoDB 走索引才锁行)。
  3. 避免锁争用 更新高频热行时,考虑排队机制或减少并发直接写。
  4. 先查询后更新 如果更新逻辑复杂,可先查出主键集合,再按主键批量更新。
  5. 分区裁剪 对分区表进行 UPDATE/DELETE 时,WHERE 条件带上分区键,只锁定相关分区。

查看和设置事务的提交方式

select @@autocommit;

set @@autocommit = 0;

提交事务

commit;

事务回滚

rollback;

开启事务

start transaction 或者begin;

事务四大特性

原子性

一致性

隔离性

持久性

并发事务问题

脏读

不可重复读

幻读

隔离级别

为了解决上面的并发问题提出的方案

分四种

​ read uncommited

​ read commited

​ repeatable read(默认)

​ serializable

查看隔离级别

select @@transaction_isolation;

设置隔离级别

set session transaction isolation level read uncommited

字符串函数

concat(s1,s2,...) 字符串拼接

upper(str)

lower(str)

lpad(str,n,pad)

rpad(str,n,pad)

trim(str)

substring(str,start,len);

数值函数

ceil 向上取整

floor向下取整

mod 取模,余数

rand 随机生成0-1

round 四舍五入,小数四舍五入,规定多少位小数

日期函数

curdata() 当前日期

curtime()当前时间

now() 当前日期时间

year(data) 获取给出的时间的年分

month(data)获取给出的时间的月份

day(data)获取给出的时间的当日

data_add(data,INTERVAL count d) 给出时间叠加之后的时间

datadiff(data1,data2) 时间间隔天数

流程函数

IF(value,tt,ff) 如果value为ture,返回tt否则返回ff

IFNULL(value1,value2) 如果value1不为空返回value1,否则返回value2

case when value1 then value2

多表查询的几种分类可以根据两个相交的集合分析

集合A

ab相交处

集合B

内连接

左外连接

右外连接

左排除连接

右排除连接

全外连接

外排除连接

三种存储引擎

INNODB(默认),MYISAM,MEMERY

INNODB支持所有操作。

MYISAM 用于插入和查询,极少修改删除。现在已经被其他数据库比如mongoDB之类的代替了

MERMERY 被Redis之类的内存数据库代替了。

类型:

非空约束 NOT NULL

唯一约束UNIQUE

主键约束 primary key

默认约束 default

检查约束(8.0.16之后) check

外键约束foreign key

Nginx

server{
	listen       80;
	listen 443 ssl;
    server_name  www.xxx.com;

	location /YH-LX001B {
    	try_files $uri $uri/ /YH-LX001B/index.html;
	}
}

$uri 和 $uri/ 的作用

“保障静态资源(CSS, JS, 图片等)能够被正常访问

当一个浏览器访问你的单页面应用时,会发生以下步骤:

  1. 浏览器请求 /YH-LX001B/ -> Nginx 返回 index.html。
  2. index.html 文件被浏览器解析,它里面包含了链接去请求各种静态资源,比如:
    • <script src=“js/app.js”>
    • <link href=“css/style.css”>
  3. 浏览器会向服务器发起新的请求:
    • GET /YH-LX001B/js/app.js
    • GET /YH-LX001B/css/style.css

正确的流程是怎样的?

有了完整的 try_files $uri $uri/ /YH-LX001B/index.html;,流程是这样的:

  1. 请求 /YH-LX001B/js/app.js:
    • $uri -> Nginx 在网站根目录下寻找 /YH-LX001B/js/app.js 这个文件。
    • 找到了! 于是 Nginx 停止尝试,直接将这个正确的 JavaScript 文件发送给浏览器。✅
  2. 请求 /YH-LX001B (一个目录):
    • $uri -> 寻找名为 YH-LX001B 的文件,没找到。
    • $uri/ -> 寻找名为 YH-LX001B/ 的目录,找到了。
    • Nginx 会默认寻找这个目录下的索引文件(如 index.html),并返回它。✅
  3. 请求 /YH-LX001B/settings (一个前端路由):
    • $uri -> 寻找名为 settings 的文件,没找到。
    • $uri/ -> 寻找名为 settings/ 的目录,也没找到。
    • 最终 fallback 到 /YH-LX001B/index.html,Nginx 返回这个文件。
    • 浏览器拿到 index.html 后,由前端的路由器(Vue Router/React Router)解析 URL 中的 /settings 并显示对应的页面。✅

域名为:api.openso.top

​ 对外开放的api接口,所有项目的openapi都走这里。

​ 后端项目必须要配置项目名:

server:
  port: 48080
  servlet:
      context-path: /proj1

​ 例如:

​ 1.proj1:api.openso.top/proj1/getuser/1

​ 2.proj2:api.openso.top/proj2/getuser/1

配置方式1

server{
    listen 80;
    listen 443;
    server_name api.openso.top;
        
    location /proj1/ {
        proxy_pass http://10.147.17.85:48080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location /proj2/ {
        proxy_pass http://10.147.17.86:48080; #映射到不同的后端地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

配置方式2

server{
    listen 80;
    listen 443;
    server_name api.openso.top;
        
    location /proj1 {
        proxy_pass http://10.147.17.85:48080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location /proj2 {
        proxy_pass http://10.147.17.86:48080; #映射到不同的后端地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

核心规则

Location 匹配规则

  • location /proj1/ :精确前缀匹配,要求路径以 /proj1/ 开头
  • location /proj1 :宽泛前缀匹配,/proj1、/proj1/、/proj1abc 都会匹配

Proxy_pass 路径处理规则

proxy_pass 格式处理方式说明
http://... (无斜杠)保留完整路径location 匹配部分 + 剩余路径都转发
http://.../ (有斜杠)替换 location 匹配部分只转发 location 匹配后的剩余路径

一、后端有配置项目名

  • 后端
server:
  port: 8080
  servlet:
      context-path: /proj1
  • Nginx
location /proj1 {
	proxy_pass   http://localhost:8080;
}
#或者
location /proj1/ {
	proxy_pass   http://localhost:8080;
}

二、后端没有配置项目名

  • 后端
server:
  port: 8080
  • Nginx
location /proj1/ {
	proxy_pass   http://localhost:8080/;
}

访问http://localhost/proj1/test。

  • proxy_pass http://.../ 有斜杠,会替换掉 location 匹配的部分

  • /proj1/test中的/proj1/会被替换成/,剩余test部分

apt install nginx

在server下添加

server {
	listen 80;
	listen 443 ssl;#HTTPS的默认访问端
	server_name www.openso.top;
	
	#禁止ip访问
	if ($host != $server_name) {
        return 403;
    }
}

安卓开发

安卓原生打包uniapp插件

博客链接以及教程

  • assets
  • [uniapp原生Android插件开发入门教程 (最新版).md](uniapp原生Android插件开发入门教程 (最新版))

uniapp原生Android插件开发入门教程 (最新版)

原创 已于 2023-11-30 10:38:30 修改 · 6.6k 阅读 · 57 · 64 · CC 4.0 BY-SA版权 版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 文章链接:https://blog.csdn.net/qq_56892518/article/details/134686079

前言:

前段时间写了一个点餐系统的项目,APP运行在商米收银机上,需要调用其内置打印机去打印小发票,但是uniapp不能直接调用Android设备,所以只能通过Android插件实现,但当时对于Android原生插件开发的了解还是一片空白,后面在插件商城找了一个插件并完美实现了打印功能,自那之后,就一直想学习一下Android插件开发。刚好最近公司事情不多,便认真学习了一下,但是学习过程中发现官方文档对初学者不是很友好。为了让后面的头同学少踩坑,特点花了时间整理出此篇贴文。希望此文能够帮助到您!

1.开发环境

以下是我的开发环境,各位可根据自己的情况来,没必要跟我的一模一样

HBuilder X版本号3.96AndroidStudio版本号 4.2.2

2.下载Android 离线SDK

下载完成后打开的zip文件是这样的

将该文件解压到电脑本地磁盘上

注意 : 保存该文件夹的路径不得包含中文,空格等非法字符,要不然Android Studio 打开会报错

这是我解压的文件路径 D:\test1\Android-SDK@3.96.81954_20231106

3. 使用Android Studio 打开 UniPlugin-Hello-AS 文件

导入完成后,这个地方改成Project,方便预览文件

这个时候可以尝试运行一下这个模板项目,看有没有报错,第一次运行时间有点就,因为需要加载资源,耐心等待右下角进度条加载完成即可。

运行后发现无论是Android Studio还是模拟器都提示appkey错误,那么下面我们就去申请一下appkey等配置信息。

3.申请配置信息

使用HBuilder打开uniapp示例工程源码下面的unipluginDem项目文件夹

打开manifest.json选择基础配置重新获取AppID

点击确定后AppID这一栏便后自动生成一个Id编号

这个时候打开uniapp开发者后台就可以看到这个项目的信息 开发者后台

如果还没有证书的话点击创建证书,如果已经有证书了那就点击下载证书

等待证书下载完成后将证书文件 复制到app文件下面,这里的目录需要注意一下,不要放错了

点击证书详情查看证书SHA1、SHA256秘钥和别名,点击查看证书密码,查看此证书密码,这些信息可先复制到其他地方,后面要用到。

将app 信息填写到build.gradle中

创建离线打包Key管理,离线打包key管理这个功能迁移到了各平台信息,单击 “点击前往”链接前往各平台信息选项卡

点击右上角新增按钮创建一个新的key

点击创建按钮后回弹窗下图最右侧的弹窗,把相应的信息填写进去

填写完成表单信息后点击提交按钮

这里会提示这个包名已经在其他开发者使用了,自己可以将包名改一下,改了就不会出现这个提示框 。

添加完成后列表中会出现一行新的数据,点击创建按钮

创建完成后点击查看按钮,将APP key 复制到AndroidManifest.xml下面的android:value中,

修改完成后记得同步一下信息

修改app文件下的 dcloud_control.xml 里面的 appid

生成本地打包APP资源

打包完成后点击导出路径

将打包文件复制到apps文件下面

补充 : 这里如果不好粘贴的话,将apps文件夹在外部打开,用电脑自带文件夹去操作

这个时候再运行一下

这个时候便运行成功了

下面我们以前分析一下案例代码,在uniapp的ext-module页面中中分别调用了testAsyncFunc、testSyncFunc、gotoNativePage三个方法,而在Android的TestModule java文件中同样有这三个方法说明uniapp调用的就是这三个方法,我看可以改一下里面的返回值内容,看一下代码运行是否正常。

修改返回信息后,页面内容变化了,说明上面分析的没错,并且代码运行正常,可以进行下一步

下面我们自己创建一个module试试

这里我选择的是Android 4.1 最低版本,大家跟可以跟我一样,也可以根据自己的情况来。

创建完成后在这里可以看到创建的文件夹

创建一个java文件,这里是创建在com.example.test 这个包下面

复制 uniplugin_module文件夹下面 build.gradle 配置文件里面的内容,这里只要复制我框起来的就可以了。

将复制来的内容粘贴到新我们刚刚创建的test文件夹下面的build.gradle中。

下面开始编辑新建的Java文件,为了少出错,咱们还是先选择复制粘贴他原本的代码,后期自己可以根据之身情况修改代码,为了方便复制代码和比对代码,将代码窗口切换传左右两侧显示。

将TestModule里面的代码复杂到我们刚刚创建的java文件中,这里Android Studio 通常会自动帮你导入相应的包,不需要单独复制。

以下是我 的代码

package com.example.test;
 
import android.util.Log;
 
import com.alibaba.fastjson.JSONObject;
 
import io.dcloud.feature.uniapp.annotation.UniJSMethod;
import io.dcloud.feature.uniapp.bridge.UniJSCallback;
import io.dcloud.feature.uniapp.common.UniModule;
 
public class testmode  extends UniModule {
    //run ui thread
    @UniJSMethod(uiThread = true)
    public void getTest(JSONObject options, UniJSCallback callback) {
        if(callback != null) {
            JSONObject data = new JSONObject();
            data.put("code", "success");
            data.put("code", "我是新来的");
            callback.invoke(data);
            //callback.invokeAndKeepAlive(data);
        }
    }
}

注册插件

这里需要在app\src\main\assets目录下打开dcloud_uniplugins.json文件,将我们刚刚创建的包名以及类名复制进去格式为"包名"+类名 ,type这里填module

引入插件

在插件项目app目录下的 build.gradle 文件中,添加我们刚刚注册的插件这里的名称就是我们刚刚创建的文件名称

在HBuider中编辑uniapp代码,这里requireNativePlugin引入的就是我们刚刚注册插件时填写的name,方法名就是我们在Java文件里面定义的方法名。

以下是我这边ext-module.nvue文件里的代码

<template>
	<div>
		<button type="primary" @click="MyTest">这是我刚写的</button>
		<button type="primary" @click="testAsyncFunc">testAsyncFunc</button>
		<button type="primary" @click="testSyncFunc">testSyncFunc</button>
		<button type="primary" @click="gotoNativePage">跳转原生Activity</button>
	</div>
</template>
 
<script>
	// 获取 module 
	var testModule = uni.requireNativePlugin("TestModule")
	var testmode = uni.requireNativePlugin("testmodule")
	const modal = uni.requireNativePlugin('modal');
	export default {
		onLoad() {
			plus.globalEvent.addEventListener('TestEvent', function(e){
				modal.toast({
					message: "TestEvent收到:"+e.msg,
					duration: 1.5
				});
			});
		},
		methods: {
			MyTest() {
				testmode.getTest({
						'name': 'unimp',
						'age': 1
					},
					(ret) => {
						modal.toast({
							message: ret,
							duration: 1.5
						});
					})
			},
			testAsyncFunc() {
				// 调用异步方法
				testModule.testAsyncFunc({
						'name': 'unimp',
						'age': 1
					},
					(ret) => {
						modal.toast({
							message: ret,
							duration: 1.5
						});
					})
			},
			testSyncFunc() {
				// 调用同步方法
				var ret = testModule.testSyncFunc({
					'name': 'unimp',
					'age': 1
				})
				modal.toast({
					message: ret,
					duration: 1.5
				});
			},
			gotoNativePage() {
				testModule.gotoNativePage();
			}
		}
	}
</script>

代码写完后重新打一个 打包APP资源,打包教程文章前半部分有,可以直接按照前面的方法打包。

打包完成后替换掉原来的打包文件,这里替换的是Android Studio里面的文件。

替换完成后重新运行一下,这里可以看到我们刚刚新加的功能已经能够正常使用

打包插件打包之前需要检测一下SDK版本,如果插件于uniapp里面的SDK里面设置的SDK版本不一致打包会报错。

点击Android Studio窗口右侧的Gradle在我们刚刚创建的文件夹下面找到assembleRelease,点击即可打包编译。

编译完成后左侧文件目录中找到目录为test/build/outputs/aar/test-release.aar 的打包产物

将打包产物复制到uniapp中,这里需要事先创建好一下文件目录 nativeplugins 、test、Android、package.json 其中Android、package.json为同等级。

目录创建完成后需要编辑一下package.json文件,这里的class就是我们注册插件时填写的class,name和type也是。

这是 package.json 里面的代码

{
    "name": "testmodule",
    "id": "testmodule",
    "version": "0.0.1",
    "description": "我是新来的",
    "_dp_type":"nativeplugin",
    "_dp_nativeplugin":{
        "android": {
            "plugins": [
                {
                    "type": "module",
                    "name": "testmodule",
                    "class": "com.example.test.testmode"
                }
            ],
			"integrateType": "aar",
            "parameters": {
                
            },
            "dependencies": []
        }
    }
}

下面就可以打包一个出来试试看,这里的证书就是我们前面申请的证书,

打包完成后安装到模拟器运行正常。

结束语

这也是我第一次写这么长的贴文,写的不是很好,希望各位见谅。

系统学习笔记

大小单位:px,dp,sp

px:手机屏幕的最小显示单位,它与设备的显示屏有关。一般来说,同样尺寸的屏幕(比如6英寸手机),如果看起来越清晰,则表示像素密度越高,以px计量的分辨率也越大。**
pd:有时也写作dip,指的是与设备无关的显示单位,它只与屏幕的尺寸有关。一般来说,同样
尺寸的屏幕以dp计量的分辨率是相同的,比如同样是6英寸手机,无论它由哪个厂家生产,其分辨
率换算成dp单位都是一个大小。
sp的原理跟dp差不多,但它专门用来设置字体大小,也是Android推荐的字号单位。手机在系
统设置里可以调整字体的大小(小、标准、大、超大)。设置普通字体时,同数值dp和sp的文字看
起来一样大:如果设置为大字体,用dp设置的文字没有变化,用sp设置的文字就变大了。

视图宽高:

**(1)match_parent:**表示与上级视图保持一致。上级视图的尺寸有多大,当前视图的尺寸就 有多大。 **(2)wrap_content:**表示与内容自适应。对于文本视图来说,内部文字需要多大的显示空间, 当前视图就要占据多大的尺寸。但最宽不能超过上级视图的宽度,一旦超过就要换行:最高不能超 过上级视图的高度,一旦超过就会被隐藏。 (3)以dp为单位的具体尺寸,比如300dp,表示宽度或者高度就是这么大。

小说

第353章 帝城为炉

九十九座护龙台的光,终于落到了人的身上。

最先承受这股力量的,并非芈家老祖,而是一个立在西北天穹的黑袍修士。此人方才还在冷笑,身侧忽然多出了一条金线。他横移百丈,那条线也横移百丈,牢牢拴在了他的影子上。

秦林五指收拢。

黑袍修士脚下一空,护身宝光层层炸裂,整个人被拖过长空。他厉喝一声,祭出一口紫色小钟,钟声震得皇城屋瓦翻飞,终于让那条金线滞了一滞。

也仅有一滞。

一只龙爪从钟后探出,将钟与人一并握住。

钟声骤停。

待得龙爪张开,只有碎铜与血雨落下。一道不足尺高的元婴裹住遁光冲出,却撞上另一座护龙台升起的金芒,当空散成了点点灵光。

数十位强者之间,空出了一个位置。

“秦林!”

有人惊怒交加。

秦林并未看他,只将掌中一片紫铜碎屑弹开。

“还有谁要教朕,如何做这个皇帝?”

夜空静了一瞬。

下一刻,七八道神通同时轰下。山形大印横断云层,剑气分开夜色,更有一杆白骨长幡张开,幡上层层叠叠的面孔睁眼,喷出冰冷的灰雾。

秦林迎着那些神通走了一步。

九十九座道台齐鸣,天地灵气沿着街巷、宫墙与地底大阵奔涌而来。真龙从他背后抬头,一口咬住了山印,金鳞间迸出万千剑芒。

龙身也随之凹陷,数片鳞甲碎成了光。

他并不是毫发无伤。

芈家老祖却没能因此露出笑意。

那口几乎拦腰斩开顾芳的玉尺,才刚迎上龙尾,便被抽得倒转回来,狠狠撞在他自己的胸前。骨裂声响起,他退出十余里,脚下连着踩碎了三片云层。

“结阵!”

齐轩正的声音终于露了出来。

蒙着面目已没有意义。秦林既敢把所有人留在这里,显然没打算事后再同他们装作不识。

他拂袖卷出十二道青光,嵌入诸人之间。原本各自为战的法力顿时连成一片,顶住了下压的金龙。另一侧,齐天鸿张口吐出一团黑雾,雾中有一滴暗红鲜血,在金光里缓缓转动。

“再舍不得,你们就带到棺材里去!”

随着这一声厉喝,天上接连亮起了十余点血光。

……

守蔵室中,江明看着那一幕,脸色已经变了。

“这些老东西,果然凑到了一处。”

墨欢原本正扶起一座倾倒的经架,闻言抬头,才看清天上那片翻滚的黑云。他手一松,经架又砸了下去。

陈生伸手托住。

“你不怕压死自己,也顾一顾架上的东西。”

墨欢忙伸手接稳,却仍忍不住朝外看。

陈生也在看。

玲珑宝珠悬在三人头顶,垂下的光幕隔开了外面的冲击。先前围攻守蔵室的修士早已退了大半,余下之人藏在断墙后,既舍不得宫中的机缘,又怕撞上这里那口杀人的铁剑。

彭沙消散之处,还留着一道深深的剑痕。

这一剑让许多人清醒了。

不过,陈生很清楚,若天上的秦林败了,那些人便会重新扑回来,来的也不会只有眼下这一批。

他右手握剑,拳背那道伤口已经收了血。宝珠虽能暂护守蔵室,却不能照住整座皇城。天上随便落下一场真正冲着他来的攻伐,都足以让他现在的从容变个模样。

“此地不宜久留。”江明道。

陈生没有立刻答话。

他的目光从秦林移到那十余滴冥血上,眉头一点点皱起。

此前在舫城,他见过王元枫、司马言吞服此物。那时候,两人的气息各自暴涨,他分不清其中更细的变化。如今这么多冥血同时现世,又被护龙台逼在一方天地,倒显出了一件怪事。

那些血,正在彼此牵引。

一滴升,一滴降,强者夺取弱者逸散出来的黑气,仿佛同一炉丹药里的诸般药力,尚未成丹,便先争起主次。

秦林也察觉到了,金龙压得更快。

芈家老祖却在此时发出一声低吼,将手中玉尺贯入那团血雾。尺身上的黑色纹路猛地舒展,化成无数细长的丝,刺进了身旁数人的护体黑气。

有人怒喝,立刻便要斩断。

“你们想独自去接他的龙爪?”芈家老祖森然道。

斩落的剑停了一停。

就是这一停,几股冥血之力已经合到了一起。

天空骤然暗下。

先前被压得只有薄薄一层的黑雾,忽然向外涨开,将一条金龙的半截身躯吞了进去。金鳞与黑气剧烈摩擦,声音刺入耳中,令人牙根发酸。

江明骂了一声。

墨欢却望向陈生:“大师,你在看什么?”

“看谁的命硬。”

陈生说完,掌心忽然一热。

这股热意来自储物袋,并非他常用的任何一件法宝。他心念一动,一块鎏金虎符落到手中,虎背上平日暗淡的纹路,此刻正一条条亮起。

药监长交符时那句郑重的嘱咐,蓦然浮上心头。

万万不要出示给他人看。

陈生将五指合拢,遮住了大半光芒。

“看来,还有人在等我。”

话音才落,守蔵室后方的墙壁上,传来了三声叩响。

叩击并不急,头两声短,末一声长。与此同时,一道细小的金纹从墙缝中探出,与虎符上的纹路相合。

陈生没有开门,先分出一缕宝光,将整面墙壁照了个通透。

墙后站着十三个人。

为首的正是药监长。

他头上的礼冠不见了,左袖从肩头烧掉一半,裸露出来的手臂焦黑。身后的十二名甲士排成两列,其中一人背上插着半截断矛,血顺着甲缝往下滴。

“陈大师,是我。”

药监长的声音有些哑。

陈生抬手,守蔵室后门开了一道缝。十三个人鱼贯入内,最后两名甲士转过身,立刻以长枪封住门口。

“陛下要你来做什么?”陈生问。

药监长先看虎符,再看陈生,压低声音道:“请大师随我走。”

“去哪里?”

“出宫,出城。”

江明眼中一动。墨欢则脱口道:“陛下不是占着上风么?”

药监长抬头。

隔着残破的穹顶,可以看见秦林一拳击穿黑雾,打得一名强者吐血横飞。那尊帝王法身何等威严,几乎压得人不敢直视。

可药监长眼中并没有多少喜色。

“陛下已服了那颗丹。”

陈生手指微紧。

三一四阶化神丹是他亲手所炼,其中药力能够将修士推到何种地步,又能留住多久,他比许多人都清楚。眼下这番战力,不等于秦林已经安安稳稳地踏过了化神门槛。

“大师。”药监长又唤了一声,“请快些。”

陈生看着他烧焦的左臂。

“他让你来时,可说了自己怎么办?”

药监长沉默片刻,道:“陛下说,城里不能连一个收拾残局的人都没有。”

“所以把我送走?”

“活着的人,才能回来。”

天上一声巨响,守蔵室西面的高墙倒了半边。

一名躲在墙外的修士见里面来了宫中人,眼珠一转,手中忽然飞出三枚青钉,分取药监长与背负断矛的甲士。他自己却身形一闪,扑向倒塌的经架。

陈生连头也没有回。

铁剑横过膝前,一缕星光从剑锋掠出。

三枚青钉断作六截,那修士还未摸到经架,头颅便先掉了下来。

断墙后立刻响起一阵杂乱的遁走声。

药监长瞥了眼那具尸体,将余下的话咽了回去。

陈生把剑上的一点血弹开,转身看向江明与墨欢。

“跟上,先去看看。”

墨欢立刻抱起脚边一只匣子。

陈生看了他一眼:“这时候还惦记丹书?”

“我的储物袋在里面。”

墨欢脸上一红,终于把匣子打开,抓出自己的东西。

江明忍不住笑了一声,笑到一半,头顶又是一震。他低头避开一片落瓦,脸上的笑意也淡了。

药监长带着他们穿过内堂,在一扇从未向陈生开过的石门前停下。

门上没有锁,只有两只相向而伏的石虎。

其中一只的背上,空着一块。

陈生把掌中的虎符翻过来,恰好能与那块空缺相合。

石门之内,传来了悠长的风声。

皇城上空,第二条金龙钻入了血云。

这座城已经成了一只扣住炉盖的巨鼎。鼎里有人要炼尽群魔,也有人正等着炉破之时,将执火的人一同吞下。

而秦林留给陈生的,原来不是一把刀。

是一道门。

第354章 退路

虎符并非只有开门之用。

陈生将一缕法力送入其中,才发现符内还藏着一段未曾启用的神念。此刻护龙台尽起,那层遮蔽随之解开,秦林的声音在他识海中响了起来。

“祖师,若事不成,替弟子带些东西出去。”

声音到此便止。

秦林连败后该去哪里,都没有多说。

陈生收回神识,问药监长:“什么东西?”

药监长指向石门。

“各州道藏的种本。有些传承,外面的道脉已经灭了,只有这里还留着。”

他又指了指十二名甲士。

“他们随符走。出城后,听持符之人的命令。”

那十二人没有应声,但目光已一齐落在陈生手上。

陈生忽然觉得虎符重了几分。

国朝万载积蓄,十二名死士,一条不为外人所知的退路。秦林没有把后手交给某位朝中大臣,而是交给了一个在外人看来,与他只见过一面的炼丹师。

“带上他们两个。”陈生道。

药监长点头。

“还有紫令堂。”

这一次,药监长没能立刻应下。

陈生看着他。

“那里有我的人。”

“大师,离宫道通往城外,途中无处分岔。此时再去接人,赶不及了。”药监长迟疑片刻,“紫令堂有自己的护阵,陛下也命龙骧卫留意过。”

陈生缓缓吐出一口气。

他没有再争。城中绝不只有紫令堂一处要紧,秦林能在这场大战里替他分心一回,已经不易。

门外的轰鸣隔着厚重石壁传来,反倒显得格外沉闷。

药监长催道:“虎符一入,内库与禁道同时开。道开之后,宫中这一重藏禁便会开始挪转,撑不了太久。请大师莫要迟疑。”

陈生却将虎符收进了袖中。

“先等一等。”

药监长愕然。

陈生已经转过身,向空荡荡的内堂看去。

“舫城一别,两位倒是越发懂礼了。来了这么久,也不打声招呼。”

江明脸上的神情立刻变了。

他什么都没看见。

但陈生的剑已经出了鞘。

一道剑光落向左侧经架,架上的玉简自行升起,移到旁边。下一瞬,整座木架无声裂开,露出后面一条横生的桃枝。

桃枝不过手臂长短,却在虚空中生了根。细密的根须穿过石缝,一端连着守蔵室外彭沙留下的剑痕,一端已探到内库门前。

“道友好眼力。”

王元枫自桃影中现身,另一侧水光微动,司马言也缓缓走了出来。

两人的目光掠过药监长,最后都停在陈生那只袖子上。

陈生看见了,面上却无变化。

这两个人没有随着天上的诸族强者一起围攻秦林。他们跟着乱军潜到守蔵室,在彭沙撞坏的禁制边缘藏了这么久,所图显然不小。

“我还以为,你们来替彭沙收尸。”

王元枫瞥了一眼外面:“那等蠢物,也配我们来收?”

话说得轻巧,他却没有往前走。

当年陈生能够从他们联手之下安然退去,靠的便是那颗宝珠。眼下宝珠悬在屋中,光芒虽不强,照得他脚边那些根须微微蜷曲,不能再向前探。

两百年过去,这位陈大师的境界似乎没涨多少,但王元枫已经不会把他当成寻常元婴。

“大家各取所需,如何?”他道。

陈生笑了:“先说说,你们要什么。”

药监长站在侧后方,嘴唇动了动,最终没有开口。

他看见陈生的左手垂在袖边,指尖正轻轻按着剑鞘。那副不紧不慢的样子,并非真要与来敌叙旧。

“内库里有一卷旧图。”司马言抢先道,“取到手,我们即走,不拦你们的路。”

“天下宝图这样多,我该替你找哪一卷?”

司马言眼中掠过一丝不耐。

王元枫忽然向他使了个眼色。

晚了。

陈生看见了那一瞬的犹豫,声音变冷。

“到了这一步,还要我开着门,等你们自己进去挑?”

天穹再次震动,门内的风声急了些。

司马言终于道:“黑崖封禁图。”

陈生握剑的手指,一根根收紧。

黑崖。

这个名字,他很久以前便听过。

那时候,秦林还不是元梁大帝,只是一个跑到边地寻找国师传承的少年。在祝霞山的小院中,少年说起父皇驾崩、国师失踪,也说起一夕之间出现的黑崖禁地。

后来陈生踏入元梁,找过旧人,翻过旧卷,去过允泽,也杀进过冥血祭场。

这么多年,他总想知道,二狗到底去了哪里。

如今,有人站在了他面前,要抢走一卷与黑崖有关的图。

“那种地方,”陈生望着司马言,“也值得两位亲自来?”

王元枫忽然笑了一声。

“陈大师,你装不明白的样子,我见过一次。”

陈生也笑了。

当年假扮冥血使者,在祭场上诈问两人,那一场戏到了末尾才被识破。如今旧事重提,彼此心中都清楚,今日没有什么交图放人的和气收场。

“那次你们运气不错。”陈生道。

司马言拔剑。

剑锋才露出半寸,厅中已响起潮声。青色水光从他身后漫起,将破碎的经架映得一片幽寒。

药监长一抬右手,十二名甲士同时挺枪。

江明却忽然开口:“王道友,你与他约的是平分?”

王元枫看过来。

江明的目光落在司马言手上,又慢慢移到那条桃枝。

“一张图好分。图上若只有一条活路,不知怎么分。”

王元枫眼神微沉。

“汝南侯府的小子,收起你这点心思。”

“怎么,侯府的人连句话都说不得?”

江明嘴上仍硬,背在身后的手却已经握住剑柄,掌心生出了汗。

他只说了两句话,便感觉两股气机压了过来。没有陈生与宝珠立在前头,此刻他便连站稳都难。

陈生趁着这短短的空隙,传音问药监长:“可知道此图?”

“知道名字。封在这里已有多年,未再提取。”

“开门后,能取?”

“能。只是那一阁的封禁,未必——”

“门后种本,你带人去取。不要碰那一阁。”

药监长猛地看向他。

陈生的目光并未离开两名敌人。

“那图归我。”

司马言似乎猜到了两人在商量什么,忽然一剑刺出。

潮声化作巨响。

一道青碧剑芒横贯内堂,在丈许之外骤然分散,千百道细小剑气避开陈生,直取他身后的药监长与甲士。王元枫同时抬手,桃枝上的叶片尽数转红,一道道锁链般的光辉从半空垂下,缠向玲珑宝珠。

两个人仍和当年一样。

一人困,一人杀。

陈生没有退。

宝珠向上一升,先将屋中悬浮的玉简送向远处,继而垂下重重光幕。青碧剑气撞上去,如骤雨打入深湖,荡开无数涟漪。

那条桃枝却趁隙向前长了三尺。

陈生眼中厉色一闪,铁剑自下而上斩出,星光沿着木纹钻了进去。

数片桃叶碎裂,王元枫闷哼一声,强行将桃枝抽回。

血从陈生尚未长好的拳背上迸出来,染红了剑柄。

他也没有追。

这一回交手,双方都摸清了几分底细。对面借冥血暴涨的法力仍然难缠,陈生却比上次更熟悉他们的手段。此地又不是舫城,他身后有宫中藏禁,不必跟着对方的路数去打。

司马言冷冷道:“你能护住几个人?”

陈生看了一眼他剑上的青光。

“你不妨试试。”

墨欢忽然从旁边探手,将一只烧得发红的小炉倒扣下来。

炉火落在两人脚边,照见几缕沿地面爬来的桃根。根须被宝光压得细若游丝,才触到陈生身后的影子,墨欢便趁其一滞,倾尽法力烧卷了末梢。

一滴黑血从根须中落出,嗤的一声蚀穿地砖。

墨欢脸色有些发白,嘴却没停:“上面说得热闹,底下还偷!”

王元枫神色骤冷,盯了他一眼。

墨欢往陈生身后缩了半步,炉子却抱得更稳。

陈生抬起左手,将鎏金虎符按入石虎背上的缺口。

药监长的呼吸蓦然一重。

“咔。”

细小的机关声,压过了厅中的潮声与远方的雷鸣。

两扇石门朝内分开,门后没有金山玉海,只有一条狭长的石廊。石廊两侧浮着大大小小的封匣,正随地底阵势缓缓转动。更深处有一线白光,透出夜风的凉意。

那便是离宫道。

虎符上的金纹一寸寸暗了下去,几乎与石虎融成一体。

药监长率先入内,十二名甲士随之抢上,将石廊前半截占住。江明拉了墨欢一把,两人并没有奔向那线出口,而是回身站到陈生侧后。

王元枫的桃影已经越过门槛。

陈生终于向前走了一步。

陈生横剑挡在石廊入口,余光却被库中一只漆黑的长匣牵住。

匣上缠着重重禁纹,其中一缕气意,竟让他生出了一种极熟悉的感觉。

他曾在太平峰的晶碑前,见过相近的起势。

王元枫与司马言也看见了那只匣子。

三个人的目光,在同一处碰上。

下一刻,陈生的剑先亮了。

第355章 夺图

这一剑先斩的,是越过门槛的桃影。

星光从木影中穿出,王元枫腕间一震,那条已经探入内库的细根断了。他没有去接断根,反将手中桃枝往前一送。

漫天落英,转眼淹没石门。

陈生侧身退入石廊,铁剑贴着门边一划,将随身追来的三朵桃花切碎。花瓣落地,烧出三个漆黑的小洞。

王元枫随花影而入,司马言紧随其后。

两人都没再提各取所需。

狭廊深处,药监长翻动一枚宫印,将迎面浮来的封匣逐只摘下,交给身后的甲士。那些原本连成一片的禁纹,此时已分成了两层,外层随地脉挪转,内层仍贴着匣身,不能强取。

他们取的是种本,王元枫二人盯的却是靠近廊尾的漆黑长匣。

陈生比他们更近一步。

他左手抬起,玲珑宝珠垂下一线清光,卷住长匣的一端。

“留下!”

司马言的剑几乎同时到了。

这一剑没有直刺陈生,而是贴地铺开,碧光化作一片涌动的海面,沿两侧石壁攀上去。转瞬间,整条石廊都像沉入了海底。

江明脚下一滑,被墨欢拽住衣袖,才没有跌进那层碧光。

一名正在接取封匣的甲士却没能避开。水中骤然立起一线剑芒,击碎枪杆,将他的右臂齐肘斩断。

甲片与血一齐溅开。

陈生目光一沉,宝珠光芒陡然铺展,将甲士连同那只尚未落地的封匣托了起来。

也就在这时,王元枫手中的桃枝长出了一片新叶。

叶色殷红。

一道粗如手腕的木影从陈生背后升起,绕过铁剑,直取他的脖颈。陈生旋身斩落,木影从中断开,却又分成数十条细藤,缠住了剑身。

王元枫笑意一闪。

陈生的铁剑没有抽回。

剑上星光逆着藤纹而行,将数十条细藤寸寸绞碎,顺势刺向王元枫的手腕。

王元枫退了半步,桃枝上那片新叶随之枯黑。

他脸上的笑意没了。

这两百年,他并非全无进益。桃花仙木枝虽曾被斩去一截,余下的本体却让他重新祭炼了一遍,更以冥血灌养,连生出的虚根都有侵蚀法力之能。

寻常元婴的飞剑,只消被缠上一时半刻,便要失去灵性。

眼前这口黑沉沉的铁剑,反倒沿着根须来杀他。

“你还要看多久?”王元枫喝道。

他是在叫司马言。

方才陈生分心护人,司马言已经借着两侧水光,越过了半条石廊。此时他一只手按向黑匣,听见喝声,头也没回,反手便是一剑。

海潮轰然卷起。

陈生将铁剑横在身前,撞来的巨浪中却藏着一口实剑,青碧剑锋在他眼前一点点放大。

他右手的伤口再度迸血。

铁剑与那口剑相交,刺耳之声填满石廊。陈生脚下石砖接连碎裂,向后滑了三步。

王元枫没有错过这个机会。

他抬掌拍在桃枝上,枝身的纹路尽数亮起。一株无根桃树从虚空长出,树冠压住廊顶,粗大的枝条将陈生前后左右一并封死。

桃树之中,唯有那颗宝珠还亮着。

它既要压住水光,又要护着一众甲士,再被桃影一裹,光芒顿时缩小了许多。

“陈大师,何必呢。”王元枫道。

他声音又缓了下来,手中催动桃枝的法力却越来越猛。

“你肯走,我原也肯让。”

树身之内,传来陈生的声音。

“现在还来得及?”

“来得及。”

王元枫眼底有讥色。

“宝珠留下。”

他话音才落,司马言忽然低喝一声。黑匣上的禁纹卷住了他的指尖,竟沿着护体水光向他手臂攀去。

司马言断然撤手,斩掉一截袖口。

那片青布还未落地,便在禁纹中化作了灰。

王元枫眼神微动。

长匣尚取不走。先将陈生杀了,得了宝珠,再逼那个宫中老奴解禁,也来得及。

他五指越收越紧。

树冠中,数十片红叶一齐翻了个面。

陈生却在这一刻闭了闭眼。

斩断的桃根、枯黑的新叶,还有此刻叶背流动的黑纹,在他心中连成了一条线。

这根桃枝有伤。

伤得还不轻。

木中尚存的生气,支撑不起王元枫眼下催出的凶威。那些冥血之力确实补上了缺口,却也在啃噬枝身,每逢木影大涨,王元枫便要将血气向根部收束一回。

否则,不等困死别人,这件宝物先要从里面烂掉。

陈生炼过太多灵木入药的丹。木气旺在何处,败在何处,隔着这一层黑雾,也瞒不过他的眼睛。

方才他连斩枝叶,王元枫只当他想破困而出。

却不知每斩一处,对方便要多催动一分冥血,去填那株桃树的缺口。

如今,火候到了。

陈生握剑的右手忽然一松。

铁剑向下沉了半尺。

王元枫清楚看见,他拳背上的血已经流进了袖口,护体宝光也跟着晃了一晃。

正是这一晃,让王元枫下定了决心。

桃枝点向眉心,一点暗血自他额间渗出,没入枝头。

木影暴涨!

连司马言都侧了侧头,似乎在忌惮他骤然攀高的气势。

陈生却向前走了。

他迎着最厚的树影,肩头一沉,直接撞了进去。数十根尖刺刺在身上,琉璃般的光泽从皮肤下浮起,挡住大半,余下几根刺进血肉,带出一蓬血点。

“找死!”

王元枫厉喝,枝头黑气急收,要将陈生死死钉在树干里。

那一片黑气收回根部的瞬间,玲珑宝珠忽然从树冠落了下来。

宝光不再四散。

它沿着陈生撞出的缝隙,照在了王元枫手中的那截桃枝上。

枝身一沉。

王元枫的手也跟着沉了下去。

他猛地发现,自己指间那股刚刚收拢的冥血,竟被压在了木纹之内,一时冲不出来。

虚空中的桃树轰然摇动。

“司马——”

碧色剑光已经从陈生身后斩来。

司马言知道两人联手才有胜机,这一次没再留力。长匣前水光一空,尽数化进剑中,直取陈生后心。

陈生没有回身。

他的铁剑已从桃树的腹心斩出。

星光贯穿枝干,直抵王元枫眉前。

王元枫仓促间横过左臂,臂骨上浮起细密黑纹,竟要凭这一身冥血硬接斩星。

与此同时,陈生上身猛地一侧。

青碧剑锋擦着他的左肩斩下,肩头的护体光泽当场裂开,鲜血染透衣袍。他借着这一击向前猛进,右手五指重新握紧剑柄。

“开!”

一条手臂飞了起来。

王元枫的护体黑纹只亮了一瞬,便随着断臂一同消失。斩星余势不止,撞在他胸前,将他连人带桃枝砸到了石壁上。

桃树从中裂成两半。

司马言眼角猛地一跳。

陈生身后的剑伤是真的,右手几乎握不住剑也是真的。

他却拿这些真伤,换到了王元枫面前。

王元枫挣扎着抬头,嘴里满是血沫。

玲珑宝珠悬在他头顶,失了木影遮护,层层清光落下来,将他连同手中的桃枝一并镇住。

“黑崖里的事,你不想——”

“司马道友也知道。”

铁剑刺下。

王元枫胸前刚刚凝起的黑气被一剑穿透,剑锋直没后心。那股宏大星意并未散去,沿经脉灌入紫府,将其中才要离体的元婴绞了个粉碎。

石壁震了一下。

王元枫的头颅垂了下去。

他的右手仍握着桃枝,五指却一根根松开,最后那截灵木落在陈生脚边,发出一声极轻的响。

枝上只剩了两片叶。

陈生抬手摄住,袖袍一卷,将它收了起来。

随后,他拔出铁剑,转向石廊深处。

司马言与他对视,忽然觉得面前这个满身鲜血的人,比舫城那一日还要难缠。

那一日,两个人能逼他走。

今日,地上已经躺了一个。

司马言的左手,已经按进了黑匣。

方才的一剑没有救下王元枫,他便知道,今日再无联手杀人的机会。趁陈生拔剑,他将护体水光尽数裹在左臂上,五指硬生生穿过了那层禁纹。

皮肉转眼焦黑。

他右手持剑,往匣缝里猛地一撬。

内库的藏禁还在挪转,原本严丝合缝的封纹被这一撬,终于错开了一线。长匣炸开,半卷灰白色的绢图从中露出,展开的边角上,是一片漆黑山影。

司马言眼中一亮。

陈生的剑光也在这一刻到了。

剑锋不取绢图,直刺他的咽喉。

司马言俯身避开,一缕头发被斩断,未等他直起腰,玲珑宝珠的光便照在了图上。

半卷绢图被拉得绷直。

两人一左一右,隔着数丈对视。

司马言还想催动碧海潮生剑诀,体内的血气却骤然一滞。侵入左臂的禁纹已经爬过腕骨,正沿小臂向上攀来,若再拖下去,不必陈生动手,他这半边身子先要废了。

陈生看得清楚,手中铁剑缓缓抬起。

“还不松手?”

“你既要这图,便也知道黑崖是什么地方。”司马言的额上全是冷汗,“没有引路的人,拿去也是送死。”

陈生笑了一下。

“所以,你会带路。”

司马言脸色变了。

他能感觉到,周身水光正在被宝珠一寸寸压回体内。陈生仍在流血,气息比方才弱了许多,却没有给他喘息的意思。

王元枫的尸体还靠在墙边。

司马言忽然向后猛拽。

“嗤啦!”

图没有从宝光中抽出,却从西侧裂开了一角。

陈生眼底骤冷,剑光当胸刺来。

司马言横剑架住,只觉得一股巨力沿剑脊撞入胸口,整个人向后飞出,狠狠砸在廊壁上。

他喷出一口血气,卷住那块不足巴掌大的残图,抢着收入怀中。

随后,剑锋向下一压。

他竟将自己的左臂齐肘斩断!

断臂坠向地面,其中被封纹逼住的冥血猛然炸开,黑光撞向两侧石壁。原已错动的禁纹受到冲击,顿时乱成一团。

一块丈许长的顶石自廊上裂落。

它下面,是抱着封囊的甲士。

陈生左掌横推,宝珠分光而下,先接住顶石。司马言身外碧光随之一松,整个人化作一道贴地的水影,从陈生脚侧掠了过去。

铁剑还是追到了。

一缕星芒击穿水影,从司马言背后透了进去。他闷哼一声,逃出的碧光里多了一线鲜红。

他不敢停,更不敢再回头,冲出内库之后,直接撞破守蔵室残顶,向皇城外遁去。

夜空中四处都是杀光。

这道狼狈的水影没入其中,很快便失了踪迹。

陈生追到门前,握剑的手抬了一抬,终究没有斩出下一剑。

身后顶石还在向下压。

他回手一引,硕大的石块被宝光托过人群,轰然砸在王元枫的尸体旁。石屑飞起,将那张死不瞑目的脸盖了大半。

墨欢猛地吐出一口气。

刚才他与江明一左一右架着那名断臂甲士,若那块石头当真砸下来,三个都跑不掉。

“让他跑了。”墨欢望向殿外,颇有些不甘。

“他还会来找我。”

陈生将绢图折起,收进储物袋。

“有的人,舍不得已经送掉的半条命。”

他没去细看图中所绘的山势,只看清西侧缺了一角,折起时,残边旁露着一个“岭”字。司马言怀里那一小块,至多能容下一段外缘山道,中央禁势仍在自己手中。

两人总有再见的时候。

“大师!”

药监长在廊尾唤他。

那线透入夜风的白光正在变窄,风声也从先前的悠长,变成了尖利的呼啸。石廊两侧已空出大半,最后一只种本封匣被装进封囊,十二名甲士都退到了出口前。

断臂者的伤口已经被扎住,由两名同伴搀着。另一名背负断矛的甲士将手中长枪倒转,当作拐杖,一步一步挪向那线白光。

药监长的脸色比他们还难看。

“走!”

陈生道。

他没有往廊尾去,反将玲珑宝珠托得更高,压住了仍在乱窜的残余禁光。

药监长一怔。

“我护最后一程。让他们把种本带出去,先藏在接应处,不得擅回。”

十二甲士听见这句话,终于不再迟疑。三只封囊由未受伤者分持,受伤的同伴夹在中间,依次没入白光。

江明转头看着陈生。

“你不走?”

陈生从袖中取出一瓶丹药,倒了一粒含住,随后将瓶子抛给他。

“先止血。”

江明接住,低头看了一眼,才发现自己的手掌也被碎石割开了。他一路抓着伤兵的衣甲,倒没怎么觉得痛。

药监长等到最后一名甲士离开,快步折了回来。

陈生皱眉:“你跟他们去。”

“老奴还得取符。”

药监长右手托着宫印,立到石虎旁。石廊尽头的风声骤然拔高,白光从一线缩成一点,终于彻底熄灭。

一阵沉重的闷响沿地底传来。

开启不久的离宫道,合上了。

两侧禁纹缓缓归拢,石门也开始向内收束。陈生带着江明、墨欢退出门槛,药监长最后退出来,在虎背上一按,取回了那块已经暗淡的鎏金虎符。

虎符完好无损,金纹却不再亮。

“藏禁归位之前,这条道开不了第二回。”药监长哑声道。

“那便不开。”

陈生接了虎符,收进袖中。

他走过残破的经架,重新看向天穹。

上面的情形,比入库前更险了。

共有的血云已铺满皇城上空,两条金龙在云中翻滚,龙身上的金鳞大片脱落。秦林一拳打穿血云,换来的却是七八道一齐压下的杀伐,连那尊映照天地的帝影都矮了一截。

芈家老祖的玉尺,反而越来越黑。

尺身每亮一回,周围便有数名强者气息下坠,黑雾顺着细丝涌入芈家老祖身前。那些人未必愿意供他吞取,却又不得不借同一片血云挡住龙气。

有人试着斩断身外黑丝,刚断了两根,秦林的拳意便从缺口压进来,逼得他重新接住血云。

陈生看了片刻,忽然道:“能给陛下传话么?”

药监长抬起宫印。

“能。”

“告诉他,先断最强的主血引。”

药监长望了一眼天上,神色凝重。

陈生指向那柄玉尺。

“别一处处去堵。越压那些弱的,余力越往这里聚。这老东西借众人保命,还在借他的拳头,将旁人的血气压给自己。”

他刚刚在桃枝上见过相似的变化。

被强行聚合的异力,总要有个收束之处。那处若藏得严实,便是最难攻破的根;如今芈家老祖为了抢过秦林的凶威,已经把它露在了外面。

药监长将这几句话送入宫印。

过了片刻,印上掠起一线金芒。

只有四个字。

“朕知道了。”

药监长抬头,看见秦林又被血云压退了一步。

他的嘴唇不由抿紧。

墨欢将剩下的丹药递还陈生,低声道:“大师,那颗化神丹,还撑得住么?”

陈生抹去嘴角的血。

“丹在他肚子里,我隔着这么远,哪里知道。”

墨欢怔住。

江明却看见,陈生已经把剑重新握稳了。

片刻后,天上传来一声龙吟。

不是进。

秦林双手一分,竟将困在血云里的两条金龙,同时抽了回来。

第356章 帝杀

两条金龙一退,半边天空都空了。

先前还被压在宫城上方的血云骤然翻涌,追着龙尾向前。无数黑丝从云中垂下,刺进龙鳞裂开的缝隙,发出蚕食桑叶般的细响。

秦林身后的帝影开始收缩。

诸敌先是一惊,继而眼中都露出了喜色。

这一夜,他们已经被那个年轻皇帝压得太久了。

九十九座护龙台齐起的时候,许多人当真以为自己会死。如今,那股无处不在的压力终于弱下去,就像压在头顶的一座大山,露出了一道可以逃生的缝隙。

“他撑不住了!”

有人喝道。

齐轩正没有跟着喊。

他站在血云一侧,看见秦林脚下的龙气虽散,护龙台却仍旧亮着,心里莫名生出一丝不安。

“芈道友,不宜——”

话才出口,芈家老祖已向前踏去。

他听见了。

也知道秦林未必没有后招。

可到了此时,他比谁都明白,退不得了。

别的人能遮住面目,能缩回族中,再找一条门路试探皇帝是否愿意罢手。他芈家第一个杀进神都,亲口喊出另立新君,连皇城的门都让族人打破了。

秦林若不死,芈家便要死。

“随我杀!”

玉尺横过长空,漆黑的尺身将血云从中剖开。原先缠住诸敌的细丝尽数绷紧,几名气息稍弱的修士闷哼一声,竟被扯得踉跄向前。

有人怒骂。

芈家老祖不理。

他只盯着秦林背后正在收回的金龙,尺锋一沉,斩向龙首与帝影相接之处。

“吞焰分天尺!”

黑火腾空。

与顾芳交战时相比,这一尺多了十余道冥血相助。火焰向两旁分开,中间那线暗隙横贯云天,所过之处,连护龙台升起的金芒都被截断。

秦林抬手,挡在身前。

一重金光碎裂。

再一重金光碎裂。

他的龙袍自胸前裂开,一道深可见骨的伤口从左肩斜拉向胸腹,鲜血才涌出来,便让尺上的黑火烧成了暗红色的雾。

帝影剧烈一晃。

群敌精神大振,数道早已蓄势的神通紧跟着落下。秦林侧身避过山印,后背却被一道灰光扫中,衣袍顿时失去光泽,露出的皮肉也蒙上了一层死气。

这一退,他直退到了皇宫正殿上方。

下方琉璃瓦承受不住外泄的气机,一片片翻卷出去。

芈家老祖大笑。

他看见秦林嘴角的血,也看见那双仍然明亮的眼睛。

“再撑下去,你这座皇城,先要压死你自己!”

秦林没有答话。

他突然伸出右手,抓住了玉尺。

满空黑火沿着手指攀上来。

金光与黑焰在那只手上交错,皮肉焦裂的声音,隔着十余丈都听得分明。芈家老祖一惊,随即催动血云,想连这只手一并斩去。

秦林却将玉尺向自己这边一扯。

原本隐在血云中的尺尾,就此露了出来。

那上面,结着一颗拳头大的血瘤。

无数黑丝自其中生出,另一头连着十余位强者,轻轻一动,便有冥血之力从各处涌来。

秦林看了一眼。

“原来在这里。”

芈家老祖脸色骤变。

两条退回来的金龙没有护住秦林受伤的身体,而是向相反的方向游走,一左一右,绕在了那颗血瘤两侧。

与此同时,九十九座护龙台的光,齐齐低了一寸。

不是熄灭。

是沿着大地与宫墙,向正殿上方收拢。

整个皇城的龙气,被秦林在这一刻拧成了一股。

芈家老祖猛地抽尺。

抽不动!

秦林右手的血肉已经烧去一层,仍死死握着尺身。芈家老祖连催法力,尺锋震动,将那只手的指骨割得咔咔作响,却始终挣不出去。

“你们还在看什么!”芈家老祖嘶声道。

齐轩正袖中青光顿起,要把众人的法力重新接拢。

晚了半步。

秦林左掌压下。

两条金龙同时张口,咬住血瘤,皇城升起的那股金芒从中贯穿,恰似一柄自大地拔出的天剑。

第一根黑丝断了。

第二根,第三根。

紧接着,是一连串绷弦般的爆响!

十余道冥血牵连被从尺尾处强行截断,整片血云剧烈膨胀,继而裂成大小不等的数团。有人当空喷血,有人被逆卷的黑雾掀出数百丈,还有人立刻挥剑,斩去了自己身外剩余的细丝。

他们等这个机会,也等了很久。

冥血仍在他们体内,力量却再不能像方才那般任意流转。

芈家老祖想补一分,便得有人愿意再送一分。

此时,没人肯第一个伸手。

秦林将玉尺向下一折。

“咔!”

一道白色裂纹从漆黑尺身上显出来,玉尺随之断作两截。

芈家老祖心神剧震,口中喷出一大股鲜血,连握尺的手都软了一瞬。

秦林趁势向前,左拳直击他的胸膛。

这一拳,没有漫天圣贤,也没有铺陈天地的帝影。

只有九十九座道台升起的龙气,尽数压进方寸之间。

芈家老祖双臂交叠,黑纹从肩头一路攀到指尖,密密麻麻,如穿了一身坚甲。

拳落,甲碎。

两条手臂一齐向后折去,胸前传出闷雷般的骨裂声。芈家老祖倒撞入正殿檐角,将半座殿顶砸塌,尚未落地,又被秦林一把从碎瓦里抓了出来。

“另立新君?”

秦林的声音不高。

芈家老祖张了张嘴,牙齿间全是黑血。

他还没有死。

体内那一滴精炼了不知多少回的冥血,正在拼命填补碎裂的骨骼,胸口塌陷之处以肉眼可见的速度鼓起,眼中的惊惧,也渐渐化作了凶戾。

远处,齐天鸿已经冲了下来。

齐轩正放出的十二道青光在他脚下接成一线,既能将他送到秦林身后,也能接住芈家老祖,带着两人一同脱离正殿上方。

他们终究不敢坐看芈家老祖被杀。

失了这个挡在最前面的人,皇帝下一拳,就要落在齐家头上。

“放手!”

齐天鸿袖中喷出大片黑雾,化作一只狰狞大口,直咬秦林后颈。

同一刻,下方残破的宫阙上,响起了一声极低的咳嗽。

顾芳还在。

他半跪在瓦砾之间,甲胄烧得只剩残片,一手压着腹间伤口,另一手撑着那柄泣血长刀。

刚才数次轰击,他都没能站起来。

这一次,他望着从头顶掠过的齐天鸿,松开了按住伤口的手。

长刀向上抬起。

没有血色盈天,也没有万千兵卒随行。

只有一道短而暗的刀光,横在了那条青光之路上。

齐天鸿猛地偏转身形。

刀光贴着他的脚下掠过,斩断了最近的一段青芒。他袖中的黑雾慢了一瞬,顾芳却已连人带刀跌回了瓦砾,腹间淌出的血,染红了身下数块碎砖。

仅此一瞬。

秦林的左拳,第二次落下。

芈家老祖刚刚鼓起的胸口彻底塌陷。金光从前胸穿入,贯过脊背,连那一层层急着长合的黑纹,都被皇道龙气从里面冲得粉碎。

一声惨叫冲上夜空。

他的元婴裹着暗血,自头顶遁出,才飞出半尺,便被等在上方的龙口吞了进去。

龙腹中黑光骤亮。

那东西还在挣扎,撞得整条金龙鳞甲翻飞。

秦林五指一握。

金龙向内盘紧,将那点黑光层层碾灭。

最后,连惨叫也没了。

半截玉尺从空中落下,插入御道的石缝,尺上沾染的黑火一点点熄灭,露出几处原本温润的玉色。

满天强者,都看见了。

芈家老祖死了。

秦林松手,将残破的尸身掷在殿前。

黑雾凝成的大口扑到他背后,被回旋的龙气击碎。齐天鸿的剑指尚未收回,便对上了那双染着血丝的眼睛。

秦林转过身。

“轮到你了。”

齐天鸿退得极快。

芈家老祖的元婴才一散,他便收回袖中黑雾,将剩余的冥血之力尽数压进遁光,向齐轩正所在之处冲去。

那十二道青光已经重新接上。

齐轩正掐诀一引,天空中同时出现了数条交错的光路,横贯皇城,分别通向不同的城门。

他不再想着反杀。

眼下最要紧的,是把人带出去。

秦林却比齐天鸿更快一步。

龙气沿宫墙向上卷起,挡住了最近的一条青光。齐天鸿强行转向,耳畔忽然响起一声低沉的拳鸣。

他回身一掌推出。

拳掌相接,整条手臂都失去了知觉。

齐天鸿惊骇欲绝,才知道方才芈家老祖独自承受的,是何等沉重的一击。没有那片共有的血云替他分去余力,身上的黑纹根本挡不住皇道龙气。

他借着倒飞之势,再次向外遁去。

齐轩正抬手接住他,十二道青芒齐震,竟将迎面压来的龙气撑开了数尺。

“走!”

两个字才出口,秦林的左拳已经压在青光上。

一道道青芒从中碎裂。

齐轩正脸色骤白,身形尚未站稳,一缕凝练至极的金光便穿过右肩,将他半边身体轰得向后翻去。

鲜血洒落长空。

齐天鸿伸手去抓,没能抓到。

秦林的手,已经按在了他的头顶。

冥血在他体内暴涌,试图从掌下冲开一条生路。那只手上的金光却向内压去,穿过护体黑雾,直入紫府。

齐天鸿的身形僵住了。

片刻之后,口鼻眼耳间都亮起了一线金色。

他张开嘴,没能发出声音,整个人便在半空崩裂。

皇城上方,又一位显露真容的世家强者,连同元婴一起消散。

齐轩正看见了。

他右肩的血洞仍在向外涌血,半边脏腑也被那缕金光震裂。这个时候,他却连回手一击都不敢,强行将散开的青光归到脚下,借秦林杀人的一瞬,冲出了护龙台合拢的边缘。

一道金芒追着他掠出城头。

远处又响起一声闷哼,青光向下一坠,随即没入城外的群山。

秦林站在空中,左手抬起,却没有再落下。

他胸前的伤口忽然涌出一大股血。

刚才被他硬压住的灰色死气,也重新浮上了脖颈。

三一四阶化神丹的药力还在经脉里奔流,已经不复初时那般汹涌。护龙台将整座皇城的力量送入他体内,同样让血肉与经脉承受着难以想象的压力。

他能再追。

但皇城留不下第二个秦林。

天上其余的强者,早在芈家老祖身死时便开始逃散。此刻齐家一死一逃,再没人肯回头争一口意气。

几道遁光撞上护龙台升起的金芒,惨叫着跌了下去。另有人狠心舍掉法宝,拼着一身伤,从尚未合拢的缝隙中钻出了神都。

秦林没有将龙气继续向城外铺去。

他五指收拢,余下的金辉转而压向宫城,将地面仍在厮杀的乱军切成了数段。

群龙俯首,金鳞映夜。

皇城里,先是一片寂静。

紧接着,龙骧卫的呼喊响了起来。

声音从一处宫门传到另一处,又传到被打得残破的长街。那些原本还在苦撑的守军抬起头,看见天上只剩下一道染血的身影,便知道这一夜究竟是谁赢了。

有人大笑,有人满面血污地跪倒。

秦林的声音落下来。

“闭宫门,收败军。敢再持兵者,杀。”

号角声随之响彻神都。

……

陈生将铁剑收回了鞘。

直到此时,他才松开一直压住左肩的手。血已止得差不多,那一片衣袍却与伤口黏在了一起,稍一牵动,仍有锐利的剑意刺入骨缝。

江明递来一条撕好的布。

“胜了。”

陈生接住,点了点头。

墨欢抹了一把脸,忽然笑出声来。

“我方才真以为,那老东西还能爬起来。”

他说的是芈家老祖。

江明朝殿前望了一眼:“爬起来,也会再被打下去。”

他说得笃定,握着剑柄的手却过了好一会儿才松开。

药监长已经在往外走。

几名龙骧卫从倒塌的宫阙中抬出了顾芳。老人的甲胄不敢随意剥下,只得连同贴在身上的残片,一并放在担架上。

那把长刀仍握在他手中。

一名甲士试着取下,没能取动。

陈生将一只小瓶抛给药监长。

“先护住心脉。刀留着。”

药监长接住药,看了一眼,神色微松,赶上去与随行医官说了几句。顾芳口中被送入一粒丹药,微弱的气息终于稳住,眼睛却始终没有睁开。

他活下来了。

这一夜几乎耗尽了他那副老迈身体里所有还能拿来拼命的东西,救治远未结束。

秦林落到宫阶上,望着担架从身旁经过,伸手按住了顾芳紧握刀柄的手。

老人的指节微微动了一下。

秦林没有说话,目送他被抬入内殿,方才转过头,隔着残破的宫墙,望向守蔵室所在的方向。

陈生正在那里看他。

两人的目光短暂相接。

随后,秦林抬了抬那只已经看不出原来模样的右手,算是打了个招呼。

陈生脸上露出一点笑。

“这小子。”

他声音很低,江明没有听清。

药监长回来时,秦林已在近卫护持下转入宫中。天上的帝影散去,连最后一缕不属于元婴境的浩大气息,也随着收拢的龙气消失了。

陈生望了片刻,心中已有了数。

秦林仍在元婴圆满。

那颗丹替他推开过一道缝,让他在这一战中看见了更高处,却还没能让他真正站稳在那里。往后的路,得由他自己走。

“陛下要见大师。”药监长道。

陈生低头看了一眼自己满身的血,又看向药监长那条烧焦的左臂。

“先把你们身上的伤收拾了。人既赢了,还能少说两句话?”

药监长这一次没有催。

他向宫门外发出一道传令,遣人去接撤出城的十二甲士,随后便让医官替自己封住左臂伤势。那条手臂已麻木许久,药力一入,疼得他额头顿时冒出了冷汗。

墨欢找了处尚算干净的台阶坐下,将小炉抱在膝上。

炉底裂了一道细纹。

他摸着那道裂纹,心疼了半晌,又抬头去看陈生袖口。

“大师,那条桃枝……”

“你想拿来烧炉?”

墨欢被说中了心思,反倒来了精神。

“仙木之属,若能取一点枯皮入火,或许——”

陈生将袖口合紧。

“等你什么时候不炸炉,再说。”

江明忍不住笑了。

墨欢瞪了他一眼,低头继续看炉,并没有死心。

陈生坐到另一侧石阶上,调息了片刻。丹药化开的暖流止住了几处伤势,亏空的法力却没那么快补回来。

宝珠仍悬在肩侧,光芒柔和,不见损伤。

他将那卷绢图取了出来。

灰白的图面展开,山峦从左至右连成一片,墨色有深有浅,越往中央,笔势越繁复。西侧缺了一角,裂口斜斜向内伸去,恰好撕断了一条绕山而行的外道。

缺口旁,完整显露的两个字,是“西岭”。

司马言取走的,就是那里。

陈生没有急着去补那条路。

他的目光沿山势向内,一直看到了图中的三重枢纽。

第一重分向群山,第二重回抱中枢,第三重却忽然收束,将前两重彼此不同的禁法,纳入同一道印势之中。

到这里,所有细小的笔画都变得极其简净。

陈生的手指停住了。

太平峰,晶碑。

那时他拿着二狗留下的陈字牌,走进隐于山中的洞府,才真正看见旧友在漫长岁月里留下的道法。

道一印。

他曾亲手结过那道印,也知道它如何以一统众,驭使诸法。

如今,图中三重枢纽收束时的转折,与他所得的传承一一相合。最深处那一笔看似断开,实则借前两重禁势续上,正是道一印极难写尽的那处变化。

不是名字相似,也不是某一缕气息碰巧相近。

黑崖的封禁里,确实有陈二狗留下的手段。

江明察觉到他的神色,走了过来。

“认得?”

“认得。”

陈生的目光没有离开图。

“找了许多年的人,终于又让我摸到一点东西。”

江明看着那片深黑的山势,问:“他还在人世么?”

陈生沉默片刻。

“我盼他还在。”

他将手指从中枢移开,落到东麓一条极细的旧道上。

那条路没有通向图心,只绕到第一重禁势的外侧,尽头画着两片相对的断岩,中间留白。

“这里可以先看看。”他说。

江明俯身,将那段山势记在心里。

“等你这只手能握稳剑了再去。”

陈生瞥他一眼。

“方才握得不稳,也杀了一个。”

“所以我说等一等,不是说不去。”

这句话让陈生笑了。

天色一点点淡下去。

过了小半个时辰,宫门方向传来甲叶相撞的声音。十二名亲卫从城外折返,走的已是城门与御道。受伤的两人躺在担架上,三只封囊由同伴护在中间,一只也没有少。

为首的甲士见到陈生,先行了一礼,随后才将种本交给药监长查验。

所有封匣都在,封印也完好。

药监长长出一口气。

他将种本带到守蔵室尚且完整的东间,留下十名能行动的亲卫守护,又遣人把两名重伤者送去救治。此时内库尚未重新开禁,谁也没有急着再去动那扇门。

陈生将虎符在掌中翻了一面,仍旧收了起来。

人和东西都回来了。

至于这块符,等见到秦林,再问他还要不要拿回去。

远处,第一缕晨光照在正殿断裂的檐角。

御道上仍有未干的血,宫城四角的号角声却已低了下来,换成整齐的军令与脚步声。

陈生收起绢图,站起身。

这一夜,他没能问出二狗的生死。

可黑崖,终于不再只是别人口中的一个名字。

第357章 你替谁留的退路

战后第三日,陈生在殿外等了半炷香。

没有礼官来教他如何入内,也没有人敢催里面那位至尊。两名侍卫守着殿门,一人甲缝中还露着新换的白布,见他望去,立即挺直了腰。

陈生移开目光,低头活动了一下右手。

手背上的伤已合拢,握拳时却仍有一线涩意。那一夜过去,他没有急着吞下大把灵丹,只将残留在伤口中的异种气机慢慢拔净。剑伤好治,带着别人剑意长好了,再想除去便要多受一回罪。

殿门终于开了。

里面先出来两位老臣,一人的袍角被烧短了半截,临走前仍回头说了一句:“陛下,猎月洲的事拖不得。”

“朕知道。”

秦林坐在一张并不宽大的案后,声音有些哑。见陈生进来,他站起半截,胸口忽然一滞,又扶住案沿。

陈生没行礼,反手将门关上了。

“坐着。”

秦林笑了笑,重新坐下。殿中只点着两盏灯,案上却堆满了各地急报,最新送来的几封还沾着灰。陈生扫了一眼,没去拿,先把一只空盏放在他面前。

“吐一口法力进去,别掺皇道龙气。”

秦林依言抬指。

一线金芒落入盏中,颜色漂亮,绕了半圈,底下却析出了一缕暗黄。陈生以细火舔过盏沿,那缕暗黄猛地一跳,像烧到尽头的灯芯,随即熄了。

“三一丹的余性。”他道,“你还想靠它再打一场?”

“有人就是在等朕坐下来喘这口气。”

“那便让别人去打。你若再强提一次,下一炉药未必赶得上。”

“下一炉?”秦林看了看他,“祖师肯炼?”

“料齐了再问。”

秦林终于笑出了声,笑到一半,又咳了两下。那一夜横压神都的气势从他身上退去了,留下的仍是一个元婴圆满的修士。修为足够高,却不是把天地间所有事情都变得容易的凭据。

陈生等他缓过来,才道:“虎符是给我留的,还是给你留的?”

秦林的笑意淡了。

“都有。”

“说清楚。”

“十二亲卫能开离宫道。若皇宫守不住,他们原应护你离开,把道藏种本带出去。”秦林停了一下,“人和书都在,元梁便没有死尽。”

“人是我,书也是我带。听来倒是一桩好差事。”

这话有些冷。

秦林没有避开他的眼神:“祖师若不肯,十二亲卫也强迫不了你。朕把符交到你手里,便是将这一条退路交出去了。”

“可你没有告诉我。”

“告诉你,你会劝我走。”

“不错。”

殿中静了一阵。

秦林指腹压着案边,缓缓道:“父皇死后,我离开过一次。再走一次,天下人未必还肯等我。”

陈生看见案旁立着一柄尚未收入鞘中的剑。剑锋上有一道细缺,握处缠的金丝也断了,帝王没有让人换掉,仿佛还准备随时拿起来。

“我若照你的意思走了,回头等到的,是不是又一道讣告?”陈生拉过椅子坐下,“你还不如早些告诉我,让我自己选带什么人、什么书。”

秦林眼中动了一下。

“朕本想,事后请祖师入朝。有你在,许多事便不必像从前那样……”

“也做国师?”

秦林没有立即回答。

陈生笑了一声,笑里没有多少喜意。

“我已经有个国师兄弟,丢了这么多年。还没找回来,再把自己填进去?”

“祖师怕这个名号?”

“怕。”陈生答得很快,“怕哪天又有人跑去广秀,告诉我皇帝死了,留一场烂摊子,让我慢慢等。”

秦林沉默片刻,忽然问:“那祖师劝朕等来日,又何尝不是替朕选?”

陈生盯着他。

那位从广秀走出去的少年,如今坐在天下最高的地方,已不是听见祖师开口就低头领训的人了。他怕死,也怕败,却有一件比保全性命更不肯退让的事。

许久,陈生吐出一口气。

“所以那夜我没有把你拖走。”

秦林压着案边的手,缓缓松开了。

“多谢祖师。”

“先别谢。我今日来,是来要东西的。”

陈生取出拓下来的图纹。

他没有将原件随便铺到满是急报的案上,只摊开其中一角。黑色山势层层叠压,乍看像是普通地形,可往深处看,几条短纹彼此牵引,隐约有诸法归一的意蕴。

秦林只看了一眼,身体便向前倾去。

“道一印。”

“像。”陈生用两指压住纸,“能认出骨架,不等于这张图就是二狗画的。你也得过他的传承,会这法门的不止他一人。”

秦林抬头:“祖师是在劝我,还是劝自己?”

陈生没有答这句话。

他当然希望图是真的,也希望那一点熟悉的痕迹通向一个还活着的人。可他已等了太久,越是快摸到一点东西,越不肯让自己因一个念头便把眼睛闭上。

“黑崖是怎么来的?”

“父皇出事那一夜,那里便成了禁地。”

“这句话,我几百年前就听过。”

秦林眼底闪过一丝疲倦,随后伸手按住眉心。

“那时我八岁。别人告诉我的事很多,有人说国师战死,有人说他弃了父皇独走。后来我坐到这里,见到的说法更多,却没有哪一个人,敢拿出他亲眼看见的最后一刻。”

陈生面色微冷。

“你信哪一种?”

“我若信他弃了父皇,便不会去太平峰。”

这句话落下,陈生才将压纸的手松了些。

“黑崖外围的地势,这些年一直在变。”秦林道,“闯出来的人讲的东西彼此抵牾,有些本是信得过的人,第二次照着自己的路进去,也没能再出来。朕能给你的,是他们带回来的见闻,不是一条直抵崖底的路。”

“我要看原样,不要替我挑过好坏。”

“可以。”

“当年的封禁、看守是谁经手,活着的有哪些,你知道多少便说多少。不知道,便说不知道。”

秦林望向图纹,隔了片刻,才摇头。

“朕知道的,未必比祖师手中这一角多。那几年留下来的东西太少了。守蔵室比别处完整些,可有些禁制,历任守蔵史也解不开。”

陈生把纸重新收起。

“那我自己去找。”

“祖师要去黑崖?”

“你以为我来问,是为了替你给群臣讲一段旧史?”

秦林没有计较这句讥讽。他转头望着紧闭的殿门,外面隐约传来又一封急报送到的声音。

许久,他道:“朕去不了。”

陈生看着他,原本准备好的话,忽然少了一句。

秦林的父亲也死在那一段旧事里。他不是不想去,只是神都刚打完一夜,帝位下还有无数双手,正等着这位年轻至尊离开。

“我知道。”陈生道。

“但朕也不能将还能动的人都派给祖师。芈氏死了一个老祖,猎月洲不会自己安静下来。齐轩正逃了,其他人还在看。”

“我又不是来替你领第二支龙骧卫。”

陈生将一张薄纸推过去,上面没有治国方略,只有几味温养灵材、远行所需的上品灵石,以及沿途可用的官道传送之处。

秦林低头,眉梢渐渐抬起来。

“祖师,这是去寻人,还是要把沿途灵脉都买下来?”

“你让我炼的岐黄丹,值不值?”

“值。”

“守蔵室的书,值不值?”

“值。”

“那便别问了。”

秦林用左手拿起笔,划去一味灵材,又在旁边补了另一种。

“前一味暂时没有,后者皇库还有。祖师自己懂药,该知道替得上。”

陈生看过,略一点头。

他没有为显得清高,将到手的修行资粮再推回去。自己要出力,要犯险,秦林坐在皇位上,自然也该出一份。

两人谈完时,灯芯已经短了一截。

陈生起身,秦林却忽然叫住他:“若真寻到国师,替朕问一句,当年他为何没有回来。”

陈生停在门边。

“你是想问他,还是想怪他?”

秦林的嘴唇动了一下。

“都有。”

陈生看了他一会儿,点头。

“等我找到再说。若他真有不是,我也会骂。”

门开了,殿外又有人躬身递来急报。陈生从那人身边走过,身后秦林的声音已经恢复了帝王的沉稳,问的第一句不是黑崖,而是猎月洲哪一座城还没有回应诏令。

他没有回头。

掌心的图纸隔着衣料,微微硌着右手的旧伤。陈生将它换到左边,顺着破损未修的长廊,往宫外去了。

第358章 东家还在

紫令堂门前搭起了半座架子。

旧匾没烧,左下角却裂开一道口,远远望去,“堂”字像被人咬掉了一笔。赵管家站在架下,正指挥两个伙计将匾重新挂高。

“再往上些。”

“赵爷,门楣就到这里了。”

“那便挂正。如今多少人看着咱们,歪着像什么话。”

陈生站在街对面听了一阵,才迈过被车轮碾碎的砖瓦。

赵管家一眼瞧见他,神情立即变了,原本竖得笔直的手指收回来,先掸了掸袖口,迎上前叫了一声东家。声音不小,足够左右两家铺子里的人都听见。

陈生看了看他的衣裳。

“换新的了?”

“旧的破了。”

“怎么破的?”

赵管家正要说,旁边扶梯的伙计便接了话:“赵爷带我们搬炉,那件旧袍子勾在架上,他没舍得扯,后来火起了,才——”

“架子扶稳!”

赵管家喝了一声,又转向陈生,讪讪笑道:“是晚辈没用,险些连一件衣裳都顾不过来。”

陈生迈进门,见正堂有两处烟熏的黑痕,靠墙的丹架空了几格,其余大体还好,问道:“人呢?”

“有两个伤了,安置在后院,都能吃能睡。丢了几瓶丹,后来没追。”

说到最后一句,赵管家声音低了点。那几瓶丹不便宜,他原想带人追回来,弗陵拦得凶,两人还吵了几句。

“没追便好。”陈生道,“你追出去,难道拿这身新衣裳去跟人拼?”

边上的伙计没忍住,低头笑了。

赵管家也跟着笑,松下一口气。随后他又挺了挺胸,说那晚确有人来打紫令堂的主意,被他站在门里喝退了。

“你怎么喝的?”

“我说,这里的丹师刚替陛下炼成大药,你们摸进来,想好怎么出去没有。”

“倒也没说错。”

“然后弗陵从后头开了半座护炉阵,炉火一亮,他们便不敢进了。”

陈生点点头,没夸他能以一敌十。赵管家知道东家听进去了,便觉得今日这件新衣确实值得换。

后院一重接着一重,几十间炉室已重新点起大半,丹火被护阵隔开,灵气在檐下缓缓流转。走近弗陵独用的那间,一股略带焦苦的药气迎面而来。

弗陵正试一张尚未做熟的三阶方,额上出汗,听见脚步也顾不上回头。炉中药液已经开始相融,底下却有一团颜色过深,像一粒烧黑的果核,越缩越紧。

陈生站到他身边,只看了片刻,便道:“火收早了。”

弗陵手指一抖,险些直接添火。

“别往那团上烧。”

陈生抓住他的手腕,稍稍抬高。弗陵反应过来,把火分到外围,先温住尚未成形的药液,再将底下那一团缓缓托起。

焦苦味散去少许。

他眼中一亮,想顺势将黑色也烧净,陈生却松了手:“这一份废了,取出来。”

“可药性还——”

“你舍不得它,它可舍得这一炉。”

弗陵咬了咬牙,以细火裹住那一团,从炉口剔出去。废液落入石盘,冒起一缕青烟,剩余药液总算安稳了。

又过一阵,他终于散去炉火,将几团残液分别收下。

这一炉还是废了。弗陵将最先生变的那一份另装一瓶,想留着拆验,手上沾的药液却忘了擦。陈生取过石盘,闻了闻那团被剔出的残药,又将盘子推回他面前。

“你这炉开得急。”

“外面催得急。”

“我刚进门,外面只有你们赵爷在催匾。”

赵管家装作没听见,拿起一块布,去擦一只早已干净的瓶子。

弗陵终于露出一点笑,随后又有些不甘:“东家,如今是个机会。”

他带陈生走到侧屋,指了指窗外。

对街原先一间丹行已经闭门,东家所属的大族被龙骧卫拿了几名主事,生意一时做不下去;另一边的铺子则打算撤走。弗陵看上的不是他们留下的炉,而是空出来的客人、药路,还有那一条原本轮不到紫令堂说话的街。

“先前我说开分部,还要慢慢挑地方。现在若不接,等几家缓过来,便没有这么容易了。”

陈生靠在窗边,听他讲完,问:“你要几间?”

“三间。”

“三间分铺,你让谁独掌三阶委托?”

弗陵顿了一下。

“可以先请……”

“能请来的三阶丹师,没个一年半载留不住。你来回跑,自己的炉还开不开?”

这句问得不重,弗陵的耳根却红了。

他不是不知道难处,只是机会一下摆到眼前,便想先抓住,再去解决后面的事。尤其东家的名声正盛,他怕自己动作慢了,白白糟蹋这场难得的势头。

陈生没有再逼问,转身去看那两位伤者。

一个手臂被梁木砸伤,一个脚踝烫得厉害,见东家进来,都想撑着起来。他看过敷的药,问了夜里痛不痛,另留下些合用的药散,便出来了。

走出去后,弗陵还站在门边等。

“先接一间。”陈生道。

弗陵抬头。

“你自己挑,也由你管。原铺的炉别搬空了,伤的人先养着。若赚得到钱,你那一份不会少;若忙不过来,便来告诉我。我能替你炼几炉,不会跟着你每一块新匾跑。”

“东家,我还想把三阶丹炼出来。”

“那你得先留得住坐在炉前的工夫。”

弗陵没有立即回答。

过了一会儿,他看了看对街,又看了看自己那只刚冷下来的炉,点头:“一间也不小了。”

他又追了半步,问道:“另外两处,我先替相熟的丹师递个话,让他们去谈,行不行?”

陈生笑着摆手:“那是他们的生意,你找我做什么。”

临近傍晚,江明与墨欢也来了。

江明带酒,墨欢带了一只新药杵。他说旧的找不到了,买新的时顺手多买一只,弗陵这里若缺便留下。赵管家一听要开席,刚准备去张罗,江明已经叫住他:“今日少几道,别摆到街上还以为你们紫令堂在庆功。”

赵管家脸上的得意收了些。

街尾那家平日送菜的铺子,昨日才挂起白布。他原定的一桌新鲜菜,也少了几样。

席摆在后院,还是他们几个人。

墨欢端酒时看见陈生的右手,立即问:“还痛?”

“拿酒没事,替你收拾炸炉便有事。”

“我已经许久没炸了。”

“那最好。”

江明在一旁补道:“那夜若让你在库门口开一炉,兴许比我说什么藏经分处更有用。”

墨欢瞪他:“你先站旁边,我可以试。”

几个人终于笑出声。

笑声过后,院外传来一阵抬木料的号子,有人走得急,碰倒了巷口的空桶,滚出老远。陈生抬眼看了一下,没起身,重新给自己倒了半杯酒。

他们谈起那一夜,起先还有几分逞强,说到谁先听错一声响、谁差点撞进自己的退路,便都没了豪气,剩下些后怕和荒唐。

弗陵没在守蔵室,也不抢着说自己经历的更险,听到后头,却忽然问陈生:“东家,你会走吗?”

席上一静。

陈生将酒杯放下:“会,去一趟外头。”

“多久?”

“不知道。”

弗陵看着他,过了一会儿,才问:“那分部的事,还算数?”

“我走我的,你做你的。”

这回弗陵真笑了,又给自己斟满一杯。

墨欢却没笑。他知道陈生去外头,多半还是为了那段一直没查清的事;他想问这回是否更危险,话到嘴边,又觉得自己总在问一样的问题。

江明看了他一眼,没有替他说。

饭后,赵管家去寻一只装旧帖的木匣,准备将这几日没来得及转交的东西交给陈生。取到后头,忽然“咦”了一声。

“还有一封,是墨老大人遣人送来的。”

墨欢立即抬头。

赵管家捧着信出来,封口还完整,背面压着一小段墨沉亲手写的字。

“前几日就到了。那时东家在宫里,后来又乱起来,一直搁在柜中。送信的人说,不是急事。”

墨欢伸手想接,看到信面写的是陈生,便在半道停了停。

陈生已经认出了字迹。

那一笔末尾略带向上的挑,正如那位老人尚在守蔵室时,给抄错的书页画下一道记号。他将信接过,没有立即拆,先看向墨欢。

“你大爷有消息了。”

墨欢点头,眼中那点难得露出的轻快,让陈生忽然不知该怎样接下一句。

第359章 未拆的信

信纸抖开,落出一张折得更小的。

上面只写着墨欢二字。

“给你的。”陈生递过去。

墨欢接得很快,退开半步去看。院里的灯照在他脸上,那一点尚未散去的酒意,被眼中的笑冲淡了。

墨沉在焚城。

他见了一位年轻时便相识的朋友,对方已不再掌家,住到城外去了,两人结伴去吃旧时常去的面。店已经换过主人,老人却还嫌汤淡,说当年一碗能吃到微微出汗,如今贵了一倍,滋味反而退了。

后头问墨欢,丹道进展如何,还会不会炸炉,别以为大爷不在神都,耳朵便聋了。

墨欢哼了一声。

“大爷就记得这个。”

他读得慢,陈生这边已看完了头一页。

写给自己的信,前半也是闲事,甚至顺手抱怨了焚城的面,后半才提到旧年。

“你先前问过国师之事,我有一桩曾经手的小事,后来想起,当面却忘了说。”

陈生的指腹停住。

墨沉曾在国师尚未失踪时,奉他的意思封过一处内库。

那时交到他手里的都是已经封妥的匣子,墨沉没有打开,也未在信中说里头有什么。他真正看见的,是国师亲自补在库禁里的一道法印。如今陈生所得的黑崖封禁图,究竟是何时入库,信里并无一字。

写到这里,墨沉在纸上画了几笔。

乍看只是潦草的曲折,落在陈生眼中,却渐渐与那张残图中的一处结构重叠起来。墨沉并不懂道一印,写得也不完整,偏偏最后一折反转的回势,与陈生自己练过的不同,更接近太平峰晶碑里那道身影。

“我只见过这一次,不敢说他做了什么。你若仍在寻旧事,可去看封存的交割副册,那卷留的是我当年的花押,不是后来重抄的总目。”

墨沉在旁边标了旧册所在,最后补了一句:“国师为何要将最后一道禁纹反过来,我没有问,他也没讲。”

关于旧事的话,只写到这里。

没有失踪之人的所在,没有一个被老人藏了千年的惊天答案。墨沉在那几笔旁落了一个小小的问号,墨迹拖得很长,仿佛写到这里,又停下来想了许久。

陈生却看了两遍。

有些痕迹,隔着转述,便会变得太像人心中希望的东西。墨沉不懂道一印,反而使那几笔生硬的线条,多了一点重量。

“写了什么?”

墨欢已经收好自己的信,凑近来看。

陈生没遮,让他看了前头那一页。墨欢认出大爷年轻时写得极凶的花押仿形,笑道:“这最后一笔,我小时候学过,怎么都写不像,他还说是我性子不够沉。”

江明在旁边道:“他是不是只想让你少炸两炉?”

墨欢正要反驳,眼角却扫到了纸尾。

那儿还有一行字,写得很平常。

“年岁到这一步,趁脚下还能走,便不多坐了。你看得明白,不必学那些人,急着替我将这一趟写成归乡。”

他的声音停住了。

院里刚才还有人轻轻挪凳,此刻那点响也没了。陈生看见墨欢的目光从字上移到自己脸上,心里一沉。

“大爷这话什么意思?”

“他这些年……”

“还有多久?”

墨欢问得很直接。

陈生道:“很难说。不是明日便会出事,但确实已经不多了。”

“你什么时候知道的?”

这句比先前更轻。

陈生顿了一下:“他离开之前,我听出来了。”

墨欢还看着他,仿佛等后面还有一句。

陈生停住。他还记得那一次宴上,墨欢说起大爷出门,神色轻快。他当时听着,只将杯里的酒喝完了。

“我以为,早知道只是多添烦恼。”

“那是你以为。”

墨欢的脸一下白了。

他平日说话温和,急起来也多半是为了丹炉,陈生很少见他这样压着声音。酒杯还在他手边,杯里剩下半口,他低头看了一眼,忽然将杯子推远。

“我还同你说,大爷出门访友去了。”

“我记得。”

“你也记得自己怎么听的?”

陈生皱起眉。

“这是他自己的选择,我没有权力替他把归期定下。”

“我问的是你。”

墨欢抬起眼来,眼中没有眼泪,只有一种陌生的固执。

“他不肯说,是他想瞒我。你听出来,也不肯说,是你觉得我不必知道。你们都替我想得好,可我连想都不用想了,是不是?”

赵管家站在廊下,不敢过来。弗陵几次想开口,又咽回去,最后只将桌上的灯往里移了一点,免得风吹灭。

陈生看着墨欢,神色也沉下来。

他不是觉得自己毫无道理。墨沉一生读史看人,连最后怎样过都要被旁人拿着寿数催促,未免可悲;墨欢困在神都跟着急,老人便能凭空多活几年么?

可是,这些话此时说出来,只会让眼前的人更冷。

“我没有想让你连最后一面也见不到。”

“但可能会。”

墨欢的回答砸得很轻。

陈生一时没有接上。

墨欢把信折好,手指因用力过度,将纸边折歪了一点。他低头抚平,重新装回信封。

江明起身,将空杯拿到一旁,没有插话。

过了片刻,墨欢道:“我要去焚城。”

“信送来已有几日,他未必还在那里。”陈生道。

“信里写了那位老友的住处。大爷就算走了,我也能问下一程。”

他将自己的那张信展开,指给陈生看。

墨欢指着城外的住处,又点了点信中另一个地方。那是墨沉仍想去看看的旧地,名字他也听过,只是不知道路程有多远。

“你还要去黑崖。”墨欢又道,“我不等你一起。”

陈生的右手在膝上动了一下。

储物袋中还收着那张残图。陈生按住袋口,过了片刻,才将手松开。

“好。”

陈生想给他取一件护身之物,手已探向储物袋,墨欢却先摇头。

“我有大爷给的东西。”

陈生将手收回来。

“路上莫逞强。”

墨欢点了点头,没有再叫大师。

他离开时,弗陵送到了门口。江明仍坐着,等院门合上,才将杯中的残酒倒掉。

“他说话不好听。”江明道。

“你不必替他说。”

“我只是说不好听,没说你听不得。”

陈生望了他一眼。

江明坦然回望,片刻后,两人都没有笑。院外的小巷已经安静了,赵管家走来收碗,尽量不让瓷盏相碰,反倒打翻了一只。

陈生弯腰捡起,还好没碎。

他把杯子放回桌上,忽然觉得这一夜,比在殿内同秦林争得更累。

次日,他去了守蔵室。

墨沉所说的旧册仍在,封皮因年深而发脆,翻到后头,果然能见一列细小的花押。陈生并未凭署名便下定论,他先看册中前后相连的事,再看当年封禁留下的旧拓。

同一处反转的纹路。

旧拓的边角已发黑,那一道反折仍清清楚楚。

墨沉当年确实参与过那次封存。

陈生取出信,又对了一遍,最后将这几页的内容抄下,收好,没有强解余下的禁制。那道法印只证明二狗曾把手伸到这里,至于此刻在何处,仍隔着一片他不曾踏足的黑暗。

从宫中出来,他在墨欢院外停了一停。

门锁着。

邻家有人说,天还未亮便走了,背着平日装药的木匣,出门前还把炉室窗关了。陈生望着那扇窗,过了片刻,取出一张短笺。

他本想写大爷的信已经核过,想想,又将笔停下。

最后只写:“若寻到了,替我问安。信仍可寄紫令堂。”

他将短笺交给回来照看的伙计。

第360章 卖路的人

江明来时,陈生已经将东西收得差不多了。

宫中送来的灵材,他用了两味温养伤处,余下的分开封存。上品灵石在匣中映出一层清光,江明看了一眼,又看看自己带来的储物袋,忽然将它收回袖里。

“怎么?”陈生问。

“本想显一显阔气,看来没必要了。”

陈生笑了一声,把匣子扣上。

“侯府那边说过了?”

“说了出门,没说跟你去哪里。”

“这回未必比留在神都安稳。”

江明将自己的剑放到桌上:“我若只想安稳,便躲回府里,谁来都笑一笑。再过几百年,别人提起我,还是汝南那位三公子。”

陈生打量他片刻。

“你想靠黑崖挣个名声?”

“也不全是。”江明垂眼看剑,“那夜我站在你后头,能说的话都说完,剩下便只能等。这个滋味,不大好。”

他说得并不慷慨。陈生却知道,越是这样平平道来,越不是一顿酒之后便会散掉的念头。

“到了那里,别见我往前便跟着闯。我有我的保命手段。”

“我又不是墨欢——”

江明说到半截停住,随即摇头,“他也未必肯跟你闯了。”

陈生收起匣子,没接。

动身前,宫中又送来几份探路修士留下的见闻。陈生原想先去东麓,两片断岩夹住的那条旧道,却早在六十多年前陷成了深谷。近年还有人沿旧址下去,带回的只有一只被压扁的护身铜环。

他将图转向西侧。

“司马言抢去的是这一角。他若也想进黑崖,多半会来这里。”

江明看了看缺口:“我们没有这段路。”

“先找个在附近走过的人。至于司马道友,若撞见了,他那一份也该还了。”

两人于是改道西岭。离开神都后,先借官道传送,后来改走山路。越接近黑崖,沿路的聚落越少,最后一段连行商都不再往前,只将货卸在远处的山镇,交给另一些人带入。

路上花了二十余日。

到断碑滩时,江明才明白,为何图上那一片被涂得像浸过墨。

没有想象中冲天的黑雾。

前方甚至还生着草,沿着低处的石缝铺开,风过时一同伏倒。再往远处,却有一片高低难辨的山影,望得久了,眼睛便觉得发涩,仿佛每一道山脊都比自己所见的位置,稍稍偏开一点。

一道古碑断在滩边,余下半截被水磨得圆滑。此时水已经退了,碑下尽是干裂的青泥。

吕七就在碑旁摆着一张矮案。

此人瘦脸,金丹气息藏得不深,右耳少了一角。他卖的不是法宝,也不是灵药,案上只压着几块薄石片,每片上面都有一道细长的划痕。

“二位想进哪一层?”

他没有先问身份,眼神从两人的剑上掠过,最后停在陈生身上。

“你能送到哪一层?”陈生反问。

吕七笑了,竖起三根指头:“先到外头那道旧禁纹,三块上品灵石。我亲自带路。”

江明拿起一片薄石,见那条划痕歪歪扭扭,连寻常地图都不如。

“三块上品灵石,就卖这个?”

“石头不值钱,走得出来才值钱。”

吕七伸手,想将石片取回,江明却按住了。

“你多久没进去了?”

“前些时候。”

“前些时候,是去年,还是上一位皇帝的时候?”

吕七笑容僵了一点。

陈生看着远处的山影,忽然取出一张小纸,只画了图中靠外的一条曲线,并未把真正的残图显露给他。

“这条路,认得么?”

吕七看过,脸上的笑意收了。

“你们有旧图。”

“认得便说,别替我说。”

“干渠。以前从这里绕,能避开上面两道禁纹。”

他指向断碑右侧,一条几乎与地面齐平的浅沟正向山里延伸。碎石和枯草将沟底盖住,若没人指出,极容易当作普通水冲出来的痕迹。

陈生心中一动。

他手中所得是图的关键内段,外沿缺着,无法将所有纹路完整接到眼前地势上。如今至少有一处旧地形得到印证,却也只是地形。

“你从这条沟走过?”

“走过。”

“带路。”

陈生将一块上品灵石放到案上。

“另外两块,见到你说的旧禁纹再给。”

吕七眼中的光亮了一下,又迟疑着看向干渠。他最终还是将灵石收起,翻手取出一面铜镜,垂在胸口,绕过断碑。

“跟紧。这里最忌自作聪明,前人脚印都未必踩得。”

江明低头看了一眼。

渠边果然有几串旧足印,深浅不同,最后都被风沙掩住。吕七没有踩那些,贴着左侧稍高的地方走,每走数步,便用鞋尖点一点前头的石面。

三人走到一道阴影前,吕七停下。

“过这里,便能看见了。”

陈生却闻到了一点焦味。

很淡,不是草木烧焦,更像一缕法力被灼散时留下的气息。他看向吕七胸前的铜镜,镜光原本圆整,此刻照到那片阴影,竟在边上缺了细细一线。

“你站着别动。”

陈生折下一截枯芦,剖去内瓤,将自己一缕法力封在其中,外头缠上细细的灵丝。手指一送,芦管沿着干渠掠向前方。

初时无声无息。

到了阴影深处,芦管却突然转了个向,既没有撞石,也未触到显形的障壁,竟沿着原路直直倒射回来。

灵丝一绷。

陈生眼中厉色骤起,铁剑抢先横出。与此同时,吕七已经惊叫,胸前铜镜光芒暴涨,照出一条原本看不见的黑线。

黑线从渠底升起,向着三人卷来。

“断后面!”陈生喝道。

江明没有往黑线上劈,他看到那根灵丝回返的方向,一剑切向陈生身后,将被牵动的几缕余力斩断。

轰的一声,干渠外沿炸起半人高的碎石。

陈生前踏一步,日熙神照体的光从腕骨上透出,铁剑压着那道返冲过来的力,猛地往旁边一撩。芦管在剑下碎成粉末,渠边一块青石被打得翻飞出去。

吕七滚到地上。

那面铜镜替他受了一下,镜面开裂,护光骤暗。江明一把拽住他的后领,将他从仍在裂开的泥缝旁拖回来。

三人退出阴影,渠中便又安静了。

浅沟还是浅沟,风吹过,枯草甚至重新伏回刚才的位置。

吕七坐在地上,半晌没有出声。

陈生将铁剑抵在他面前,剑尖上还缠着一缕正在消散的黑意。

“你说前些时候来过。”

吕七咽了一下,眼睛从剑锋移向那面坏掉的铜镜。

“我……上回走这条干渠,是七年前。”

“七年前也叫前些时候?”江明冷笑。

“修士的七年,哪算——”

陈生的剑往下压了半寸。

吕七闭嘴了。

他当然看出,眼前这位不止金丹。方才那一下,若不是对方把返冲的力挑开,他便不只是赔一面镜。可直到这时,他仍忍不住看了看碎裂的镜面,心疼得腮边直抽。

“有人今年走过,我听他说的。”

“谁?”

“一个收禁地残材的。他说沟口还能走,至于后头……”

“你没问?”

“问了,后头的消息要另买。”

江明险些气笑。

“所以你把没买到的,也卖给我们。”

吕七不吭声。

陈生望向干渠,先前图中相对舒缓的一段,在如今的地势里已经成了回折之处。进入的法力会折返,再把相连的外力拖进去。方才只试一缕,尚能抽开;若三个人一齐踏过,便不是这点动静了。

旧图不是全无用处,却绝不是拿来便能照走的路。

陈生转过剑锋,在吕七胸前的裂镜上轻敲了一下。

“还认得哪里?”

吕七抬头。

“说你自己见过的。”

“碑后有一道高石脊。前两年山势动过,那边露出了刻纹,我去取过一块松脱的石皮。”

“又是听人说的?”

“这次是真的!”

吕七急忙从储物袋中翻出一块薄石,背面沾着铁灰色的细土,正面有半道浅纹。他不是每样都卖,有些留着自己揣摩,想着哪一日能悟出点避禁的法子,往后便不必只在最外头挣这点钱。

陈生接过石片,没有立即点头。

他用一点丹火扫去浮土,那半道刻纹在火下显出深浅,末端微微回收,与残图靠近断处的一笔,竟真能相接。

只是一笔。

但比吕七卖的整条路更有用。

“带我去看。”

“钱……”

陈生抬眼。

吕七干笑:“我是说,方才那一块……”

“买你的七年前。”陈生将石片收起,“现在把今天的补给我。补不上,我们就在这里慢慢谈。”

吕七看了看他仍未归鞘的剑,只好起身。

这回他走得极慢,从断碑后绕上高处,一路避开那些看似平整的石面。陈生与江明跟在后头,没有再往干渠看。

不到一刻钟,一道狭长石脊显在三人脚下。

这里比滩地高出数丈,向前延伸不过三十来丈,尽头便没入一片灰白山影。石脊侧面有一道旧刻纹,半边被岁月磨平,半边却还留在岩石深处,阴影退去时,便泛起一点黯淡的光。

吕七指的石皮缺口就在近处。

陈生将所得石片靠过去,大小恰好相合。他将石片放回草间,发出轻轻的一声响。

“再往前,你去过没有?”

“没有。”吕七这回答得很快。

江明看着石脊:“先走到那处刻纹尽头?”

陈生没有立即应。

前方只是一小段看得见的高地,再远仍被山影遮住。陈生将图收起,又看向吕七。

“今日先别走。我若退回来,还要找你。”

吕七的脸拉长了一点,却没有再敢提剩下的两块灵石。

陈生把剑提到身侧,对江明道:“我先过。见我收剑,你再走。”

江明点头,停在断碑仍能望见的地方。

山影压在远处,遮去了禁地深处的一切。陈生沿着高石脊,向第一处尚存的旧刻纹走去。

这一次,他没有踩图上那条路。

第361章 借你一剑

陈生走到第九步,脚下的石脊忽然窄了。

先前在断碑边看时,前头还容得两人并行。如今一侧仍是坚硬的山石,另一侧却只剩下灰白的空处,连草叶的影子都没有。

他没有御空,铁剑斜垂,剑锋与石壁相隔寸许,一步步往前。

石脊尽头,那道古纹正陷在崩落的岩层中。

陈生俯身看了一眼,随即转过头,将剑收入鞘中。

江明望见了信号,沿原路走来。吕七立在后面,伸长脖颈朝这里看,却始终不肯跨上石脊。

“看见什么了?”江明问。

“一道墙。”

陈生伸手扫开刻纹旁的碎土。

石脊在此处并非自然生长的山岩。两块残石之间,还留着一道平直的接缝;一颗粗大的铜钉深嵌其中,钉头早已磨平,却仍能看见兽口的轮廓。

这是压城砖用的东西。

他们脚下站着的,很可能是一段倒下的城墙。

江明蹲下,摸了摸铜钉。

“年头够久。放在外头,寻常人只当是一块铜疙瘩。”

“放在这里呢?”

“吕七大概能卖你第四块灵石。”

陈生笑了笑,沿着那条接缝继续往前看。

灰白山影遮住了石脊的另一端。两人慢慢转过一个斜角,耳边忽然响起了水声。

一条窄河横在下方。

河水不急,也不浑,几块黑色石头露出水面,其间漂着一片破布。对岸有座坍了一半的石台,台后是两根歪斜的柱子,再后面尽被灰气盖住。

断墙在前方向下倾斜,插进岸边的灰泥。陈生沿着尚未松动的残砖下去,江明跟在后面,两人落脚之处,正有半截墙根遮着河风。

江明望了望天,又望向水里。

“这地方,下过雨?”

水面上的天光比他们头顶明亮得多。

陈生没有答,目光已停在了左手边一处石面上。

那里新添了一道剑痕。

痕迹贴着地面划过,开头极细,末端却分成了数十道浅沟,沟中还残着一丝青碧色的水气。

他抬起手,拈住那一缕水气,五指一合,将之碾散。

江明也认出来了。

“他到得比我们快。”

两人绕道取了灵材,又等陈生伤处稍复,才离开神都。司马言却是当夜逃走,携着残图赶来,此时出现在黑崖,并不奇怪。

陈生伸手沿剑痕一拂。

这道剑并非朝着前方斩去。

剑意最终折回,斩在了出剑人原该站立的位置。

“看来,他走得也不顺。”

远处忽然传来一声轻笑。

“比不得陈大师,出一趟门,还带着侯府公子作伴。”

水面荡开涟漪。

一个人从石台后走出,衣袍被风掀起,青碧长剑横在右臂间,正是司马言。左袖自肘下空荡荡的,在风里打了个卷。

他在神都吃过的亏,显然没能尽数养好。脸上血色很淡,一缕黑气停在眉心,迟迟不散。但他站得很稳,眼中甚至还有一点笑意。

陈生看着他,缓缓将铁剑抽出来。

“这一回,没有王道友替你挡了。”

司马言的笑意淡下去。

“我以为,你会先问国师。”

“你肯说?”

“你若肯把手里的正图拿出来,有些话,自然好说。”

“只怕图给了你,话就不好说了。”

司马言将剑换到右手。

“我在这里待了十余日。你想走哪条路,我未必全知道;你走哪条路会死,我倒已知道几条。”

江明忽然开口:“这些话,方才有人卖过一回。”

司马言瞥了他一眼,没有理会。

在他眼里,这位侯府三公子只是站在陈生身后的一个金丹。若在别处遇见,连开口讨价还价的资格都没有。

他重新看向陈生。

“你我拼死,便宜的只会是后来的人。你要找国师,我要进崖取一样东西,未必不能同行一段。”

“进崖取什么?”

“与你无关。”

“那我的图,也与你无关。”

两人的声音都不高。

水中的破布忽然转了一圈,朝着陈生这边漂来。

陈生手腕微动,一道剑光点在布上。

破布无声裂开。

布下却钻出一缕极细的青光,贴着水面一折,直掠江明脚边。

江明早有提防,退向墙后。陈生的剑已经横到他身前,叮的一声,将那缕青光拦腰斩断。

剑气断了,河水却涌了起来。

“退后!”

陈生话音未落,水中便升起了密密麻麻的青碧锋芒。那些剑气沿着方才他落剑的方向,一齐倒卷而来。

他抬掌打出一道法力,先将江明推回石墙后,自己脚下发力,沿岸横移。

轰!

方才立足之处炸成了碎石。

反卷的剑气没有追着他不放,撞过一次,便重新落回水中。水面复又平静,唯有岸边新翻出来的灰土,说明那一下绝非虚景。

司马言在石台上看着,终于露出了笑容。

“陈大师,这里的剑,不能随便出。”

陈生扫了眼衣角。

一截白布被剑气切去,腰侧也火辣辣地痛。方才若再慢一点,便不止划破衣裳了。

他没有回话,忽然将铁剑抛起。

斩星之光映亮河面,直抵对岸。

司马言的笑停在脸上,抬剑斜引,身形却并未避开。

星光穿过了他的胸膛。

江明一怔。

石台上的人散成一片水纹,转瞬又聚拢了。与此同时,对岸一根石柱轰然断裂,裂开的石面上,露出了一道竖着的黑纹。

陈生伸手召剑。

剑回到半途,河水再次起伏。

这一回折回来的,不只是司马言的碧海剑气,还有方才那一剑尚未耗尽的星芒。

陈生眼神终于凝重了。

玲珑宝珠从眉前升起,清光如伞撑开,将袭来的锋芒抵在三丈之外。他抬手握住飞回的铁剑,手臂被震得向后一沉。

石台上的司马言,身影又清晰了些。

他故意以剑脊在肩头轻轻一敲,脸上的嘲色更浓。

“还要再来?”

陈生凝视着那道身影。

宝珠能破惑心之术,此刻清光照过去,水中人却没有散。他心念转动,目光慢慢移到那半根被自己斩断的柱子上。

断的是实物。

被剑贯穿的人,才不在那个位置。

江明贴着墙根绕了半步,忽然低声道:“陈道友,他站着的地方,水里没有影子。”

“我看见了。”

“但右边有。”

陈生眸光一凝。

右侧水面上,一道极淡的人影正被波纹切碎。若只拿它与石台上的司马言相比,位置差了数丈,姿势也不大相同,反倒容易当作另一根柱子的倒影。

江明退到陈生身边,以手指在身后轻轻划了一道。

“他的剑,是斜拿着的。水里那把,朝下。”

陈生顺着那一角看过去。

河水深处,那柄倒垂的剑,忽然转了个方向。

石台上的司马言却还未动。

陈生嘴角微微一抬。

“原来在这里。”

司马言看见陈生的笑,心中忽然生出一点不安。

他没有等这点不安变成实物。

水中剑影先动,石台上的人影随之提剑,磅礴剑气沿着河面铺来。两岸的残石同时发出轻响,刻在石上的旧纹亮了一瞬,将这片水面衬得如同磨过的铜镜。

陈生却没有接剑。

他拎起江明,纵身退上断墙。

碧潮在脚下拍过,数十道剑气撞上墙根,碎石四散。陈生足尖一点,避开塌落之处,落在一块仍有旧纹的城砖上。

江明站稳,低声道:“他也不能离开那里。”

“暂时不想离开。”

陈生看向河水右侧。

那里才是司马言真正藏身的地方。石台上的不过是投到旧禁中的身影,借着这条残河,连他的剑势也能一并挪开。

此前在干渠,吕七领错路,法力撞上残禁,被倒送回来。

眼前这段旧城墙下,也有相近的变化,却被司马言借来藏身。陈生直接向石台斩剑,实际砸中的,是对方身前那片会折转法力的水面。

“站在原地等我把自己打伤,主意不错。”陈生道。

司马言不答,抬剑又是一击。

这一剑朝着断墙拦腰斩来。陈生避得开,江明却没那么快。宝珠清光往下一压,将江明护住,铁剑随即斩落,只断面前数丈剑势,不再往河中追。

可这样守着,终究吃亏。

司马言躲在旧禁之后,占着地利。陈生纵然找到了真身,也隔着一层会把攻伐送回来的障壁,贸然闯过去,很可能先吃自己一剑。

更麻烦的是,剑气碰撞越多,两岸那些石纹便亮得越快。

似有什么沉睡的东西,正在被他们一点点喂醒。

陈生低头望向脚下。

铜钉附近的纹路,与河对岸的石柱连在一起。其中有几笔极为熟悉,一起一伏间,竟与残图上那道反转的法印相应。

道一印。

陈生在太平峰上得此法时,曾想过自己总有一天,也能打出二狗那般压盖群雄的威势。

后来练得久了,才知两人的道路实在不同。二狗能以一印压得旁人不敢抬头,他却总要想,若一印打不死,下一剑该落在哪。

此刻他想的,仍是下一剑。

陈生将神识沉入眼前数丈残纹,没有去追更远处的气机。只是一段倾倒的旧墙,残存的法意已经压得他眉心发紧。

当年在此处留下印的人,远比他现在强。

那股力量却并非一味向内收摄。

在最末一折,它忽然朝外转去,竟给被统在其中的诸般法力,留了一道向外散开的缺口。

陈生心中微动。

“江道友,沿原路退到石脊上。”

江明看了他一眼。

“你不退?”

“我要再借司马道友一剑。”

这话没压声音。

司马言眼中掠过厉色。他已看见陈生脚下的变化,那几块原本暗淡的旧砖,正随着陈生左手结印,一块接一块亮起来。

他不知道这是什么,却不打算让对方做成。

一滴暗血从口中吐出,落上青碧长剑。

河面立刻向下凹陷。

仿佛整条河,都被这一剑抽走了水。

江明刚退到石脊前,就被扑面而来的剑意压得呼吸一窒。他咬牙伏低,双掌撑住地面,才没有被卷入下面的水光。

陈生的左手也在发抖。

结印所需的法力,比他预想得更多。那些旧砖不是他的法器,他只能顺着尚存的一丝法意,将其中两道彼此反折的纹路暂时接上。

司马言一剑斩下。

青碧光华吞没断墙。

陈生抬起头,眼前只有一片无边剑海。日熙神照体的血气从周身升起,护住脏腑,右手铁剑却没有迎向那片海。

他的左手向下按去。

一道并不宏大的印影落入城砖。

石壁轰然一震。

青碧剑气仿佛撞上了两道错开的门,一半擦着断墙冲过,一半沿着刚亮起的旧纹,倒卷回去。

陈生肩头猛地一沉,旧伤被震开,鲜血浸入衣里。

那片倒卷的剑气却已穿入河面。

司马言的神色变了。

他熟悉这段残禁,知道外来的法力会怎样回折,却未曾想到,自己借路放出去的剑,也会忽然被引回同一条路上。

水中的倒影率先碎裂。

右侧一处看似空无一物的乱石滩上,一道人影狼狈退了出来,手中长剑不断挑击,斩开反卷回来的青碧锋芒。

正是司马言。

陈生拔身而起。

他没有再向水中那座石台出剑,而是踩过两块露出水面的残石,沿着司马言仓促退避时露出的狭窄空隙,直扑乱石滩。

“你也练过这道禁法!”

司马言又惊又怒。

他横剑迎上,剑锋相撞,震起漫天碎石。

陈生左肩的血顺着衣袖往下流,右手握剑却极稳。宝珠不再远照石台,而是悬到两人头顶,将周围四散的水光一点点压住。

司马言身后,碧海再次浮现。

这一次没有旧禁替他遮掩,陈生清楚地看见,每当那片剑海要成势,司马言眉间的黑气便向下钻入一分。

冥血还在给他力量,也在不停地索取。

司马言显然顾不得这些。

他一剑快过一剑,水光沿着陈生手中的铁剑攀上来,想要绞碎那只握剑的手。陈生却踏进了他的剑势里,运转日熙神照体,肩背发力,将人硬生生撞退半丈。

这一下并不巧妙。

司马言的一线剑气割开了陈生肋侧,陈生的剑柄也砸在他胸口,将护体黑纹砸得暗了一片。

两人各退一步,几乎同时再进。

司马言要把他逼回水中。

陈生要他离开那条河。

数剑之后,乱石滩被削平了一层。司马言脚下忽然一陷,陈生已从侧面劈来,斩星的光华压进青碧剑海,将横在两人之间的水色撕开。

司马言喉头一甜,连退三步。

他本欲沿旧路重新藏入禁中,忽然听见一声唢呐。

声音不高。

甚至被碎石的轰鸣遮去了大半。

可那一声直入识海,他眼前刹那浮起无数晦暗人影,俱都朝着自己伸手。

司马言的脚步停了半拍。

陈生口边,普通的唢呐刚刚放下。

黄泉仙曲只奏了一音,识海已随之震荡。他没有继续吹,收起唢呐,向前踏出了最后一步。

铁剑与青碧长剑在半空撞出火光。

司马言用尽力气挡住了剑锋,眼底刚有一丝喜色,便见陈生空着的左掌,落在了自己胸前。

同样是道一印。

没有断墙,没有旧禁,只是一道他苦练多年的攻杀法印。

司马言喷出一口血,身形向后栽倒。青碧剑上的力道随之一松,铁剑切着剑脊落下,星光没入了他的肩颈。

乱石滩上,终于安静了一瞬。

下一刻,一道黑气从司马言体内冲出,裹着元婴,直奔河面。

玲珑宝珠早已等在那里。

宝光压住黑气,元婴在其中挣扎,脸上第一次露出了惊恐。陈生没有再开口,抬手一剑,将其斩碎。

河面晃了晃,石台上的那道人影也散了。

这一次,没有再聚起来。

陈生站在乱石滩上,过了几个呼吸,才慢慢吐出胸中的浊气。

他拄剑站了一阵,方将司马言的储物袋摄到手中,又拾起那口青碧长剑。

江明沿着他方才走过的残石过来,走到一半,便听他道:“别再往前,左边那块。”

江明连忙换了落足处。

等真正踏上乱石滩,看见陈生肩上的血,他皱起了眉。

“还能走?”

“先坐一会儿。”

陈生说着,已经在一块石头上坐下,取丹吞服。

江明守在一旁,听水声重新慢慢响起,忽然道:“方才那一剑,算是你斩的,还是他斩的?”

陈生正在封住肋侧伤口,闻言抬头。

“剑是他的。”

他将那口青碧长剑抛到江明面前。

“现在归你了。”

江明接住,剑上的残余气息刺得掌心微痛,他却没有松手。

这口剑比他自己的好得太多。尚未抹去的印记也凶得很,短时间内根本不能拿来随心驱使。

他翻过剑脊看了一眼,忽然笑了。

“看来这趟出门,总算不只会花钱。”

“先别高兴。炼不服它,别拿到我面前来吵。”

“先前我替你出主意,你便给我这点耐心?”

“主意已经算在剑里了。”

江明掂了掂新得的剑,嘴边还有一句话,终究没舍得顶回去。

第362章 撤军令

司马言的储物袋里,没有陈生以为会有的冥血。

只有三只空瓶。

瓶内干涸的黑痕,颜色一道比一道深。陈生将它们另行封好,又取出那片从守蔵室抢走的残图。

图边撕裂,几处刚添上的痕迹尚未褪去。

他把自己所得的一段铺在旁边,两处断口恰能相合。原本一直接不起来的外沿,此时终于连成了完整的一角。

江明俯身看了片刻。

“那条干渠在这里。”

陈生点头,将手指移向另一道细纹。

“我们刚才过的,是这段。”

图上有河,却没有他们方才见到的石台。那座石台的位置,本应是一条通向外侧的短甬道。

陈生抬头,看向河对岸。

司马言死后,借剑势撑起的水色正在渐渐淡去。石台依旧在,然而台上两根柱子,一根矗立,一根倒伏,和他们初来时看见的角度并不相同。

他拾起一块碎石,抛向台边。

石头穿过那片尚未散尽的微光,落到了更低处。

一座半埋在泥土里的门,显了出来。

“不是没有路。”陈生道,“是被挡住了。”

江明望着门洞,眉头并未松开。

“过去?”

陈生低头看了看自己的左肩。

血已经止住,体内却还留着一线剑意。刚才借旧禁接印,又损了不少心神,若再来一个司马言,他也不愿照样打第二场。

“先调息。”

他说完,将玲珑宝珠留在两人头顶,便在岸边盘膝坐下。

江明守了两个时辰。

其间河边光线变过两次,头顶天色却没有跟着亮暗。远处偶尔有石块落下,声音半晌才传过来;离他们更近的一片裂岩无声崩开,反倒像发生在极远处。

他没有去碰这些异象,只把通往石脊的几处落脚记在心里。

直到陈生睁眼,江明才抬了抬下巴。

“吕七怕是要等急了。”

“他若没走,回去请他喝一杯。”

“若走了呢?”

“那便省下。”

两人绕过水边,沿图中那一条短线,走向半埋的门洞。

门上本来有一块匾,已经碎得只剩半边。江明抬手拂去积土,认出一个残缺的“军”字,却认不出前面是什么。

陈生的目光落在门槛。

那里有一道极深的掌痕。

五指向内,掌根却向外,在最后一处转折上,刻出了一道逆着整个门势的回纹。

他缓缓蹲下。

墨沉信里所画、库禁旧拓上所留、残图之中那一笔看似拧错的回势,到这里,终于有了一个能够看见的去处。

这不是引人深入的手段。

掌痕连着门内的阵纹,向外分开,护住了一条极窄的过路之处。

“是让人出去的。”陈生低声道。

江明正要问,陈生已起身进门。

门后是一间不大的石室。

墙上挂着几枚破损的甲片,地面横着一杆断枪,角落里蜷着一具早已枯朽的尸骨。

甲片上的纹路,与龙骧卫的旧甲相近。

陈生没有伸手搬动尸骨,先将宝光铺在四周。确认没有随着触碰而发动的气机,才走到那杆断枪旁。

枪尾嵌着半枚军牌。

军牌上的姓名已经磨去了,余下几道刻痕,应是某一支战部的号记。江明看了很久,只认出不是如今神都守卫常用的编列。

“回去问顾芳,也许认得。”

陈生点头,拓下尚能看见的纹路。

墙后又有一道门。

两人先前只顾看石室,直到转过半面残墙,才发现那道门已被什么东西从内侧打碎。一只布满缺口的铁盔嵌在门中,盔下的石头上,有几行刻得极急的字。

江明将上面的尘土拂掉。

“奉国师令,弃重甲,由此退。”

往下,还有几个名字,字迹各不相同。

其中一个写到半途便断了,旁边有人替他补上末一笔。再往下,刀痕交错,多半已经看不清。

陈生站在那里,没有说话。

他翻过那么多旧书,听过那么多人的供述。有人称颂国师,有人说国师与大帝一般刚愎,也有人根本不肯提那个年代。

眼前这些字,却写得很简单。

弃重甲。

由此退。

他俯身看了看那具尸骨,又望向门外仍有水光浮动的地方。

这里不是二狗给自己留下的洞府。

是一条让人逃出去的路。

当年有人走到了这里,也有人没能走完。国师失踪以后,这条路与整片黑崖一道沉进了禁地,至于究竟退走多少人,眼前已经找不到答案。

陈生伸手,沿着门槛上那道掌痕比了一比。

尺寸不大相合。

他收回手,从储物袋里取出一块干净的布,将掌痕旁那几个尚未显出的字擦了出来。

“诸军去。”

后面隔着一道裂缝。

“余留。”

末尾只刻了一个陈字。

陈生的动作停住了。

那个字的最后一笔很重,斜斜贯入石中,像是刻字的人才落完笔,便已经转身,去迎另外一处涌来的东西。

江明看了他一眼,没有开口。

陈生将布慢慢攥紧,过了许久,才低声骂了一句。

“逞什么能。”

声音落在石室中,空得很。

没有人像许多年前那样,笑着应他一声。

……

两人最后没有再往里走。

甬道内侧的断面上,旧禁仍在流转。再进一步,就不是方才那一段残墙与乱石滩,陈生只以神识轻触,便觉识海一阵钝痛。

他收回神念,将取下的拓纹封好。

司马言的那一角西岭残图上,还有一处被新墨圈出的岔口,就在这条撤路的内侧。陈生望了片刻,将它与甬道中仍然流转的禁纹对了对,记下位置。

他将两段图合拢,一并收起。

这一次,至少不必再只追着一个失踪的传闻。

他们找到了当年的路,也找到了国师留在这条路上的命令。

沿石脊退出时,江明忽然问:“他若真在里面,你准备怎么进去?”

陈生看了眼左肩。

“先把这一剑养好。”

“然后?”

“找个认得当年军牌的人,再来。”

江明点点头,随着他走出灰白山影。

断碑边的光线一下明亮了许多。吕七还没走,正蹲在地上,拿一块布擦那面裂开的铜镜。见两人回来,他先看见了陈生衣上的血,又看见江明多收了一口剑,后头本想问的话便没敢出口。

陈生把一枚玉简放到矮案上。

“右侧那条干渠,至少眼下不能走。有人来问,你别再卖七年前的路。”

吕七忙点头,视线却仍在两人之间来回转。

“那……里头如何?”

陈生将那枚玉简往他面前推近些。

“这上面写的,不收你的钱。”

吕七听懂了,立即将余下的问题咽回去。

江明终于笑了一声,取出路上没喝完的酒,放到案上。

“你的。”

吕七怔了怔,看看酒,又看看陈生,一时不知该说什么。

两人已沿着断碑滩往外走。

陈生手中多了一张拓片,拓片折起的地方,恰好盖住末尾那个陈字。他将它放入贴身的袋中,与那枚从边地一路带来的旧牌收在一处。

这回,他记住了怎么回来。

第363章 老兵的刀

陈生与江明没有循着来时的路,一口气赶回神都。

两人在沿途停过几处,又在城外借了一座临水的小院。前后六周,陈生肋侧的剑伤已经长好,左肩也收了口,只在连续催动重招时,还会牵出一阵滞涩。

江明没少嫌这段日子清静。

不过,每回陈生推门出来,他不是在催炼青碧长剑,便是坐在一旁喘气,也没真往城里的风月场跑。

等两人再入神都,正殿上的破檐已经换了。御道有些石砖颜色尚新,行人却已不肯多看,更多目光,都在新近腾出来的铺面与各家门前。

芈家在神都的府门仍然封着。

江明经过时,往那两扇大门上看了一眼,没有说话。

他们去见顾芳。

老人已从内殿移到宫城西侧的一处院落。院门不大,里面没有香炉,也没有供伤者赏玩的花木,墙根靠着几副旧甲,一柄黑中泛红的长刀横在木架上。

陈生尚未迈进门,便听见里面有人吼道:“叫他们明日来!”

“将军,医官说——”

“他治我的伤,还管我见谁?”

院中,一名年轻亲兵低着头,双手捧着一卷名册。顾芳坐在檐下,外袍松松披着,露出脖颈下一片新生的皮肉。那皮肉比旁边的肤色浅,往下却有一道尚未愈尽的暗痕,一直没入衣襟。

见亲兵不动,顾芳伸手便要抓名册。

人刚向前一倾,他的胸腹间便响起极轻的一声闷咳。

亲兵立即上前。

“站那儿!”

顾芳将手撑在扶手上,等那口气顺过去,才抬头望向门外。

“来了便进来,看什么热闹。”

陈生走进院子,先看了一眼那卷名册。

“来得不是时候?”

“正是时候。这小子听那些医官的,比听我还勤。”

顾芳将名册抽过来,翻到最后,点住其中几行。

“补来的人里,有几个在山中猎过妖,见过血。叫他们来,老夫只问话,不下场。”

亲兵松了一口气,才要应声,又听顾芳补了一句:“把刀带上。”

他脸上的轻松立刻没了。

江明忍住了笑。

顾芳瞥他一眼:“练刀的人,不带刀来,难道叫我听他背兵书?”

亲兵终于领命退了下去。

院子安静下来,顾芳伸手够茶,手臂抬到一半,又压了回去。他看着杯口那一缕热气,脸色很不好看。

陈生把茶盏推近,随后取出一只丹瓶,放在旁边。

“药监长说,你嫌宫中的丹慢。”

“不是嫌慢。”

顾芳顿了一下。

“是太慢。”

他望向墙根的长刀,眼神比方才发怒时还要沉。

“刀还能用,人倒先躺下了。”

陈生将瓶塞拔开,倒出一枚赤色丹药。药香才散,顾芳便察觉到其中温养气血的厚重药力,目光随之一动。

“你炼的?”

“昨日出炉。能补这些时日耗去的气血,烧坏的经脉,还得另养。”

顾芳拿在指间看了片刻,没有问能不能立刻上阵,张口服了。

药力入腹,他闭上眼,脖颈间浮起一层淡淡血色。过了好一阵,才重新睁眼,伸手拿起了茶盏。

这回,手没停在半途。

顾芳饮了半盏,开口道:“说吧。你不是专程来给我送丹的。”

“有东西,请你看看。”

陈生取出军牌的拓片,铺在案上。

顾芳原本还向后靠着,见到拓片,身子又坐直了。

他伸手将纸拉到面前。陈生正要铺第二张,他已指住其中一道短刻。

“先看这个。”

顾芳没有去问缺掉的姓名,只沿着拓下的几道残纹往下看,指头最后停在一道半环旁。

“守渡营。”

江明抬起头。

“先帝时龙骧卫的旧编里,有这一营。”顾芳道,“这半环是水口,上面的三道短刻,才是营记。如今的编列不用这么刻了。”

陈生问:“是哪一渡?”

“牌只剩一半,地名和人名都磨掉了,我哪里认得。”

顾芳将拓片移近,又看了片刻。

“从什么地方取出来的?”

“黑崖,西岭。”

顾芳眼中的漫不经心,终于一点也不剩了。

陈生没有立刻讲门槛后的那几行字,先说了倒下的城墙、会反折剑力的残河,以及被泥土半埋的石室。

说到司马言,顾芳插了一句。

“死了?”

“连元婴一起。”

顾芳点头,让他继续。

那股理所当然的痛快,让江明想起了神都一夜。这位老人躺在这里,听到旧敌死讯,仍像刚从刀下砍落了一个人。

陈生将拓片铺开。

“奉国师令,弃重甲,由此退。”

顾芳的目光慢慢扫过那几个字。

他没有说话,按着拓片的手指却微微收紧。

江明问:“你听过这一道令么?”

“没听过。”

顾芳答得很快。

“我没随那支兵进过允泽,更没进过黑崖。这营旧号为何留在那里,里头死的是谁,别指望我凭半块铁就给你们说出来。”

他低头又看了一眼营记,再抬眼时,眉头已经拧起。

“只是,守渡的兵,若到了连重甲都得弃的地步,那渡口多半守不得了。”

陈生道:“也可能他们已经不在原来的渡口。”

“那就更险。”

顾芳用指节敲了敲案面。

“败退时甲重,扔了便能跑快些。这话谁都会说。但龙骧卫不是提着一口气往前跑的凡军,那一身甲若还用得上,能多挡不少杀招。”

“石室里还挂着甲片。”

陈生取了一张空纸,将当时记住的纹路画了几笔。细处他没有补,只画出甲片边缘相接的走向。

顾芳看完,伸指在两段纹路之间一划。

“这里,还有一道?”

陈生想了想,点头。

“那就是联阵甲。边镇也用过这一制,单件护人,成队之后,法力能顺着甲纹相济。谁被盯住,旁人便能替他扛一阵。”

顾芳将纸转向自己,另画了一条极短的线。

“可这里若被人攥住呢?”

陈生看着那条线。

皇城上空,玉尺末尾连着诸人的黑丝,蓦然从记忆中浮起。

“有人借军阵抽他们的法力?”

“我只是说,有这个可能。”

顾芳把指头收回去。

“老夫领兵,若见麾下无端脱力,偏偏军甲还连着,第一件事就是断阵。断不掉,便卸甲。甲下的人得留下,甲可以再打。”

他说到这里,忽然伸手抓住自己膝上的衣料,用力扯了一把。

衣料没有裂,他眉间反倒抽了一下。

江明忙扶住倾斜的茶盏。

顾芳骂了句脏话,将手重新放平,盯着纸上的甲纹,过了几息才续道:“可命令到了,有人未必肯脱。穿了几十年的东西,救过命,忽然叫你扔掉,一犹豫,退路就没了。”

陈生想起石室角落的尸骨。

他们只见到一人,却不知道还有多少人,连那间石室都没走到。

“若当真是冥血所为……”江明低声道。

“也别先替它认了。”

顾芳抬头。

“军阵被夺,有几种手段。有人在甲上做了手脚,有人直接夺了主阵的器物,都能出这样的祸事。你们在黑崖见到的,究竟是哪一种,要进去看。”

他早就知道冥血害人。

可军中一个判断错了,赔进去的便是一队人的命。这个一提敌人便恨不得拔刀的老兵,说到这里,反而把每句话都压得很稳。

陈生将另一张拓片展开。

“诸军去。”

“余留。”

顾芳的视线落到末尾的陈字,许久没有挪开。

院外有人走近,靴底踏过石阶。秦林停在门边,身后只跟着药监长,没有叫人先行通报。

他也看见了案上的字。

顾芳要起身,秦林抬了抬左手。

“坐着。”

右手还缠着一层薄布,露出的指尖比平日僵硬些。秦林走近,没有先问陈生的伤,也没有问黑崖里有多少宝物,只盯着那张拓片看了片刻。

“石室里,只有一具尸骨?”

“我们只走到了那里。”陈生道。

秦林点了点头。

“父皇带出神都的兵,朕想知道,后来究竟有多少人回来。”

他的目光掠过军牌的拓纹。

“下次再找到名字,带给朕。一个也行。”

陈生将拓片留在案上。

“这一份给你。”

秦林伸手接住,没有说谢。

顾芳忽然道:“陛下,补进边镇的那批人——”

“明日让你见。”

“刀也让他们带着。”

秦林终于转头,看了老人一眼。

“他们的刀可以带。你的,先放在架上。”

顾芳张了张嘴,还是没能把后面的话说出来。

江明低下头,肩膀轻轻动了一下。

顾芳看见了,冷声道:“三公子,笑什么?听说你也得了一口好剑,明日不妨过来,让我看看汝南侯府出来的人,如今都能打几招。”

江明脸上的笑一下僵住。

秦林没再留,将拓片折起,收入自己的袖中,没有交给身后的药监长。

陈生把军牌拓纹收回,连同顾芳画出的那几笔甲纹,一并夹好。

临走时,他把案上那只丹瓶推给顾芳。

顾芳伸手压住瓶子,道:“这样的药,往后还能有么?”

“灵材有,便能炼。”

“我让人去寻。”

老人没有问自己还能活几年,目光又落到了架上的长刀。

“下次,至少得能把它从这里拿起来。”

陈生走到门口,忽然停步。

“若只想拿起来,今日就能。”

顾芳望向他。

“要拿着出去杀人,就别嫌药慢。”

院里安静片刻,随后响起了顾芳的笑声。

声音仍有些哑,却比先前那几声呵斥痛快得多。

江明跟着陈生穿过西廊,走出一段,才道:“你方才分明看见他要叫我去试剑,也不替我说一句。”

“你不是嫌没人看你练剑?”

“我嫌的是这回事么?”

陈生笑了笑,低头看向储物袋。

司马言留下的三只空瓶,还封在里面。

那人在黑崖外围停留了十余日,带在袋里的三瓶都空了,却没有离开。除了夺回正图,他究竟还在等什么?

陈生记起西岭残图上的那一圈新墨。

回去,该把那处再看一遍了。

第364章 一剑之得

江明还是去了一趟顾芳那里。

老人没有拔刀,只看他使了两剑。第二道青光贴着院墙掠过去,切掉半片落叶,也抽得他丹田一空。

顾芳伸手,在剑势尚未完全散去时,将那半片叶子夹住了。

“剑不错。”

江明正要说话,便听老人接着道:“出第二剑以前,先想想第一剑能不能杀人。”

他带着剑回来的时候,陈生已经把司马言的储物袋倒空。

三只空瓶摆在窗下。

旁边是合好的封禁图,西岭的一角被镇纸压平,司马言新添的墨迹,终于不再挤在卷起的皱褶之间。

江明坐到对面,先给自己倒了一盏水。

“你在他袋里找出第二颗元婴了?”

“找出两个字。”

陈生把图推过去。

圈记之下,靠近一道水纹的地方,有两个写得极细的字。

洗剑。

此前在黑崖里,两人只顾辨路与禁势,司马言留下的那圈新墨也仅记了位置,没有逐笔去看。

江明看了一阵,目光慢慢转到自己手中的青碧长剑上。

“给它洗?”

“或许。”

陈生拿起一只空瓶,倾向日光。瓶内的黑痕已经干透,紧贴着瓶壁。

另外两只也是一样,只是痕迹更深。

“他到了黑崖,仍在用冥血。明知道伤重,又失了一条手臂,却守着那条残河不肯走,总得有个值得等的东西。”

江明将剑横在案上。

青碧剑身映着窗棂,光泽极好。只有靠近剑锷的一道凹纹,停着细如发丝的乌色。他这些时日催炼过许多回,司马言留下的神识印记已经磨掉一部分,这道乌色却仍在那里。

“我试着碰过它。”江明道,“与那道旧印不是一回事。”

“怎么不同?”

“印会挡我。它不挡,却跟着我的法力往里走。”

陈生将空瓶放到剑旁,目光在瓶痕与乌色间停了片刻。

他在舫城见过司马言以冥血催剑,又在神都与黑崖各接过他的攻伐。那一缕难以拔净的气息,并不陌生。

“先别去炼它。”

江明皱眉:“我也没打算把那东西吃下去。”

“没有叫你弃剑。你压不住的几处,先封着。”

陈生取出一张封气符,贴在剑锷旁,止住那缕向外浮动的乌气,没有去动剑中原有的锋芒。

“若圈里真有一处洗剑池,司马言想去的,兴许就是能清掉这种器染的地方。”

江明道:“洗过之后,它便听我的了?”

“你还没磨去的旧印,也等着别人替你洗?”

“随口一问。”

江明将剑收回来,伸指在剑脊上轻轻一弹。

青光微起,又被他压住。

六周里,他只摸清了几处能用的法力流路,尚做不到如司马言那般,挥剑便有碧海潮生。

眼下,他所用的还是自己早已熟练的御剑手法,能催出一线锋锐,能在近处折转,代价却很重。

方才在顾芳院中,两剑过后,他连第三剑的起势都压不住。

“至少比刚拿到手时好。”江明道。

“是好些。”

陈生看了一眼他的脸色。

“今日别使了。”

江明没有抬杠,把剑放进暂用的长匣。剑锋才入,匣中原有的禁纹便微微发颤,他不得不又加了一重法力,才把盖子合紧。

得了好剑,连个合用的匣子都没有。

想起器物铺掌柜报出的价钱,江明脸色更难看了。

陈生已经在收图。

“明日走?”

“走。”

江明答得很快。

陈生看了他一眼,笑着将三只空瓶重新封起,放回储物袋。

陈生又将图展开一次,越过那道水纹,看了看石室内侧尚未走完的路,才折好收起。

临行前,他去紫令堂取了些常用的丹材,与赵管家交代了去处。江明则将那只临时剑匣加缚了一道绳,背在了自己身后。

第二日,两人出了神都。

……

在转往下一处官道之前,江明忽然提出,要沿水涧绕一小段。

陈生抬眼看他。

“你在这里也有相熟的人?”

“有相熟的人,便不能是卖东西的?”

江明取出一块玉简,指了指其中粗略的山形。

“我问做剑匣的掌柜,他说这一带的石鳞鼋,甲性沉,能压水属器物外泄的锋气。品阶虽不及这口剑,先打个匣子护着,够用了。”

“他说有,你便来?”

“地方不值几个钱,甲才值钱。那掌柜巴不得我带一副回去,连怎么寻都教了。”

两人走到涧边,远处一座被水冲缺的石桥横在山口。桥下尽是乱石,一处深潭颜色发青,潭边几块磨得发白的岩石上,留着粗大的爪痕。

江明伏身看了看,眼中亮了一下。

潭里确实有东西。

气息不弱,尚未越出三阶。只是水深石多,若一味往下钻,很容易被拖进对方熟悉的地方。

陈生在一块高石上停步。

“甲归你,胆归我。”

江明转过头。

“你这便要分?”

“替你看着四周,不算出力?”

江明想了想,指向潭边。

“那你站远些。气息一放,它更不肯出来了。”

陈生笑了一声,敛去气机,退到了石桥上。

江明没有立即拔出青碧长剑。

他先用自己的旧剑划开潭边一块突出的石头,剑尖挑起碎石,连着数缕法力投入水中。

沉在潭底的东西动了。

水面下先出现一片缓缓抬起的灰影,继而是覆盖着细鳞的头颅。那头石鳞鼋没有扑上岸,只将长颈探出水面,浑浊的眼珠盯着江明,四足仍抓在水下岩壁上。

它比江明预想的还大。

背甲鼓起时,潭水向两侧溢出,连岸边几株树的根都浸了进去。

江明足下轻点,掠到侧面,旧剑贴着水面刺向它的颈根。

石鳞鼋忽然缩头。

一面坚硬的甲缘挡住剑锋,发出刺耳的摩擦声。江明一击未成,先向后退,前足果然紧跟着探出,重重拍在他方才踏过的石上。

碎石飞起。

江明没有让剑追进潭里,而是顺着它抬足时露出的空隙,削向足根。

这一剑见了血。

石鳞鼋低吼,身外浮起一层混着砂石的水浪,猛地向岸上推来。江明连退两步,旧剑横在身前,斩开最先打来的水势,却被后面一块藏在浪里的大石砸中了小腿。

他半跪了一下,旧剑脱手,坠在脚边浅水中。水势仍在向上涌,转眼没过了他的膝盖。

那头妖物这才出了潭。

它要把伤到自己的人,一起拖回去。

江明抬头,手已落在背后剑匣上。

青碧长剑出匣。

他没有去催自己尚未炼熟的深处禁纹,法力只沿这些时日摸清的几处流路送入,剑上便亮起了一线寒光。

这一剑,斜落在探来的前足上。

鳞甲无声分开,厚厚的血肉被从中斩断,石鳞鼋猛地向下一沉,扑来的身躯失了平衡,轰然撞向岸边。

江明胸口随之一空。

那口剑取走的法力,比在院中还多。

他咬住牙,没有急着补第二剑,左手一引,自己的旧剑自水中飞回,贴着妖物眼前划过。

石鳞鼋受了一次重创,见又有剑来,立刻将头颈缩回甲中。它向潭边转身,半边背甲高高翘起,要借翻身的力道滚回水里。

江明等的,就是这一转。

他收起旧剑,单足点在一块露出水面的石头上,纵身扑近。麻木的小腿踩不实,身形向旁边晃了一下,握剑的右手却没有松。

青碧剑锋贴着腹甲与颈甲之间的缝隙,直刺而入。

妖物猛地挣起。

江明几乎握不住剑柄,剩余法力仍被他一股压进剑里。

一道极窄的碧光从甲中透出。

翻起的背甲重新落下,砸得潭边泥石飞溅。江明被甩到数丈外,滚了半圈才停住,背靠一块石头,喘得连话都说不出来。

青碧长剑还插在妖物颈间。

片刻后,那双浑浊的眼睛不再转动,四足也彻底垂了下去。

陈生从桥上走下来。

他先看江明,再看那片尚算完整的背甲,开口道:“匣子有了。”

江明抹去脸上的泥水,咧嘴笑了一下。

“胆也有了。”

他说着便想站起来,小腿刚一着力,又坐了回去。

陈生俯身看了一眼,只是皮肉撞伤,骨头没有断,便抛给他一枚丹药,自己去取那只妖胆。

等江明能走动时,两人一同把背甲卸了下来。

甲缘有一处被旧剑划过,背面却完整。江明伸手敲了两下,声音沉闷,厚重的水气藏在内层,难怪那掌柜肯收。

他将战利品收入袋中,又走回妖物身前,亲手拔出了青碧长剑。

这一次,剑锋上流的是他自己打出来的血。

陈生站在一旁,看他用了好一会儿才将剑上的气息压住,忽然问:“还嫌只会花钱?”

“已经赚了。”

江明小心擦去剑身的血迹,没有用刚恢复的一点法力去催它。

“以前在府里,看什么都觉得能拿来。真出来,才知道一副匣子也得花心思。”

“后悔了?”

“我后悔没有早出来几年。”

江明将剑归匣,系紧绳结。

说完这句话,他自己先静了片刻。

百年前,侯位落在大公子一脉,府中还死了一位嫡子。他以为只要别再被拖进去,便算赢了一回。

可从守蔵室到黑崖,遇上真正要夺命的人,他仍得先往陈生身后退。

陈生从没有拿这件事说他。

他却渐渐不肯只这样下去。

“我也想结婴。”江明道。

话说出口,没有他原以为的那么难。

陈生把妖胆收好,转过身。

“先把你这口剑炼服。”

“我不争侯位,可没说连自己的路也不要。”

江明抬手摸了一下剑匣,眼中重新有了笑意。

“下次进崖,你找你的故人。若有我用得上的东西,我也要拿。”

“各凭本事。”

陈生说完,便向官道走去。

江明跟上来,因腿上还疼,头几步慢了些,嘴上却不肯让。

“这句话,等我结婴之后,你再说一回。”

山风从石桥底下穿过,吹散了岸边的血腥气。

青碧长剑已经重新安静在匣中。它仍有江明催不动的地方,也仍留着那道未能清去的器染。

两人没有折回神都,沿着下一段官道,继续往黑崖去了。

第365章 旧炉

在陈生与江明重回神都之前,墨欢已经到了焚城。

墨欢找到那座院子时,先听见了一阵争吵。

“七枚。少一枚,你自己下去捡。”

“你上回写信,还说五枚足够。”

“上回是哪一年?”

墙里静了一下,随即传来金铁相碰的轻响。

墨欢在门外站住了。

他赶了十余日的路,过传送阵时嫌人多,走山路时嫌风慢。昨晚入焚城,今早又问了三处,才找到信里那位老人的城外住处。如今手掌按着门,他反倒没有立即推。

里头说少一枚的人,他不认识。

另一个,却是墨沉。

“来了便进来,在门外听什么。”

墨沉的声音穿过院墙。

墨欢推开门。

院里铺着大片黑石,中央横了一张长案,案上排列着七枚银白长钉。一个宽额老人站在案后,袖口挽到肘上,正将其中一枚放到眼前细看。墨沉坐在另一边,脚旁散着拆开的布包,灰衣下摆沾了不少红土。

墨沉还在用指节敲着案沿,催邵循把挑出的那一枚给他。

墨欢喉头动了一下,先叫了大爷,才想起向另一人行礼。

“晚辈墨欢,见过……”

“邵循。”宽额老人道,“你大爷借走我一口炉,说以后把孙子送来替我扇火。人没送来,炉也没回来。”

墨欢下意识看向自己的储物袋。

邵循笑了。

“果然还带着。拿出来看看。”

墨欢却没有动。他盯着墨沉,路上想好的一肚子话,到了嘴边,先挤出来的竟是一句:“信里说你们去吃面。”

“吃过了。”

“那这些是什么?”

“取炉用的。”墨沉将最后一枚长钉放回案上,“先坐。一路过来,喝口水。”

墨欢仍站着。

邵循看看他,又看看墨沉,提起案下的铜壶,往杯里倒了水。

“你大爷这人,写信向来省字。给我的信也是,说来住两日,进门先问我留的酒还剩多少。”

墨沉抬眼:“是你先说酒喝完了。”

“怕你喝完。”

墨欢接过杯子,一口也没喝。

墨沉看了他一会儿,将面前的长钉推远些。

“小陈同你说了?”

墨欢握杯的手顿时收紧。

“你还知道我会问。”

墨沉没有接话。

“我看见你的信了。他才说的。不是他特意叫我来,也不是谁让我带你回去。”墨欢说得很快,末了声音却发涩,“我自己来的。”

院里的铜壶还冒着热气。

邵循将一只石墩踢到墨欢身后,没再插话。

墨欢坐了下去,杯里的水溅到指节上。他将杯子搁下,拿袖子擦过手,越擦越气。

“你还要下什么地方?”

“城外一处火窟。”

“去多久?”

“顺利,一两日。不顺利,再回来想办法。”

“你连剩下多少……”

墨欢忽然停了。

墨沉脸上的神色也淡了些。他拿起茶杯,吹开浮在上头的叶梗。

“我还没死。”

“我知道!”

这一声太响,案上两枚长钉微微颤了颤。

墨欢盯着杯中晃动的水,隔了一阵,才把后半句话说完。

“所以我才来找你。”

邵循转身去收器具,银钉落入木匣,发出一声又一声脆响。墨沉的杯子举到嘴边,终究没喝,又放回去了。

“今晚有地方住。”他道,“东边那间,昨儿才晒过被褥。”

墨欢抬起头。

“你知道我会来?”

“我让他晒的。”邵循在旁边道,“你大爷占了我的屋,总不能来个人,再把我挤到廊下。”

墨欢看着那扇东边的门,没说话。

过了一会儿,他把储物袋里的小炉取出,放上黑石长案。

炉腹青纹在日光下微微发亮,三足之下,却有一道细细的裂口。

邵循俯下身,只看了一眼,笑意便收了。

“谁打的?”

“神都乱的时候,挡过一下。出来后便这样了。”

墨沉伸手摸向炉底。

“哪里伤着没有?”

“没伤着。”

墨欢话一出口,便对上墨沉的眼睛。他抿了抿嘴,又补了一句:“法衣烧了两处,手上有点擦伤,都好了。炉子我没再强用。”

墨沉翻过他掌心看了看,才松开。

邵循将法力送进炉足,一线细光沿青纹游走,到炉底便断了。他曲指弹了一下,原本清越的炉鸣中,夹着极轻的一声哑响。

“外伤不算深,里面那道聚火纹却断了半截。”

墨欢的心往下一沉。

“能补?”

“能。得把炉底拆开,原先凝在里面的火意先散掉,再找相合的灵材重接。”邵循抬眼看他,“你若舍不得,继续拿丹火在外头糊着,哪日一炉药下去,连料一块赔。”

墨欢蹲到案边,朝炉底看了又看。

他此前确实想过,等忙完这一阵,再慢慢温养。

“这炉是你炼的?”

“不是我,难道是你大爷?”

墨沉道:“你当年给我时,可没说只算借。”

“你答应的事做完了,自然就不算借。”

两个人眼看又要争,墨欢抬起头:“到底答应了什么?”

邵循去内屋取出一块巴掌大的黑壳,往案上一扣。壳下露着一线青色,平整光润,与墨欢炉腹的青纹颇为相似。

“当年炼出两件炉胎。你用的这件先成,另一件大些,杂性却重,我将它放在山腹借地火慢慢淬。等取的时候,外头结了一层东西,越烧越硬。”

墨沉在旁边接道:“我去看过。一个人在外压火,另一个入内剥壳,能取。”

“你既说能取,我才把成炉给你。后来你回神都,一封信说内库修禁,一封信说等我出关。等我真出关了,你又没空。”

“那回是谁闭关前不打招呼?”

“所以这次我没走。”

邵循将七枚银钉收妥,合上匣盖。

墨欢低头,手指沿着自己用惯的炉耳,慢慢摸过一遍。

“再请一人,不行么?”

“请过,开价够我重炼一口。”邵循道,“他要的是现成灵石,你大爷欠的是现成人。我凭什么不找欠我的?”

墨沉看了他一眼。

“行了,已经来了,还要念多少回。”

邵循嘴角向上抬了一点,转身去取墙边的铜盘。

墨欢也站起来。

“我跟你们去。”

墨沉张口,他立即抢道:“不让我去,我就在后面跟着。你们总不能为了甩开我,专挑险处飞。”

“我问的是你要不要先吃饭。”

墨欢一滞。

邵循提着铜盘,终于笑出了声。

……

午后,三人入山。

火窟在一处斜下的石缝尽头。越往里走,石壁越红,脚下细沙被烧得发亮,一碰便滚成小粒。邵循取出一条旧索,系在窟外石柱上,另一端垂进里面。

“回来时别只看光,沿索走。地火亮起来,两边看着都像出口。”

墨欢应了,仍忍不住向深处多看两眼。

窟底没有铺满火海,只有两道火流,一赤一暗,绕着中央的黑壳缓缓游走。每逢赤火从上方掠过,黑壳便亮一瞬;暗火随后卷来,光又沉了下去。

青胎就在壳内。

邵循早年敲下的那一片,留下了指头宽的缺口。墨欢看过去,先瞧见一抹莹润青光,再听见隐隐的金石颤鸣。

“这东西还在自己纳火?”

“所以没给泡坏。”邵循道,“只是再放下去,里头未必先成器,外头倒先成山了。”

墨沉在窟口坐下,将铜盘搁在膝上。

他食指向下一按,盘中七道浅槽依次亮起,映在窟底,化作一张低垂的光幕。赤火的流速明显缓了下来,原本翻起的火舌也被压低。

邵循立即下去。

银钉从他袖中飞出,分落黑壳四周,钉尾一颤,壳面上便裂开一道细线。他手中多了一柄弯刃,贴着细线探入,缓缓向上挑。

墨欢站在靠近出口的石台上,护着牵引旧索。

黑壳挑起不过寸许,底下那股暗火忽然一缩,沿裂口钻了进去。

青胎的颤鸣立刻尖了。

“先退刃!”墨沉道。

邵循的弯刃已经抽出,仍被暗火燎去一点锋芒。那火并不向外冲,反贴着青胎向上爬,所过之处,刚剥开的黑壳又黏了回去。

墨欢皱起眉。

“它在吃散出来的杂气。”

邵循向上看了一眼。

“瞧出来了?”

墨欢没答。他从指间分出一缕丹火,越过石台,往那道暗火边沿一触。

丹火骤然拉长。

墨欢手腕一震,立即切断了法力。失去牵引的火丝飘下去,没入暗火,连一点声响也没有。

他脸上有些发热。

“不是让你去试它的根。”邵循道,“只看裂口边上爬出来的细焰。里头那股,留给我们两个。”

墨欢点头,换了位置,沿石台蹲下去。

墨沉继续压着铜盘,另一只手缓缓抬起,将翻卷到邵循脚下的赤火拨开。老人的袖口纹丝不动,盘上七道光却渐渐有了深浅。

邵循再试一回,敲落了一块黑壳。

黑壳尚未落地,暗火便沿着新缺口涨起。墨沉眼睛微眯,掌中光芒骤然一盛,硬生生将它压回了半寸。

就是这半寸,让邵循收回了弯刃。

“先上来。”

墨沉说完,咳了一声。

墨欢立即回头。

那声咳嗽并不重,墨沉握着铜盘的手却在轻颤。他没有松手,等邵循沿索攀回,才一点点收起光幕。

赤火重新漫过窟底。

“今日到这里。”墨沉道。

邵循提起器匣,没有争。

三人退到洞外,日头还没有落山。山风吹来,墨欢这才觉出背上湿透了。

墨沉在石边坐下,取出一粒丹药服了,闭目调息。邵循将挑出的黑壳放在脚旁,弯刃搭在膝上,慢慢磨掉刃口焦痕。

墨欢看着那块壳,忽然道:“那道细焰能引开。”

邵循没抬头。

“往哪里引?”

墨欢取出自己的丹炉,摆在地上。

墨沉睁开了眼。

“这炉已裂了。”

“所以我没说现在下去。”墨欢抬头,脸上还留着方才被吞掉丹火的不服气,“先把壳上的余焰试明白。明日你们看着成,再用。”

邵循停下磨刃。

他从储物袋取出一道铁箍,绕着炉腹收紧,避开原有青纹,将炉底裂口暂时箍住。

“只许试这一块。能进炉,不等于收得住。”

墨欢已经在看壳上的火。

他将指尖丹火捏成一只小雀,没有往暗焰上撞,而是贴着炉口飞了一周,逐渐收窄两翼。

黑壳边缘的一线灰焰晃了晃。

片刻之后,它终于离开了壳面,颤巍巍地朝青纹炉挪来。

墨欢屏住呼吸,连额前垂下的头发也顾不得拨。

邵循的弯刃就此停在膝上。

墨沉看了一阵,将手边的药瓶盖紧,没再催他回去。

第366章 还烧得起来

那缕灰焰进炉之后,墨欢整整看了一个时辰。

它不肯安生,初时在炉腹里游,后来又沿着青纹往下钻。墨欢以丹火拦了三回,越拦,它便越往被铁箍束住的裂口挤。

第四回,他没有堵。

他将炉里几缕原有的火气分开,一缕送到左侧,一缕悬在炉口,那道灰焰略停了一下,竟追着左边去了。

邵循伸手,按住了墨欢将要落下的下一道法诀。

“它追的什么?”

墨欢盯着炉内。

那些火气平日混在一起,烧炼灵药时,几乎察觉不出分别。如今被他拆开,沾着旧药杂性的那一缕先让灰焰追住,其余几缕却仍在原处。

“燥气。”

他说完,将一小片干枯药根抛入炉中。

药根燃起,灰焰立即绕了过去。墨欢再以丹火拂过药根,带走烧出来的一点焦气,那灰焰便跟着离开了炉壁。

他眼睛亮了。

“不是非得压住它!”

“你现在才知道,便这么大声。”邵循嘴上说着,自己也向前凑了些,“药根再少一点,看它肯不肯走。”

两人一蹲一坐,围着炉子试到日头落尽。

最后,墨欢以三缕细火将灰焰引到炉心,轻轻一旋,灰焰跟着盘起,终于不再去钻炉底的裂缝。

他刚要回头叫大爷,见墨沉正闭目坐着,便将那一声咽了回去。

邵循拿起铜盖,缓缓封住炉口。

“回去。”

墨欢抱炉起身,忽然道:“前辈,你那口青胎,里面能不能也先留一点杂性,不急着剥净?”

邵循脚步停了。

“说下去。”

“下面先留着,让主火有东西可吃。上面剥一层,我便引走一层散出来的燥气,细焰随它走,便不再往新口里钻。等炉胎提离火根,再将底下那层刮掉。”

邵循看了他一会儿,忽然伸手,重重拍了拍他的肩。

墨欢被拍得一个趔趄,险些把炉子送出去。

“手上有东西呢!”

“瞧见了。”

“瞧见还拍!”

邵循已经笑起来,转头向墨沉道:“你这孙子,比你会还债。”

墨沉睁开眼,撑着石头站起。

“明日试过再夸。我听他这样说过不少回。”

墨欢抱紧了炉子。

“这回不一样。”

第二日入窟之前,墨沉将铜盘递给邵循看了一遍。

昨日留下的两处灼痕已经磨平,盘中灵光重新连成一片。他试着送入法力,七道浅槽一同亮起,颜色却没有维持太久。

墨沉收手。

“不能再像昨日那样硬压。盘上最外一圈暗了,你就得退,别等我叫。”

邵循点头,在腰间多缠了一道牵引索。

墨欢看着两人,将准备好的药根片一一摊开,分成大小不同的几份。他昨夜另取了壳上的少许残焰,试出了它肯随燥气移动的大致快慢,真到了窟里,仍不敢照搬。

“我若说收炉,你们也得等一等。”他道。

邵循向他摊手:“那就别等它炸开了才说。”

墨欢没有还嘴,把最后一份药根收进袖口。

入窟,铜盘亮起。

赤火被墨沉压下,邵循沿索落到黑壳旁,先将七枚银钉重新打稳,随后没有往昨日的缺口动手,而是绕到上方,削出一片浅浅的薄壳。

一缕灰焰果然循着逸出的燥气爬来。

墨欢蹲在侧面石台上,小炉安放在两脚之间。他并不去触窟底的暗火,只等灰焰离开火根,才送出一只丹火小雀,口中衔着被炼出的药气。

灰焰向上浮了寸许。

小雀退,灰焰随。

邵循屏住呼吸,弯刃在这时挑起,将第一片薄壳完整地剥了下来。

青光露出,没有重新黏住。

墨欢嘴角一翘,法诀却没停。他将引来的细焰送入炉中,沿着昨晚试好的方向绕了半圈,让它跟着药气缓缓游走。

“再来。”

邵循已经换了刃口。

第二片,第三片。

黑壳褪去,青胎露出的部分越来越多,原来竟是一口长腹圆肩的炉。邵循早年留在胎上的浅纹一一显露,有些已经被地火磨淡,有些却含着光,细看如游鱼在碧水里转动。

邵循用指腹抹去一道浅纹上的焦灰,眼底露出喜色。

“没白养。”

墨沉在上方道:“看完了便做事。”

“我自己炼的东西,还不许多看一眼?”

邵循嘴上不让,手里的弯刃却快了几分。

墨欢炉中的灰焰渐渐多了。

最初那一点像灰线,如今已聚成一团,追着炉心的燥气打转。他换上第二份药根,额头的汗滴到炉沿,嗤的一声消失。

铁箍热得发红。

墨欢碰了一下炉身,立刻抬头:“先停。”

邵循当即收刃,墨沉也将铜盘向旁边稍稍挪开,让邵循有一处可以暂立的空隙。

墨欢并指划过炉沿,将自己那一缕丹火从灰焰里抽出。失去引头的灰焰向外一扑,被他投入的最后一点燥气牵了回去。

他趁这一缓,换了一道更细的火丝。

灰焰重新随它盘起。

只是炉底传来了一声极轻的响。

墨欢低头看去,铁箍下原有的裂缝中,多了一丝红光。

“炉子撑不住了。”墨沉道,“邵循,上来。”

“还有最后一圈。”

“那便明日。”

邵循看看青胎,终于将弯刃插回腰间,双手掐诀,准备重新落下银钉,压住已经剥开的壳口。

墨欢却看见了炉胎底下的东西。

那里仍留着一圈黑壳,正是他们有意没剥的部分。暗火沿黑壳游走,原本能分开的几道细流,此时已经逐渐合拢。

青胎一经松动,下方的火根也跟着挪了。

“不能再往回坐。”墨欢道,“落下去,底下便封死了。”

邵循神识扫过,脸色一沉。

他们能退,这一炉胎却要重新埋进火里。再来时,得连移过来的火根一起剥开。

他抬头看墨沉。

墨沉按住铜盘,半晌,吐出一口气。

“我再压一回。你把炉胎吊起,别管外面剩多少。”

“大爷!”

“你收炉,上来。”

铜盘光芒忽然垂低,七道细光拧在一起,化作一道青环,压住了正在合拢的暗火。

墨沉脸上的血色随之一褪。

邵循双掌托起,七枚银钉同时离地,带着未尽脱的黑壳,将青胎缓缓提起。

窟底发出一阵低沉的轰鸣。

墨欢已经把炉盖扣在手中,却迟迟没有落下。

青胎离开原处,剥开的肩口顿时涌出一蓬灰焰。邵循的元婴法力大半压在胎体上,分出的一股未能收住余火,灰焰向上窜去,撞向铜盘垂下的青光。

墨沉的手又颤了。

墨欢一咬牙,将炉口朝那蓬灰焰转去。

“往我这边拨一点,细的!”

邵循看见他掌中疾转的丹火,没有将整片灰焰推过去,只从边缘拨出细细一缕。

墨欢将余下的药根全抛入炉中。

燥气陡然浓了。

那缕细焰被他扯进小炉,带着其后游离的灰焰偏开半尺。墨沉盘下的青光终于不再摇晃,邵循趁势向上一提,青胎脱出了最后一片黑壳。

清越的炉鸣响彻石窟。

墨欢的小炉却在这时裂了。

铁箍的一端崩开,一小块炉底脱落,落在石台上,红得像炭。

墨欢眼睛一跳,先伸脚将裂片踢远,随即翻转炉口,斩断丹火与灰焰的牵连。失去引头的余火从缺口泄回窟底,他不再去收,将残炉夹在臂下,抓住牵引旧索,借上方传来的力向后一跃。

身子才离石台,赤火便重新漫过方才立足之处。

邵循护着青胎,已退到出口。

墨沉右手拽着旧索,将墨欢拉到身边,左手翻过铜盘,断去最后一道法力。

火声一下大了。

三人沿索退出长长的石缝,直到山风重新扑在脸上,墨沉才松开手,坐在了地上。

墨欢抱着少了一块底的小炉,胸口起伏不止。

邵循把青胎往远处一放,先来摸墨沉的脉门。墨沉摆手,没摆开,索性由他按着,自己取药服下。

“要闭目养一阵。”邵循道。

“知道。”

“你别说知道,转头又起来看炉。”

墨沉闭上了眼睛。

邵循这才回头。

十步之外,青胎立在岩石上,周身热意蒸腾,露出的胎纹随着风声微微亮起。它还没有炼入最后的禁制,也没有器灵宝光,炉口落下的一缕山风,却已经带出了清清楚楚的回鸣。

邵循走过去,绕了两圈,忽然大笑。

“出来了!”

他伸手一拍炉肩,掌心顿时被烫红,还是没收住笑。

墨欢望着那口青胎,嘴角也忍不住往上抬。他把自己的残炉往旁边一放,凑过去看那一道最先露出的胎纹,越看眼神越亮。

“这道纹,纳火时是往下走的?”

“当然。你那口炉,也是这么走的。”

“我的没这样深。”

“你用的那口,要是刻这么深,第一回炼丹,火便收不住。”

墨欢正要再问,忽然想起地上的炉,脸上的笑又垮了一点。

邵循蹲下,将破炉翻过来,拿指节敲了敲已经裂断的炉底。

“青纹也断了。”

“还能补么?”

“要重接,就得先化去大半旧材。补完也不是原先的样子,眼下我手里还缺一味料。”

墨欢默了片刻,将松脱的铁箍从炉腹上取下,塞进炉里。

“先留着。”

邵循点头,又朝炉口看了一眼。

“你倒还留了点东西。”

炉耳与口沿之间,藏着一粒灰白的小火星。

它是最先被墨欢引入的那缕余焰,一路跟着丹火盘旋,散去大半燥烈,如今附在旧炉纹里,只有豆子大小。墨欢以细火引了引,它迟缓地动了一下。

“不是下面那股。”邵循道,“只剩点火性,能不能养住,还得看你。”

墨欢已经取出一只平日存丹火的石盏。

他将火星移进去,又放了一点炮制过的药根屑。灰白的光慢慢伏低,没有熄。

墨欢捧着石盏,看了许久。

“回去我要试一炉。”

墨沉坐在石边,眼睛没睁,忽然道:“先吃饭。”

“知道了。”

……

他们在山外歇到次日,才回邵循的院子。

墨沉没有去看新取出的青胎,进门便回了屋。午后醒来,他坐在窗边喝粥,一碗尚未用尽,听见院外有人说笑,已经困得将勺子搁在碗沿。

墨欢原本想端一碟咸菜进去,站在门口看了一会儿,将步子放轻,把碟子放在他伸手就能碰到的地方。

回到廊下,邵循正将一口灰陶炉摆在石台上。

“二阶的。先借你用,别再把账记在你大爷头上。”

墨欢伸手一探,炉中只有三道浅浅的聚火纹,法力走到中段便开始滞涩,和自己的青纹炉差得很远。

“你平日也用这个?”

“煮药汤。嫌弃便还我。”

墨欢已经把炉抱到自己面前。

“谁嫌弃了,我认认路。”

邵循没走,拖过石墩坐下,看他准备灵草。

墨欢先拿寻常丹火温炉,摸清三道聚火纹的轻重,再把那粒灰白火星放在一旁,只引极细的一点进来。那点火一遇药气,便向炉底偏,他立即截住,另外分出细火,将尚未炼开的硬根翻了个面。

邵循看着看着,伸手将旁边一株灵草推近。

墨欢瞪他:“还没到。”

“我又没往里扔。”

“你别伸手。”

邵循把手收进袖里,向窗内看去,笑得肩头轻动。

这一炉收得很慢。

灰陶炉蓄不住火,墨欢不得不将原先一气呵成的几处,拆成先后两次。药液将合时,他原想再追一分火候,手指抬到半途,想起炉底那三道粗浅纹路,终于没有硬加。

一颗碧生丹滚进玉盘。

丹面不算无瑕,边上逸着一点药气,墨欢拈起闻了闻,皱眉,又看了一遍。

“炉差点意思。”

邵循伸手要拿,墨欢飞快收进瓶中。

“给你看,又没说给你。”

“我替你摸了半日火势,连颗二阶丹也舍不得?”

墨欢将玉瓶压进袖里,自己先笑了。

屋中,墨沉不知何时又醒了,正坐在窗后看着他们。

墨欢看见他,将那瓶丹举了一下。

墨沉点了点头。

“还烧得起来。”

“当然烧得起来。”墨欢道,“换口炉,又不是换个人。”

傍晚,邵家有人进城送东西,墨欢拦下问了去处,随后回屋,写了一封很短的信。

收信处仍是紫令堂,信面写陈生。

“大爷找到了,尚在焚城,我先留下。

“瞒我的事,我还在生气。

“但你若寄信,我会看。地址就用大爷上回写的,别只报一句无事。”

墨欢写完,把纸翻过来,原想添上取火时试出的那几步,写了两字,又停了。他重新取了一张纸,将今日如何试火、旧炉坏在何处,另记下来,夹进自己的丹札。

信封好后,他亲自交给将要进城的人,付了传信的灵石。

屋里传来碗勺轻碰的声音。

墨欢回头,见墨沉已经将剩下的粥喝完,正去够那碟咸菜。他走回去,把小桌往近处挪了半尺。

“明日还去不去吃你嫌淡的面?”

墨沉看了他一眼。

“看明日有没有精神。”

“那我早上不叫你。醒了再去。”

墨沉夹起一片咸菜,缓缓点头。

院中,邵循又揭开了罩在青胎上的布,拿着灯,贴近炉肩照了起来。

第367章 洗兵池

吕七换了一张桌子。

原来的矮案被收在断碑背后,缺了一条腿,新桌却高得不合用。他蹲着擦镜子,听见脚步便抬头,话到嘴边,看清来人,又咽了回去。

“那口剑,能用了?”

他看的不是陈生,而是江明背后的长匣。

“能。”江明道。

“几招?”

“两招。”

吕七一时分不清这是真话还是消遣,目光移向陈生。陈生的衣裳换过,左肩不再洇血,站在滩边看山的神情,却同上回离开时差不多。

“还从那条石脊走?”吕七问。

陈生点头。

“前些日子,里面有响动。半夜,像铁甲拖过石头。我没进去看。”

“哪边?”

吕七抬手,指的是他们上回进入的灰白山影。

陈生将这句话记下,没有再买路。江明走过桌边,敲了敲高出许多的案面。

“再垫半块砖,往后便能站着做生意了。”

“站着累。”

“那你这张桌子买亏了。”

吕七看着他背后的剑匣,想起那一壶酒,终于敢笑了一声。

……

石室里的断枪还在原处。

陈生没有再去拂那几行字。他将玲珑宝珠升到头顶,清光照见内甬道的断面,密密的旧纹正在岩层中收拢,堵着往里的一段路。

上回神识撞在这里,像针扎进头颅。这一次,他只将法力凝在指尖,沿门槛的掌痕,找到朝外回转的一小段。

先前见到的退路,确实仍向外。

可维持这条退路的力量,另有来处。掌根下,两条更细的刻线钻进墙内,一直延到甬道尽头。

陈生取出封禁图。

司马言圈出的岔口,在其中一条细线旁。新墨压着旧线,几乎遮掉了这处看似不起眼的转弯。

他没有将整道法印灌入山壁,而是以指尖推住那一小段回势。外出的气机稍稍转开,门内随即露出一条倾斜的缝。

半人宽。

陈生额角渐渐见汗,抬手把宝珠移到缝中,自己侧身走过。江明跟上,袖子擦着石壁,传来一股冷硬的麻意。

两人通过后,陈生松开手。

身后的禁纹重新合拢。他看着墙脚露出的两块石台,一块在内,一块在外,分别刻着回转的短纹。

“内边也有起手处。”陈生道,“回来时从这里开。”

江明将位置记住,才转身看前面。

岔口并不远。

右边是一条向上折起的石阶,阶上压着灰气;左边则传来滴水声,石面湿润,沟槽中有一点青光缓缓向下游去。

青光像极了司马言的剑气。

陈生俯身,将其中一线拈起。那点残力立刻散了,皮肤上却留下极淡的一道黑痕。

“他至少到过这里。”

再往下,黑痕便断了。

一截烧焦的左袖挂在石棱上。陈生认得那件法衣,当夜司马言断臂后,空袖在风中甩过,袖边有一条银纹。

如今银纹已经烧得发黑。

石道尽头,隐约有甲片相撞的响声。

陈生抽出铁剑,江明也取下长匣,手却先握住了自己惯用的旧剑。

他们走到水声最响的地方,一口圆池出现在眼前。

池沿足有十余丈宽,水色银白,底下却沉着黑漆漆的重甲。甲片一层压着一层,间或伸出断裂的刀柄、枪头,仿佛有人把整座军营的兵器倒进了这里。

池壁上刻着两个大字。

洗兵。

那字的下半已经泡在水中,笔画边缘结了厚厚的白垢。

陈生的目光越过池面。

对岸有一道拱门,门前站着一副完整的黑甲。甲领中空空荡荡,右手却握着一柄长柄军刀。水一动,黑甲身上的鳞片便跟着开合。

“里头没人。”江明低声道。

陈生还未回答,黑甲已转过头。

一团暗红光华从空盔中亮起。

“列阵。”

声音像从一口坏了的铜钟里发出来。

池中的甲片纷纷立起,银白池水剧烈起伏。两道水流冲上池沿,裹着碎甲,直扑甬道。

陈生一剑斩去。

斩星的光将迎面的水流断开,甲片也碎了几枚。水中却忽然伸出一条黑线,卷住尚未散尽的剑气,抽回池底。

那副黑甲身上,立刻多亮了一层光。

几副残甲从水中托起,连成一线。黑甲踏着它们越过池面,走到这一侧池沿,脚踝后的六条粗线也从池底拖了过来。

长柄军刀横扫而来。

陈生带着江明退向侧墙,军刀劈进方才立足处,石道被犁开一道深沟。刀上没有寻常修士变化精巧的刀意,只有沉重得惊人的法力。

顾芳说过的话,在陈生心中掠过。

重甲联阵,法力共用。

他看向水里。先前沉着的重甲此时半浮半沉,每一副甲背后都有细细的黑线,交缠着接到那副黑甲脚下。

那些线不在甲外。

它们穿过了甲中原来留给活人的地方。

陈生眼神冷了下去。

一团新的血色从黑甲腹中升起,沿着甲叶向上流。相同的气息,他在允泽见过,在祭祀的山谷也见过。

冥血道。

这口旧军池里,如今还留着它的东西。

黑甲再次挥刀,满池残甲也跟着抬起兵器。陈生以宝珠清光抵住迎面两刀,右手剑锋贴着墙面掠出,斩在黑甲膝间。

铛!

火星迸溅。

膝甲被斩裂,那道裂缝却立刻被水中涌来的黑色填满。陈生手腕一震,左肩深处也有些发涩。

他压下法力,退开半步。

再硬斩几次,这副甲未必散,池里却会先填满他的剑气。

江明伏在一块断石后,忽然道:“它走不过来。”

黑甲已经追到池沿,军刀足以扫到甬道,脚却没有再往前迈。

脚踝下,六条比旁处更粗的黑线,从水中拉得笔直。

陈生看了一眼,嘴角微微扬起。

“那就让它过来。”

他收回铁剑,向前一踏。

日熙神照体的血气从肌肤上升起,映得白水一片赤金。陈生避过刀锋,右掌轰然落在刀杆中段,将长柄压向一侧。

黑甲身形微晃,池中的黑线齐齐收紧。

陈生不等它站稳,肩背发力,再往前撞了一步。

军刀劈中了宝珠清光,震得他胸腹一闷。他借这股反震抓住刀杆,将黑甲从池沿拉出半尺。

那六条粗线,终于从水面下绷了出来。

“江明!”

江明已跃出断石。

旧剑先点开一片袭来的残甲,新得的青碧长剑才从匣中升起。他没有铺开剑海,只把积攒的法力尽数压在剑尖。

一道短促的碧光,斩向最外侧的黑线。

线断了。

其余五条猛地一抖,其中两条交叠到一起。

陈生向池侧转身,拖着刀杆,将黑甲再拉出一步。他的肩口传来一阵撕扯般的痛,手却抓得更紧。

青碧剑再落。

这一下比方才短,也比方才重。

交叠的两条黑线同时崩开。池中残甲轰然沉下一片,那副黑甲上原本连成一体的红光,忽然从腰间断开。

江明脸色发白,新剑斜坠,他抬手收回,立即贴着墙退去。

余下的碎甲已经朝他涌来。

陈生松开刀杆。

失了拉扯,黑甲的军刀立刻转回,要将面前的人连肩劈断。

可陈生的左掌也在这一瞬落下。

道一印打在它胸前。

这一次,印光压住的是甲叶中不断游动的杂乱法力。黑甲胸口停了一息,铁剑已经重新入手,从甲领穿了进去。

斩星的光,自空盔中贯出。

黑甲没有血。

它体内却有一团裹着暗红碎屑的黑影,尖啸着沿剑身扑来。

玲珑宝珠清光收拢,只罩住两人之间的丈许。黑影撞在其中,扭动了几下,仍想钻进甲片,陈生已横剑一切。

黑影碎了。

满池的残甲同时落下,掀起一片白水。

长柄军刀失去支撑,磕在池沿,断成了两截。

陈生站稳,先看向江明。

江明还握着旧剑,剑尖抵在地上,见他望来,抬起另一只手,竖了两根手指。

“两招。”

陈生呼出一口气,笑了。

“够了。”

……

那副黑甲散开后,里头落出几截朽骨。

陈生将它们收在断甲上,搬离池边。甲内还夹着一张薄薄的铜片,铜片上刻着半个姓,已经不能辨全。

他没有去猜那是谁。

池水渐渐平复,靠近泉眼的一小片银光却越来越亮。与别处沉浸的黑色不同,那片水中有几块乳白石块,细小的水珠从石隙中滚出,将附近的残余黑痕往外推去。

“池里有东西。”江明道。

陈生蹲在池沿,看了片刻,又以一道细火裹住滚到近处的白石碎屑。

碎屑没有熔化,火中掺的一丝甲上异力,却先被它吸了过去。

“濯灵石。”

他在守蔵室读过炼器辅材的记述,这种石头少有整块出世,常附在灵泉底部,泉洗火炼之后,能分去器中外来杂力。眼前这几块已积养到四阶,外层虽受浸染,石心仍是干净的。

司马言图边写的洗剑二字,到这里才有了一个实际去处。

陈生看向来时石道。司马言留下的剑痕断在那一截焦袖旁,再往池边,就一丝也找不到了。

那人找到过这里,却未曾越过这口池。

陈生将宝珠留在两人近处,取出乌玄炉。

池边没有安稳的大块地面,他就将炉足嵌在方才劈出的石沟里。细火贴着池底流过,裹住三块濯灵石,从残甲之间提了出来。

一块石心已经黑透,被他弃在外侧。余下两块则被分开,先剔去外壳,再以水云柔控火法护住石心,免得刚离泉眼的灵性被猛火逼散。

江明等气息稍复,将青碧长剑放在炉边。

“它能洗?”

“附在剑脊的冥血气,能试着剥开。旧主的印记,你还得自己磨。”

江明将剑往他面前推了推。

“那就先洗外面,里头的我慢慢磨。”

陈生将剔下的一片石壳放进小盏,汲来泉眼附近的清水,温了片刻。水化成薄薄的白雾,被他引到剑脊那几处黑色上。

青碧剑轻轻颤鸣。

黑色先缩成细丝,再被石片带入盏底。陈生换了两次石片,便收了火。再深处的异气缠在残印里,继续强洗,损的就未必只是附着的气息。

江明握住剑柄,试着抬了一下。

这次掌心没有那股阴冷的刺痛,剑中的沉重却仍在。法力一入,方才消耗过大的经脉便空得发疼。

他立即收剑入匣,嘴边已有笑意。

“这两剑出得值。”

陈生将两块石心分别装好,也有些快意。

较小的一块归了江明,另一块则留在乌玄炉中温养。陈生收好铁剑,趁池水回落,又将方才拣出的朽骨移到高一些的石台上。

两人在泉眼旁调息了半日。

池壁上原先被黑甲挡住的一小段石阶,随着白水回落,渐渐露出。石阶伸进对岸的拱门,门后没有银白池光,只有一股深暗的风。

陈生收起炉,沿石阶走到门边。

封禁图在他手中展开。这里仍在西侧残角的内缘,再往前,只有一道细线接向图中较大的承力纹。

看得出相连,却再看不出脚下每一块石头。

门槛旁,留着同样朝外反折的掌痕。

与上一处不同的是,这道掌痕在最深处,仍有一点未散的金色。

陈生蹲下来,看了许久。

那点金色忽然一颤。

门后的风停了一瞬。

更远处,传来一声沉闷的撞击,仿佛有一只极大的手,隔着重重山壁,重新按住了正在翻身的东西。

陈生抬起头。

这道声音,不在图里。

第368章 生哥

拱门后头没有路。

门槛往前,只有三块斜着没入黑水的石头。再往外,是一片看不到岸的暗流,水面上浮着半截旗杆,旗布不知去了哪里,杆头已经磨得发亮。

江明停在门边。

“这里原来是渡口。”

陈生望向左侧,那里有一条窄长的凹槽,槽底卡着一艘小舟。船身以青铜铸成,船底没有龙兽饰纹,只刻着几道彼此接续的军阵符号。

一端的系索已经断了,另一端压在石中。

他们在洗兵池见过的甲纹,在舟尾也有。

“守渡营的东西。”陈生道。

他将图铺到舟旁。西侧残角上的那条短线,到这里便接上主体较粗的一道纹。图上画的是承力之势,脚下却早已不是画图时的模样:原本安舟的石台塌了一半,船底卡在一道裂缝中。

陈生把手放在船沿,法力刚一透入,暗流中便有一股力量向下拉扯。

他立即松手。

舟侧亮起的军纹也随之熄了。

“别沿整条军纹走。”他道,“池里的甲,就是这么连起来的。”

江明俯身看了一会儿,绕到舟首。那里两根铜钉已经脱开,露出一块光秃秃的舟板。

“从这里推?”

陈生点头,将乌玄炉里的濯灵石取出,以一片剔净的碎壳垫住舟侧仍然相连的军纹。江明贴住舟首,缓缓送入法力,只使船身离开裂缝,不去催动旧阵。

铜舟沉重,挪出不到一尺,江明便不得不停下。

陈生接过来,神照体运转,双手托住船沿。

石屑一块块落入黑水。

舟底终于离开凹槽,半浮到门槛前。陈生斩断仍压在石中的索头,把剩余两丈长的一截,留在舟首的铜环上。

“能走。”江明道。

陈生先踏上舟,宝珠清光垂下,将舟边丈许罩住。暗流拍到光边,船身只是微微一晃,底下却有一股更深的拉力,拖得舟首缓缓向左偏去。

江明没有跟着跳上来。

他把自己那柄旧剑插在石槽旁,俯身取下剑穗,附上一线与旧剑相连的气息,系在舟首的铜环上。穗带并不接岸,也拖不住船,只在舟偏转时,轻轻指向留下旧剑的地方。

随后他才上舟。

“到时找不着门,我那口旧剑总还在。”

“你不怕丢?”

“都拿进来了。”

江明把青碧剑匣抱到膝前,坐了下去。

陈生以法力推动舟首,铜舟贴着门边的残纹滑出。初时不过十余步,门槛上的金色尚能照到船尾,再往前,连那一点光也暗了。

暗流中,忽然立起一条长长的水脊。

水脊没有向他们扑来,而是横在前面,一寸寸升高。更远处那声闷响再次传来,水脊拦腰断开,半截拍进左侧黑暗,半截迎着铜舟翻下。

陈生抬掌,将宝珠清光压到舟首。

轰!

舟身猛地向后倒去。

江明一手扣住船沿,另一只手抓住陈生的袍角。两人没有被掀出舟,舟首却深深插进了水中。

陈生法力一提,生生把舟抬回半尺。

头顶又有更重的水色砸落。

宝珠清光抖动,陈生眉心也跟着一痛。他眼角瞥见右边一根斜穿水面的石梁,立即将铜舟压向那里。

船尾刚碰到石梁,一道金色掌印忽然从梁后升起。

掌印没有打他们。

它扣住横翻的水脊,将那片沉重的暗流向外扳去。铜舟的前方,顿时露出一块数丈宽的礁石。

一个声音从礁石后传来。

“往左,别碰梁底!”

陈生的手僵了一下。

江明已经扯着他的衣角:“左边!”

铜舟掠过石梁,沿刚被掌印扳开的空处,重重撞上礁石。

两人跃上去,身后的船立刻被水带走半丈。陈生抬手扯住断索,把它系在礁石上唯一一枚旧铜钉上,才转过身。

礁石连着一条短廊。

廊尽头的门已经没有了,只剩两根裂开的门柱。柱间有个穿黑衣的人,背对着他们,一只手按在斜倒的石座上。

那人身形高大,衣袖卷到肘上,露出的手臂上金纹与暗红痕迹交错。满头黑发被一根布带束住,发尾落在肩后,随着不断卷来的风轻轻抖动。

方才救下铜舟的掌印,正从他的另一只手中收回。

陈生没有再往前走。

他站在湿冷的礁石上,喉咙像被堵住了。

那人先看了一眼江明怀里的剑匣,继而转过头。

脸上多了一道细长的伤,眉眼却没有变。

他看着陈生,怔了片刻,随后眼中倏然亮起。

“生哥?”

陈生的嘴唇动了动,没发出声音。

黑水忽然又向上鼓起。那人眉头一皱,按在石座上的手向下沉去,整段短廊随之一震,刚卷起的浪头被压回门柱之外。

他再抬头时,脸上却已多了笑意。

“你都结婴了。”

陈生终于走了过去。

“陈二狗。”

“哎。”

这一声应得太快,太熟。

陈生抬起右手,抓住他的肩。掌下不是晶碑的光,不是隔着书卷见到的影子,是结实的筋骨,温热的血肉。

他抓得极重。

二狗却还在笑,看见他左肩动作不大顺,又皱起眉。

“谁伤的?”

“已经死了。”

“那便好。”

陈生盯着他,目光移到他死死按住石座的手上。

“你答应过我的。”

二狗的笑意慢慢收了些。

“我记得。”

“你说会想着回来。”

“想过。”

“然后就在这里想了这么多年?”

二狗没有立即回答。

风从两根门柱间冲过,带起他的袖口。陈生看见,袖子内侧已经磨得只剩薄薄一层,靠近手腕的地方,被一条暗红的细线紧紧贴住。

二狗稍稍抬了一下手。

石座下立刻传来一声尖锐的啸响。黑水翻起,红色从水底透出,像一只缓缓睁开的眼睛。

二狗重新按下,啸响戛然而止。

“我走,先冲出来的便是它。”

他偏头看了一眼门外。

“那时候没能打死。现在还没有。”

陈生的手从他肩头松开。

“所以你就把自己压在这里?”

“我不压,它先吃的就是我。”二狗抬眼,“当年也不是没想过别的法子。”

陈生胸口积了许久的话,忽然都挤到了嘴边。可他看着那条暗红的细线,最后只说出一句。

“那也不该一点消息都没有。”

二狗沉默了一会儿。

“往外送过三次,都断了。能退人的旧道后来也塌了。再往后,我只能把这一处守住。”

他看了看陈生,又看了看立在后头的江明。

“你们是从洗兵池来的?”

陈生点头。

“那副甲,你拆了?”

“拆了。”

“难怪外头的力忽然往这里退。”二狗低声骂了一句,“那东西占了军池,反把我的退路堵在外侧。”

陈生听到这里,脸色更沉。

二狗却扬了扬下巴。

“不过拆得好。能退到这里,便还有得改。”

陈生看向门柱后面的石座,那里三道金纹被红色压住,每逢暗流撞来,便有一道变得更深。

“现在怎么走?”

二狗顺着他的目光看过去。

“先把我这只手换出来。”

江明走近两步,打量石座。

“换我?”

二狗看了他一眼,笑了。

“你这胆子不小。”

“我只是先问。”

“金丹,压不住。把舟看好,待会儿它若翻了,我们三个都得在这里喝水。”

江明把青碧剑匣放在门柱内侧,转头看向舟首那条磨得发细的旧索,立即退去礁石边。

陈生已经将乌玄炉取出。

“说你要换的是什么。”

二狗瞧见炉,先是一怔。

“你还在炼丹?”

“四阶。”

二狗看了看他,嘴边的笑又忍不住了。

“生哥,你这趟来,倒真是带着本事来的。”

陈生把炉足落在短廊中,抬头看他。

“少说好听的。手要换,命也要带走。”

二狗没有应后半句,伸出空着的那只手,将石座旁一条细纹点亮。

一团缠着金光的赤色,从细纹中缓缓升起。

赤色一现,炉内那块濯灵石忽然发出细响。陈生立即以炉火罩住,脸上的神情也从方才的激动,渐渐沉静下来。

他见过冥血大祭,也见过冥血吞进修士体内的轨迹。

眼前这团赤色,深处同样有那股令人厌恶的气息;缠在外头的金光却分成数层,沿着熟悉的道一印势,死死扣着它。

二狗的力量,正与这东西绞在一起。

“先分最外一层。”二狗道,“别烧里面。”

陈生抬指,以细火托住金光。

才托起一点,二狗按住石座的手便跟着发紧。赤色猛地向内缩去,反拖着那一层金光,要将丹火也卷进去。

陈生散开火势,水云柔控火法贴着金光绕了一圈,不同它硬扯。

炉里温养的濯灵石被他分下一片,只放在金光外沿。粘在那里的一丝异力被石片牵住,金光便稍稍松了些。

“你能稳多久?”陈生问。

“你能炼多久,我就稳多久。”

“别说这个。两炷香?”

二狗看了一眼门外翻涌的水,点头。

“够。”

陈生将那片石头推入炉边的空盏,细火再绕,金光一线线展开,终于有第一缕从赤色上剥离。

二狗立刻把它引向石座外侧。

一枚几乎陷进石中的铜楔,随着金光转入,渐渐露出头来。

那是替手的落点。

陈生看清之后,没有继续加快,而是将炉火分成三股,一股托住已分开的金光,一股截住反涌的异气,最后一股落到铜楔上,缓缓烘开积在其中的暗红细痕。

二狗空着的手指,也随着火势一点点变化。

两人没有再说旧事。

黑水每撞一次,二狗便先压住石座。陈生顺着这一瞬松开的金光,剥下一线,转入铜楔。火慢的时候,金光沉在炉沿;火急了,赤色便翻身,要将先前的工夫全吞回去。

第三次反涌,陈生左肩一滞,正在分开的细火忽然短了半寸。

二狗眼中冷光一闪,空着的手向下落印。

赤色被打得一沉。

“肩不顺,就换右边。”

陈生额上已经满是汗,闻言没有逞强,改了站位,将乌玄炉也向右移去半尺。

这一移,身后的礁石忽然摇了。

江明正在把铜舟往石侧拉,骤变的水势却将舟尾抛起,断索绷得笔直。他没再用青碧剑,双手合住断索,贴着铜钉坐下,法力从掌中一寸寸渡出。

舟底撞在礁石上,震得他脸色煞白。

“还在!”江明喝道。

陈生没有回头。

炉中金光已分出大半,铜楔也从暗红变成了淡金。只剩最末一层,紧贴着二狗的掌下,迟迟不能转开。

二狗看了一眼铜楔。

“我松一瞬。”

陈生抬头。

“松了能按回去?”

“能。”

“那就松。”

二狗的手掌离开石座。

轰!

赤色猛地翻出,半座短廊亮成了血红。陈生只觉识海中被一股凶厉之意撞过,体内元婴也随之一震。

他没有向那团赤色出剑。

丹火全部收窄,托住最后一层金光,从二狗掌下抽离。乌玄炉被这股力量压得嗡嗡作响,炉足向下陷去,陈生双手却稳稳落在炉沿。

“去!”

金光入楔。

二狗抬掌,一记道一印轰然压下。

这一次,不再只有他的手抵着石座。铜楔亮起,先前被陈生剥离的力量从另一侧扣住赤色,与落下的掌印同时合拢。

短廊中的血光被压回石座。

门外的黑水,也向下落了数尺。

二狗站在那里,慢慢收回右手。

那条缠着手腕的暗红细线,啪的一声断了。

他活动了一下手指,又活动了一下肩,脸上忽然露出极畅快的神情。

“总算腾出来了。”

陈生扶住炉,过了几个呼吸,才将紊乱的法力平复。

那块濯灵石的外沿已黑了一圈,大小也少了近三分之一。他把沾了异气的部分剔下,分别封好,没有再往石座上添火。

铜楔能替这一处压住一阵,根脚却仍是二狗落下的印。再有大浪,它未必撑得住。

二狗显然也知道,捡起短廊边一块断石,坐到陈生面前。

“手能动,出崖还不行。”

陈生抬起眼。

“我已经站在这里了。你还想打发我回去?”

“我想让你先歇会儿。”

二狗低头看了看他沉在炉边的双手。

陈生指节发白,左肩的筋骨仍有牵滞,火却已经熄了。直到此时,他才转过身,叫江明过来。

江明把铜舟的断索重新绞好,走进短廊,先瞧瞧陈生,再瞧瞧二狗。

“这回总算有人叫你歇会儿了。”

陈生没有理这句,给他递了一颗温养丹。江明接过,走到门柱旁坐下,将方才搁下的长匣拉到自己脚边。

两根门柱外,水色正在变浅。

浅下去的那一瞬,陈生看见前方还有一座更高的石台,台上数道巨大的暗红锁链,全部伸进了没有亮过的地方。

二狗顺着他的目光看去,嘴边的笑慢慢敛了。

“那里,还有一处。”

陈生也收回目光。

“先说秦证。”

这句话出口,短廊中静了一会儿。

二狗将掌心按在膝上,垂着眼,过了许久,才抬头。

“死在我眼前。”

“谁杀的?”

二狗看向那几道伸入黑暗的锁链。

“他到最后,才知道自己认错了一个人。”

陈生等他往下说。

二狗却先伸出手,抓住他的胳膊。与陈生方才抓他肩头一样,也抓得很紧。

“生哥,先让我看看你。”

陈生愣了一下。

二狗仔仔细细地看过他的脸,又瞧了瞧那只仍旧稳稳握着炉沿的手,终于笑了。

“我就知道,你不会只活三百年。”

陈生喉头一涩,随即抬手拍开他的手。

“少说这些。出去以后,有的是工夫看。”

“好。”

这一次,陈生没有像许多年前那样,目送他转身离开。

他取出那枚一路带来的陈字牌,放到二狗膝前。旧牌碰着黑衣,轻轻响了一声。

二狗低头看见,手指慢慢合住。

两人就在石座旁坐着,开始讲那一夜的事。

第369章 旧夜

“严承岳。”

二狗说出了那个名字。

陈生等了一会儿,没有听见第二个人名,便问:“他是什么人?”

“随秦证平叛的老臣,元婴修士。龙骧卫出征时,后阵归他。”

二狗拇指压住掌中的陈字牌,指节微微发白。

“有一回,他亲侄侵吞了送给伤兵的药,是他自己押到军门,亲眼看着斩的。秦证说,此人不徇私,可以托付身后。”

门外的黑水拍了一下石梁。

二狗抬起眼。

“后来,秦证便是把后背交给了他。”

……

那一夜,军中还未熄灯。

允泽受阻、两败之后,龙骧卫又打过数场,营地几经迁转。秦证死时,军队已驻到另一片山谷,谷中有水,西面留着运粮的旧道。

后来黑崖立起的地方,当时还能看见天。

二狗记得那晚的云很低。

敌人的第一件法宝穿出云底,是一口倒悬的铜鼎。鼎口朝下,罩住山谷东侧,数十道气息随之压来,谷外火光一片片熄灭。

他从军中跃起,一掌打在鼎腹。

铜鼎横飞,滚下半座山头。道一印追着落下,将鼎上藏身的一名修士震出血来。

秦证也出手了。

皇道金光掠过谷口,照得敌阵一清。两道本欲扑进侧翼的血影被迎面打退,其中一个半边身子陷在山石里,再没能出来。

可是谷口后面,又有新的血光亮起。

“他们肯把本钱全拿出来了。”秦证道。

二狗隔着半片战场应了一声,双手结印,直压东侧山头。

谷中的龙骧卫同时踏前,甲纹相连,汇聚的法力托住皇道金光。前阵弓弦齐响,密密的火线射上低云,终于逼出了后面的人。

也就在那时,西南侧两座阵台忽然转向。

本该护住山谷的光幕,从中间折了过来,拦在前军与中军之间。

一名传令军士撞上去,连人带旗被掀回坡底。

二狗回头时,看见阵台底下翻出的铜盘。盘上有芈氏的篆记;另一面被火光照亮的短幡,嵌着齐家的族刻。

秦证连踏三步,震裂横在军中的光幕。

“严承岳,收后阵!”

山谷中的高台上,严承岳举起军令。

几列军甲立时亮了起来。

“陛下,中军还在!”

秦证没有退,他要接住前军。刚被震开一道口子的光幕又在合拢,数百名军士被隔在外侧,有人回头看了一眼,仍将兵器刺向谷口涌来的血影。

二狗一掌压垮东侧的半座山头,转身横穿战场。

他先去撕那道将要闭合的光幕。

秦证则落在高台前,背后,是严承岳执掌的中军阵势。

严承岳向他伸出了手。

起初涌来的,确是龙骧卫熟悉的法力。它从甲纹、阵旗之间汇拢,接入秦证身后的金光,将皇道之势向外推高了一层。

秦证抬掌,谷口一名世家强者顿时被打得倒翻出去。

严承岳就在这时翻过军令。

他掌心早已割开,血沿着令纹流下,却没有一滴落地。

高台底下,亮起了细细的红线。

秦证身后的金光猛地一沉。

原本拱卫他的军阵向内折转,严承岳的手掌穿过那道骤然塌下去的金光,打在他的后心。

秦证向前踉跄半步。

他没有倒。

那半步踏碎了台沿,他借势回身,一掌扣住严承岳的腕骨。金光从两人相接的手臂间暴起,严承岳半条胳膊当即扭曲,肩上炸开一蓬血。

“是你。”

秦证的声音很低。

严承岳没有挣脱,另一只手反而按向了自己胸前。

他的衣袍裂开,胸膛上刻满了暗红纹路。那些纹路一直延入血肉深处,随着法力转动,一道道向外展开,接住高台下涌来的血色。

秦证扣在他腕上的金光,竟被扯得弯了一下。

“你也修了冥血道?”

严承岳嘴角流着血,抬头看他。

“臣也想再往上走一步。”

秦证五指收紧,严承岳那条胳膊被生生扯断。

他连看都没有看,胸前红纹陡然张开,数道血光一齐撞向秦证。秦证抬掌挡下前两道,第三道却沿着尚未截断的军阵,从他的脚下刺了上来。

二狗刚撕开横断山谷的光幕,眼前便亮起一片血色。

他冲到高台时,秦证身上的皇道金光已经裂开。

……

“他拿自己作了祭?”陈生问。

二狗点了一下头。

陈生见过五千具尸骸堆成的祭场。王元枫与司马言当时站在祭场之外,等着冥血落下,眼前这位老臣,却将那套东西刻进了自己的身子。

“那些力量从哪里来?”

“有一股沿军阵来,另一股从他胸口出来。我打进去过,看不见底。”

二狗松开了压着旧牌的拇指,掌心已有一道深痕。

“他不是临阵才学。掌管后阵那些年,他有的是时候动手。”

陈生看了一眼脚边的铜楔。

楔上的金光还稳,底下那一线赤色,却始终贴着石缝不退。

“秦证后来呢?”

二狗望着石缝,继续说了下去。

……

道一印落下,整座高台裂作两半。

严承岳被打进石中,胸前红纹也凹了进去。二狗落在秦证身侧,一把扶住他的肩,手掌刚触上去,便被血浸湿。

“先退!”

秦证却按住了他的手。

台下传来甲片撞击的声音。

有一队军士还在往这里冲,为首的人提着长枪,口中喊着护驾。跑到斜坡中段,他胸前的甲纹忽然由金转红,整个人向前栽了出去。

他身后的人伸手去扶,也跟着跪了下去。

严承岳从碎石里抬起头。

他方才塌下去的胸口,正一点点鼓起。军甲上的红光越亮,他身上的气息便越重。

秦证看见了。

他一手推开二狗,另一只手探入自己身前破碎的金光,将仍未散尽的皇道之力全部聚起。

“退兵。”

二狗向严承岳扑去。

秦证却比他更快。

最后那道金光没有斩向严承岳,而是顺着高台向下,切进横贯山谷的主阵纹。地下如有一条大龙翻身,两侧阵旗从中折断,大片赤色被同时掀上半空。

跪在坡上的军士终于喘出一口气。

严承岳脸上的神情变了,猛地探手,想抓回被斩断的阵纹。

二狗一掌打断他的手指,再一掌,贯入他的胸口。

身后,秦证身上的光彻底暗了。

他向前倒下时,仍朝着山谷中那些脱开束缚的军士。

二狗回身接住了他。

血从两人手臂间不断滴下。秦证张了张口,二狗俯身去听,只听见了半声很低的吐息。

随后,连那半声也没了。

“秦证。”

二狗喊了一声,又喊了一声。

秦证没有再睁眼。他体内最后一点元婴气息,也在二狗掌下散尽。

二狗把他放在一块断石后。

他转过身时,严承岳已经从塌陷的高台里站了起来。

断臂没有长好,胸口被打穿的地方却正有血色往外涌,将那具残躯一点点撑直。

“你还要借多少条命?”二狗问。

严承岳张口,似乎要说什么。

二狗没给他开口的机会。

道一印迎面压下。

严承岳被打得双膝陷地,整片台基向下坍去。二狗欺近,再落一印,严承岳的头颈被打得偏向一侧,胸前数条红纹当场折断。

可就在红纹断开的那一瞬,台下又有人倒了。

秦证斩开的主阵已经断了,几队尚未脱甲的军士身上,却还有细小的红纹彼此接续。

严承岳胸口的血色沿着这些残纹猛地一卷。

二狗听见身后有人惨叫。

他打下去的第三掌生生偏了半尺,轰开台侧山石。严承岳趁隙撞来,肩头破碎的甲片扫过他的脸,在眉骨旁划下一道长长的血痕。

二狗扣住他的脖颈,把人砸回台基。

“脱甲!”

他的声音传遍山谷。

“甲上有红光的,全脱了!往西边走!”

有个军士还在拽腰上的甲带,那甲带不知被什么粘死,越扯越紧。他身旁的人举刀割开连环,将整片胸甲从他身上剥下,又拖着他往坡下跑。

金铁撞地的声音接连响起。

西侧,敌人的法宝也追了过来。

一面嵌着齐氏族刻的短幡掠过半空,血光直扫那些卸了甲的人。二狗伸手去拦,严承岳却陡然抱住了他的手臂。

“你护得住几个?”

二狗没有回话。

他以肩撞开严承岳,左手结印,打偏短幡。那一片血光砸进山壁,落石将西侧旧道堵了小半。

军士们乱了一瞬。

有人回头,要重新去捡丢下的甲。

“向西!”二狗喝道,“别回头!”

旧道边,一个满脸泥血的军士举起半面旗,先钻进了落石后面。

“守渡营,跟我走!”

随后又有两个人跟了上去。

那半面旗在石后连摇数次,后头的人终于也动了,拖伤者,弃兵器,将同袍从甲堆里往外拉。

二狗收回目光,掌印忽然翻了过来。

道一印原本压向谷口,这一翻,却扣住了自己身后的山壁。金纹沿着残破的行军阵逆行,将堆在地上的军甲与西侧旧道隔开。

严承岳第一次向后挣了一下。

二狗五指扣得更紧。

“现在想走了?”

另一端的力量还在涌入,二狗掌下那具残躯已经不像一个完整的人。胸前破口深处,露出一小片看不见底的暗色,血光正从那里往外涌。每断一处经脉,红纹便从别处接上;散开的血色落在石头上,连石中阵纹也一并染红。

他终于不再向那道血口硬打。

他把道一印落在了四周。

谷中的碎石、断旗、残存台基,在接连落下的掌印间一寸寸合拢。两侧山壁承受不住,发出漫长的轰鸣,整片战地随着禁势折转,向下沉去。

上方的夜云忽然变得很窄。

二狗还看得见西侧旧道,看得见那半面旗。他再将一道金纹推过去,托住正在下坠的道口,喊了最后一声。

“诸军退!”

血色顺着他未收回的手臂缠了上来。

严承岳被压回石中,胸前那道血口却没有合上。二狗抬手去镇,掌下随即一沉,连他的道一印也被从里面咬住。

头顶,最后一线天光消失了。

……

江明不知何时已坐直了身子。

“那面旗,退过去了?”

“过去了。”二狗道,“举旗的叫方厚,守渡营的人。我最后见他时,他正把一个断腿的往石后拖。”

陈生记住了这个名字。

秦林在宫中问他要一个名字,如今终于有了。

“后来的路,你们走过。”二狗看向门柱外,“我留过字,也改过几次退路。能看见出口的时候,总觉得再挪开一块石头,便能出去。”

他抬起方才解脱的右手。

腕上的暗红细线断了,那道勒痕仍在。

“越挪,那东西缠得越深。”

陈生没有去接这句话,目光落在前方的高石台上。

“严承岳还在里面?”

“那夜我把他压在了那边。后来,他没有再用自己的声音说过话。”

二狗将旧牌收进衣内,站起了身。

陈生也随之起身。左肩牵了一下,他换右手扶住炉沿,没有急着把陷在短廊里的炉足拔出。

“别再照当年的法子,独自上去硬打。”

二狗看了他一眼。

“这回有人替我看身后了。”

话音落下,高石台上的一条暗红锁链忽然绷紧。

铜楔里的金光随之一颤。

二狗抬掌,按住将要翻起的印势,脸上那道细伤在金光中清晰起来。

陈生沿着锁链望去。

黑暗深处,一道极细的血光,正从石台背后缓缓透出。

第370章 这一回

二狗讲完时,石座上的铜楔已经暗了一截。

陈生没有立即接话。他摸出一只瓶子,先递给二狗,随后坐到门柱另一边,将两粒丹药压进掌心,服了下去。

江明看着那只瓶子,问:“给国师的,和给我的一样?”

“你想吃他的?”

江明看了一眼瓶中流转的赤金药光,摇头。

二狗服下药,闭目片刻,肩背间原本一直压着的气机终于向外舒开。陈生近在咫尺,清楚地感到了那一层又一层深厚的元婴法力。

圆满。

二狗在他看不见的这些年里,已经走到了这一境的尽头。

可这股法力刚向外舒开,石座下的赤色便随之一涨,像有什么东西咬住了末梢,要将它扯回去。

二狗抬手,按了一个短印。

“我知道你要问什么。”他道,“还没化神。卡在这里,往上走的那一步,我也试过。”

陈生望着他指间收回的光。

“试的时候,它就跟着吃?”

“吃得比我快。”

二狗嘴边动了一下,没笑出来。

石座又是一震。

乌玄炉仍陷在短廊原处,丹火没有点。陈生回到炉背风的一侧坐下,闭上眼,运功调息。丹药温养的气息从脏腑缓缓散开,左肩深处仍有一点涩痛,他不去强压,只先收回散在四肢的法力。

二狗也坐着。

两人之间,一块已经被剔掉黑边的濯灵石静静搁在炉沿。再没有人伸手去碰那口仍在翻涌的石座。

过了一个多时辰,江明先睁眼。

门外暗流低下去,礁石对面露出半截石梁。顺着石梁往上,是一条狭窄的旧阶,再上便到了那座更高的石台。

那里不知什么时候,有个人站起来了。

头发灰白,衣袍却是极鲜亮的深紫色。只看上半身,他像一位刚从宫中下直的老臣,右手平放在身前,另一侧衣袖中却没有手,袖口只垂着一束细细的红线。

往下看,袍角全浸在暗红里。

几道锁链穿过石台,紧紧勒住了他的腰腿。链上的红色缓缓游动,每到他身边,便顺着衣下钻进去。

江明立即握住剑匣。

那人抬眼望来,先看陈生,再看江明。

“这些年了,陈国师总算有客。”

声音隔水传来,低低的,末尾却叠着另一道尖细的回音。两道声音一起落下,紫袍老人才合上嘴。

二狗没有起身。

“严承岳,你若能把那张脸撕下来,倒更像你如今的样子。”

紫袍老人并未生气,向下看了一眼。

“陛下叫我穿着这身衣裳来见他。我一直留着。”

江明的手指在匣边紧了一下。

他在神都听过无数人口称陛下,自己也向那位至尊行过礼。眼前这句,却听得他胃里发凉。

严承岳望向陈生。

“你是他的生哥?”

陈生抬起头。

“你听过?”

“听得不少。他以为你活不到今日,所以有些事,连提都不肯提。”

二狗眼神一冷。

陈生的手却从炉沿抬了起来,搭在他小臂上。

“说。”

严承岳笑了笑。

“你来找他,就该知道,外面那一朝的元婴修士再多,也填不满这里。想带他走,要么把他身上的印全拆掉,另找一个人按上去,要么……”

“要么把你杀了。”二狗道。

“你试过。”

严承岳的目光,慢慢落到陈生那座乌玄炉上。

“丹师?”

陈生没有回答。

“老夫认识过几个。药理弄得比谁都清楚,真到了生死关头,也还是恨命不够长。你能走到元婴,自然更懂得惜命。”

严承岳将一只手抬起。

手掌上没有血珠,只有一缕极细的暗红气息,从指间向下垂着。那气息一落,石阶下的水便泛起细密的波纹。

“你比他更适合坐下来谈。”

陈生看着那缕气息。

他忽然想起司马言吐到剑上的暗血,还有神都一夜里,连成一片的黑丝。

“芈家老祖死了。”

严承岳的手停在半空。

“齐天鸿也死了。齐轩正跑了,往哪跑的,还不知道。”陈生道,“坐在皇位上的还是秦家人,叫秦林。”

紫袍老人没有立即说话。

陈生望着他,继续道:“他父亲死时,他八岁。你这身衣裳,要不要换个地方,穿给他看?”

石台上的锁链,忽然响了一声。

严承岳笑意未变,指间暗红却向下重重一甩。

一道水刃从石阶中冲出,掠向短廊。

二狗起身,单手横推。

金色掌印将水刃拦在门外,余势撞上石梁,削去一层黑石。陈生已经持剑在手,等水光散了,剑尖却没有刺进那片暗流。

严承岳望见他的剑,脸上的笑慢慢淡了。

“秦林,竟能做到这一步。”

“你不知道的还多。”二狗道。

他甩了甩方才落印的右手,走到门边,低头看了看石阶。

严承岳也垂下目光。

两人隔着一道还没露全的旧阶,谁都没有再开口。

陈生走到二狗身侧。

“这一次,你别急着把他砸碎。”

二狗偏过头来。

“我没急。”

“你手都伸出去了。”

“许久没能用这只手,试试而已。”

陈生看着他。

二狗也看着陈生,片刻之后,终于先移开了目光。

严承岳站在上头,望见他们这一眼,嘴角又有了笑。

……

陈生将图展开,摆在石座下。

他没有指那座高台,先指向铜楔后面的一道金纹。那道纹在地上走了半圈,又没入二狗原先一直站着的地方。

“这条,是你自己的力。”

二狗点头。

“这些年都缠进去了。严把外头的东西接进来,我把它按住,一来一回,这处已经分不开。”

“方才分开的那一层,还能再拿出来。”

“能。铜楔撑不住全部。”

陈生将手指移到图里两道交错的纹上。

“那就不让它撑。你往上走,严要抢你松开的这一截,我先替你截住。等你打到他身边,把他借用的那股力赶到一处,我拆你的印,收进炉里。”

二狗盯着图看了一阵,伸指压住更深的一点。

“这里一松,他便能抽身。那时他未必只冲着我来。”

“我有宝珠。”

“宝珠护不住整座崖。”

“护我这几步,够不够?”

二狗没有立即答。

陈生把封禁图收窄,只露着眼前这一段相连的结构。

“以前你一松手,它先吃你。现在我替你接这一截。该你打的,你打;我的火,别全攥在手里。”

二狗垂眼看着自己的右掌。

指根那条勒了多年的红痕尚未退去,他翻过手,掌心却已能完全舒开。

“生哥,我打他的时候,你就别叫我慢。”

“你若有空打别处,我才叫你。”

二狗忽然笑了。

“成。”

江明在旁边听了半日,终于道:“第三个人呢?”

二狗转身,将石座旁一块碎盖掀起。

盖下是一只小铜盘,盘内两道浅槽,一道连着铜楔,一道通往来时的渡口。铜盘外沿刻着三枚小小的军符,边上还留着方便士卒握住的凹口。

“退兵时转流用的。”二狗道,“大股力量压在内座,这里只拨外面的去向。先前军池被占,往外那头送不动。你们拆了它,才能用。”

他握住铜盘,稍稍转了一寸。

礁石边的水向外低去,旧铜舟立刻朝来路偏了偏。石座上的铜楔却随之变暗,二狗又将盘转回原处。

江明看清了。

“不能现在一直放。”

“不能。等他抢我那截法力,生哥把我的金色印力收进炉,剥下来的血气落外槽,你再送。”

二狗沿另一条浅槽划过。

“这条通回军池,借弃在池里的甲卸掉这一阵冲力。我把旧退纹留在外头,血气会压在池内。别转过一寸,过了,就真送到出口了。”

二狗在盘边画了三下起手之势,没有将手掌按进去,只让江明看清落点。

“用你的法力推这三处,借原来的余力动盘。别往中间填,那是元婴军阵的根脚。”

江明俯身,试着扣住盘沿。

铜盘很重,纹路一接上,却能慢慢向旁边挪动。只是挪到方才那一寸,掌中便传来强烈的压迫,像有一只手要将他的指骨掰开。

江明立即退回,额头见了汗。

“我能拨这一下。再深,不成。”

二狗点头,把碎盖斜立在他的身前。

“就一下。生哥在炉口举金光,你动盘;收了,你便退。”

江明握着盘,没有说自己怕不怕。他看了一眼高台上的严承岳,又看陈生。

“拨完,退哪边?”

“左边门柱后。”陈生道,“舟我先系牢。”

他走到礁石,将已经磨细的旧索另加一道法力,固住舟首。江明从袋中取出那块较小的濯灵石,先放在剑匣旁;陈生回来,将它推到铜盘外缘。

“沾上了外来的气,先松手。这一股送不净,也别拿自己填。”

江明抬头。

“我还没结婴呢。”

陈生看着他,忽然也笑了。

他将剩余的干净石心分成两片,一片贴在乌玄炉内,一片扣住铜楔。炉足仍留在短廊,一道细火已从炉口伸出,接到那截不断涨缩的金纹旁。

二狗从衣内取出陈字牌,重新放回他掌中。

“拿好了。”

陈生把旧牌收回贴身袋,握住铁剑。

门外的水在这一刻低了。

石阶露出七级,最后一级接上更高的石台。严承岳垂在身前的右手已经慢慢抬起,空袖下的红线也向两侧散开。

二狗走出门柱。

他没有再贴着石座站着,而是踏上第一阶,肩背舒展,右手的金光一道道亮起。

陈生站在炉旁,看见自己那位失踪多年的兄弟,终于重新向敌人走去。

二狗没有回头,只问了一声。

“这一回,一起?”

“走。”陈生道。

第371章 断桥

二狗走上第四级石阶,严承岳便动了。

右手朝下一扣,空袖中的红线同时绷直。石台前那几道锁链不再绕着他的腰腿,猛地从水中抽出,打向二狗身后。

他打的不是人。

锁链落处,正是两根门柱之间的乌玄炉。

锁链先打向门柱,链上射出的细红线则直探短廊。陈生抬起铁剑,星光照亮炉边,斩断迎面的第一道红线。另一道却沿剑光底下贴进来,卷向炉足。

玲珑宝珠清光下压,将炉与陈生一同罩住。

锁链撞在光上,震得炉口细火一矮。

二狗已经跃过最后三级阶,落到了严承岳面前。

“看这里。”

他说着,右掌打下。

金光盖住紫袍老人的半张脸。严承岳向旁边偏了一步,脚下石台立刻塌出一个深坑,碎石没有往外溅,全被那记法印压进了下方的水里。

二狗的第二掌随之落下。

严承岳抬手迎上,掌心红纹一齐张开。两股力量撞在一起,旧石台从中裂出一条长缝,裂缝两边,红色与金色各占一半。

他嘴里那道尖细的回音,忽然低了。

“你终于肯松开了。”

二狗没有答,左手扣向他的肩颈。

严承岳向下一沉,半身衣袍碎开,胸前那一片暗色便露了出来。

不像伤口。

破开的血肉之后,有一层极薄的黑光,黑光里不断向外吐出赤色。每一缕赤色伸出,便先缠住他胸侧残存的经脉,再向石台四面铺去。

二狗指尖将要触到黑光,忽然一停。

掌印转向,压住胸口外侧两道红纹。

严承岳脸上的神情立刻变了。

他不肯让这两道纹被合在一处,右手翻起,抓向二狗手腕。二狗以臂撞开,欺身更近,第三记道一印压进了他的胸前。

整个高台,忽然向下一沉。

短廊里的铜楔也在同时下陷。

陈生盯住楔后那一道金纹,丹火已经铺在其外。金纹绷直,深处缠着的赤色陡然向上扯来,像有人要从炉口里拔出一根早已埋进石头的钉。

他以细火托住金纹,没有顺着往外追。

濯灵石上响起几声细小的碎裂。

贴在金光外侧的暗红被一点点带离,落入炉边那道空槽。陈生右手持剑,左手沿炉口展开,火势连转三次,终于将第一段金色印力收了回来。

二狗在上头觉到了。

他原本被向下拖着的右肩,忽然轻了一线。

严承岳也觉到了。

他猛地扭头,空袖下的红线不再袭炉,转而贴向那条正在收窄的金纹。线一接上,便向上扯,连二狗身边的水也跟着抬起。

“你的力,还在这里。”

两道声音同时响起。

严承岳没有只顾一句话,他右掌拍地,高台两侧数道血光同时立起,将二狗逼在台心。自己却沿着脚下的赤色,向石阶之外滑去。

要先杀陈生。

二狗一眼看穿,踏碎半块台沿,横身挡在他前面。

严承岳抬手,血光劈向他的眉骨。

二狗没向后躲。

他偏头让开正锋,肩上衣料被割掉一片,右掌已拍上严承岳的胸膛,将人重新按回台里。

鲜血沿二狗上臂流下。

那几道被他逼近的红纹,却终于靠到了一起。

“生哥!”

陈生没有抬头。

这一刻,炉口的金光被拉得极细,他看见了其中交叠的印势:一折向内扣住血口,一折压在二狗自己身上,最外一折才接向短廊的铜楔。

若顺着一气抽走,最先断开的会是二狗胸中法力。

他以前在道藏中揣摩过这样的变化,亲自使印时,却远没有眼前这般庞大。陈生将铁剑插在炉足旁,双手同时起火。

一股托住内折。

一股拨开压在人身上的回势。

最后一股贴着铜楔,顺原来的外沿向下游走。

那道他已练过许多年的印,在另一人的掌下,展开了远比从前清楚的骨架。

严承岳抓住这一瞬,向外猛扯。

铜楔顶部啪的一声裂开,金纹半截出石,另一半仍被红线缠着。

陈生的双手被震得一麻。

他将丹火压低,留下内侧未分的印,先将已经抽回的那一截推到炉口。

金光升起。

江明动盘。

掌心法力沿着二狗教过的三处落下,外侧铜盘向旁边缓缓挪开。盘一动,空槽里积着的赤色便被旧纹卷走,向来时的军池沉去。

江明只挪一寸。

深处那股重压却像醒了过来,猛地撞上盘沿。

他两只手同时向下沉,指节刮破,血溅在铜盘边上。那块小濯灵石立刻有一角变黑,挡住了向他掌心游来的细丝。

江明咬紧牙,盯着炉口。

陈生举起的金光还在。

他便没松。

高台上的严承岳,胸前红纹忽然暗了一下。

方才被牵着走的外来力量,没有再照原来的路接回他的身子,而是有一股沿断开的缝,冲向了下面。

他胸内那层薄薄的黑光随之张开。

黑光里面,露出了一道极窄的裂口。

二狗终于看清了。

他曾向这里打过无数掌,每次打入,力量便同自己的印一起被吞回。如今红纹聚在一处,外侧又被分出一道泄口,那条真正续接的窄缝,才从层层血色后显出来。

二狗没有向缝内追。

他右手五指合拢,扣住裂口前方的血肉,以一记向外的道一印,将整条红纹从严承岳胸中拉出半寸。

严承岳发出一声尖啸。

右臂抬起,掌中密密的红线全刺入二狗肩头。

二狗肩背一震,手却没有松。

“再往上走一步?”

二狗抬起头,掌中金光陡然亮了。

“先给我下来!”

台下的黑水轰然炸开。

严承岳被这一拉,半个胸膛离了原处,裂口跟着向外露出。二狗身后却也有一大片金纹绷起,要将他重新拖回短廊。

陈生看见了那一片金色。

他不再只守着铜楔,将已经保在炉内的第一截印力引出,护在绷起的金纹末端。两段原本被赤色搅在一起的力量,沿着同源印势缓缓相合。

此时他才向上迈了一步。

乌玄炉留在原处,炉口金光顺着手中丹火,连出一条极短的路。陈生踏上石阶,宝珠清光只罩住自身数尺,铁剑也从炉边飞回掌中。

严承岳看见了。

他忽然不再向二狗肩头刺线,空袖全向下翻,一股赤色掠过台边,直扑陈生眉心。

宝珠迎上。

清光被撞得向内陷了寸许,陈生胸口一闷,脚下的旧阶随之裂开。他踏上更高半级,右手铁剑横过,将迎面赤色一分为二。

只有这一击,严承岳便试出了他的法力深浅。

“元婴初期。”

那道较低的声音,忽然带上了极强的喜意。

严承岳胸前黑光一转,竟不再往外吐力,反过来向内猛吸。陈生手中的丹火被扯得直指台心,体内法力也随之向外漏去。

二狗眼神立刻变了。

“严承岳!”

严承岳望着陈生,脸上的皮肉向两边拉开。

“新来的,也留下。”

陈生左肩一沉,丹火短了一截。他没有强行再提,反从袋中取出早先封好的污石,将正被牵住的那缕丹火缠在石外,向台心一抛,同时斩断自己与那缕火的联系。

牵引忽然落到污石上。严承岳要抓的是活人,见那点火没有将陈生拉来,空袖中的红线立即改向,抽碎石头,重新刺向石后的眉心。

红线翻折,裂口前方的压势也随之空了一瞬。

二狗等的便是这一瞬。

他左掌落下,压住被拉出的红纹;右手松开血肉,直接扣住严承岳的后颈,把人向那条现出的窄缝里按去。

严承岳的胸前再没有余地遮掩。

陈生跨过最后一级阶。

铁剑向下。

斩星之光没有追进黑光深处,只贴着二狗压住的那一线,切过血肉与外力相接的位置。

严承岳的右掌打在陈生胸前。

宝珠清光挡住大半,余力却仍让他喉头一甜,喷出一口血。握剑的手没有退,剑锋已经从那条红纹底下斩过。

台上忽然静了一瞬。

严承岳胸中那道裂口,与他外翻的血肉,分开了。

二狗抬掌,印光盖住裂口,将它重重按回石台中心。严承岳身上的红色却还没散,正疯狂地向那层黑光爬去,想将断开的一端再接上。

陈生没有去与黑光角力。

他转手出第二剑。

这一剑,斩的是严承岳自己的经脉。

胸前残存的红纹齐齐断开,右臂先向下垂,随后那张一直端着的脸,也终于失去了平整。

只有较低的那道声音,从喉中挤出来。

“国师……”

二狗没有应。

他把按住裂口的掌印钉在台心,转身一掌,打在严承岳头颈之间。

紫袍碎了。

血肉撞进台侧裂石,彻底散开,再没有被外力一点点撑起。

一道暗红的元婴从残躯中冲出,身上缠着数条已经断了的细线,先扑向二狗的面门,又陡然一折,朝短廊飞去。

江明就在下面。

他看见那道小小的影,汗毛一下竖起,手却没有再向铜盘中添力。炉口的金光已经落下,他依着说过的次序,松开盘沿,伏身退向左边门柱。

铜盘回转。

尚未散尽的一股旧军元力,从外槽翻起,挡了元婴半拍。

二狗已经到了。

他从半空一把扣下,道一印收在五指之间,将严承岳的元婴压住。那道元婴面孔扭曲,声音已不是尖细回音,而是一个老人嘶哑的求叫。

“陛下当年……”

二狗的五指合拢。

金光一闪,元婴碎尽。

他松开手时,掌心只剩几缕正在散去的赤烟。

“当年,你没给他把话说完。”

……

石台中心的黑光,还在缓缓收缩。

陈生一手按着胸口,一手握剑,正站在裂口外侧。那里没了严承岳的躯体,却仍有赤色想沿旧军纹向外找路。

二狗落回他身边,先看他嘴角。

“伤到哪里?”

“胸口。还能站。”

“先退。”

“你要再把手按回去?”

二狗瞥他一眼。

“都到这一步了,还按回去做什么。”

他抬起沾血的手臂,先打在石台外侧,又向相邻的两处残基,各落一印。三处印势彼此接上,台下仍在流转的金纹,终于不再回绕到他身上。

陈生看清了,这才收起铁剑,将守在炉内的那一截印力,顺着丹火送回二狗掌中。

原本被牵在两人之间的金光,缓缓断开。

那层黑光往里凹去。

赤色最后翻了一次,被新合拢的旧禁压回原处。石台中心,一道窄窄的裂缝彻底闭合,留下拇指宽的一条焦黑痕迹。

二狗又等了片刻,才收手。

陈生望向他腕骨。

那里只剩先前磨出的勒痕,再没有一条红线追着伸上来。

短廊方向,江明靠在门柱后,抬起满是擦伤的手。

“盘回去了。”

二狗回头,朝他扬了一下掌。

“好。”

说完,他忽然仰头,大笑了一声。

声音撞在山壁间,传得很远。笑到第二声,他肩上的伤口也在流血,他却像全没觉出,只抬手摸了摸终于不再向后拉扯的右臂。

陈生站在旁边,也慢慢笑了。

“现在,能走了?”

二狗转过身。

“走。”

两人沿石阶退下。

乌玄炉仍立在短廊中,炉口的丹火已尽数收回。贴铜楔的那片濯灵石碎了大半,剩下薄薄的一角也染着黑;炉内那一片尚留着拇指大的干净石心。

陈生将干净的收好,污损的另封,拔起陷在地中的炉足。手臂一用力,胸口便疼得他皱了一下眉。

二狗立刻伸手,帮他把炉提了起来。

“收了,我来拿。”

“进了袋就一样轻。”

“那便先收。”

江明扶着门柱站起,低头看了看盘旁的小石头。石头一角黑了,其余仍留着乳白的光,他用布将两处隔开,放进自己的匣边。

门外的水已经降下去了。

旧铜舟斜靠礁石,船底轻轻磕着石面,舟首那一缕剑穗,仍朝着来路指去。

二狗走到门边,停了一停。

他看着来路,看着铜舟,也看见了系在舟首的那只铜环。

“我当年没能坐上它。”

江明解开绞紧的旧索,朝舟里一偏头。

“那现在上来。别又让我们等。”

二狗看了他一眼,忽然笑了。

他踏进舟中,回手接住陈生,随后把江明也拉了上来。

三人坐稳。

铜舟离开礁石,沿着比来时平缓许多的暗流,向旧军渡口驶去。

第372章 外头的风

铜舟靠岸时,江明先看见了自己的旧剑。

剑还插在石槽旁,剑柄上积着一层潮湿的黑灰。舟首的穗带向它偏去,快碰到船沿时,才重新垂了下来。

江明伸手拔剑。

第一下没拔动,他脸色顿时变了。

二狗从后面走过来,沿石槽轻轻拍了一掌。剑身附近的旧纹散开,江明这才将剑抽出,握在手里看了又看。

“还好。”

“舍不得这口?”二狗问。

“拿过一口好的,就得把用过的全扔了?”

二狗看着他,笑了一声。

“倒也不必。”

江明将剑收入自己的鞘,又解下舟首的剑穗,拿布擦干,重新系回剑柄。青碧剑仍在长匣里,两口都齐了,他才抬头问陈生:“往哪走?”

陈生正倚着门边调息,闻言指了指来时的石道。

“军池。”

水面比他们去时低了许多,门外露出几块原先没见过的断甲。它们浸在银白与黑色相杂的水里,边缘不断落下极细的灰屑。

江明拨出去的那阵冲力,已经走到这里。

二狗站在门边,俯身看向舟尾。他没有再去催军纹,只将铜舟拉回旧槽,解下磨坏的索,搁在船内。

“以后再有人进来,它未必还浮得起。”

陈生望向旧槽底下开出的裂缝,点了点头。

二狗忽然停步。

他回头看了一眼那条已被暗流遮去的短廊,抬手打出一记小印。金光掠过两根门柱,卷出一块指掌大的铜片,落到他手中。

铜片只有半面,边上断口极深,一角刻着小小的龙纹。残存的另一道刻线,不像军士姓名,更像一枚被劈断的令印。

“秦证的出征军令。”二狗道。

陈生看着他。

“你一直留着?”

“那夜从他腰间取下,封在石座后。想着有一天带回神都。”

二狗拇指抹过令上断口,动作很慢。

“他的遗骸没保住。折阵以后,那块断石也沉了,我再找不到原来的地方。”

陈生没催他。

二狗将令片收进衣内,转过身,终于走离那道门。

洗兵池的拱门,仍在前面。

他们回到池沿时,江明看见几片残甲在缓缓转动,原先散开的黑线却已完全断了。没有第二副甲站起来,也没有新的声音叫人列阵。

池水中那一小片乳白光泽,比来时弱了许多。

陈生没有再去采石。他绕过泉眼,看见先前搬到高台上的朽骨还在,就将旁边倾着的一片甲翻过来,遮在上头。

二狗看了一眼,没有认出那个人。

他站了一瞬,收回目光。

三人沿石道出去。

到那处合拢的旧禁前,陈生刚抬手,胸口便一阵闷痛。二狗已经走到石台旁,掌印向外一推,禁纹露出一条窄缝。

“你先过。”

陈生没有跟他抢,侧身出去,随后是江明。二狗最后跨过,身后的缝又缓缓合上。

出入的起手处仍在,远处那些折转的旧纹,却没有随着血口闭合一并散去。

石室里的断枪,仍斜在角落。

那半枚军牌也还嵌在枪尾。二狗蹲下看了看残纹,又望向门槛上的几行字,过了一阵,伸指碰了碰末尾那个陈字。

“我以为你会先看见这几笔。”

“看见了。”陈生道。

“然后呢?”

“骂你逞能。”

二狗向他望来。

陈生的脸色还有些白,眼神却很平静。两人对看片刻,二狗嘴边动了一下,最后没有替自己辩。

“我在里头的时候,也骂过自己。”

江明刚从残墙边走过,听见这一句,立即看向两人。

二狗抬了抬下巴。

“方才台上不是说过,想再往上走一步?卡在同一处,谁不骂。”

江明笑了。

陈生也笑了,只是笑得胸口一疼,立即低头咳了一声。

二狗伸手扶住他。

“还能走?”

“沿原路,走慢些。”

他确实能走。

可翻上倾倒的城墙时,左手撑了一下,胸腹间便又闷了一阵。二狗一把扣住他的腕,先将人拉上石脊,随后自己落在更低一点的地方。

江明在后头攀上来,看见陈生被扶得稳稳当当,嘴里的调笑话终于没说,只将长匣的绳结重新紧了一次。

高石脊向外延伸。

三人一步步走过旧刻纹,穿出灰白山影。

风扑过来的那一刻,二狗停住了。

风里有草叶的气味,有滩地泥土晒热的微涩。远处一只小虫从断碑后飞起,扑到他衣上,又立刻飞走。

太阳正在山侧,照得他眉骨旁那一道旧伤微微发亮。

二狗抬起头。

陈生站在旁边,没有说话。

过了许久,二狗才低声道:“外头还是这么亮。”

他张开沾血的手,朝太阳握了一下,像要先把这点光攥进掌心。

吕七正在桌边打瞌睡,听见石脊上有脚步,先睁眼,再抬头。

两个人,变成了三个。

多出的那人黑衣残破,上臂带血,却站得极稳。吕七只看了他一眼,刚探出的神识便缩了回来。

“这位……”

“我兄弟。”陈生道。

吕七看看陈生,又看看二狗,一时竟忘了接话。

江明走到桌边,取下长匣。

“有干净的布没有?”

“有,有。”

吕七立即回到断碑后,翻出一卷没用过的白布。江明要给他灵石,他摆手摆得极快,眼睛却还忍不住往二狗那里看。

二狗正将衣袖卷高,让陈生看伤。

红线留下的几处伤口不浅,外来气息却已随着断桥散去大半。陈生以细火拂过剩余血痕,先封住最深的两处,又将布带递给二狗。

“自己裹,别把伤口挤在一块。”

二狗低头照做,动作并不熟。最后一圈还没缠好,江明已走过去,帮他把松下来的布头压住。

“你们两个,真能把好布都用成破布。”

陈生闻言,往他擦破的双手看去。

江明立即将手背到身后。

“我的一会儿再裹。”

二狗看了看他,忽然笑得直不起肩。

这一回,吕七终于也跟着笑了一声。

他们在断碑滩歇到次日。

陈生胸前掌伤没到破裂脏腑的地步,散去积在经脉中的余劲,气息便顺了些。二狗上臂包扎好,又服了药,没有再出手试印;江明两手擦伤最轻,法力却空,趁夜多调息了几回。

吕七守着桌子,看那位黑衣人坐在石边,仰头看了半夜的星。

他没有再问从哪里带出来,也没开口卖第四段路。

次日出发前,陈生告诉他,石室内侧的那段水路还在,渡舟却损了,军池不能照从前的法力去催。至于再往里面有什么,他没有细说。

吕七一一记下。

江明将一块灵石放到桌上,算了布钱和借地方的钱,吕七没有再推。

二狗向前走出一段,忽然问:“回神都?”

陈生点头。

“秦林等着你的话。我也要回一趟紫令堂。”

“那小子现在怎样?”

陈生想了想。

“比你想的凶。”

二狗眉梢抬起。

“能杀芈家?”

“已经杀了。”

“那是得见一见。”

二狗将衣内那块军令按了按,没有再问朝中有多少人。他沿官道走了几步,见陈生仍落后半个身位,便把脚步放慢。

江明跟上来,问:“方才那条退军纹,我记下的三处,往后还能练么?”

“能。”二狗道,“练会了,比你掰那只铜盘省力。”

“也能用来御剑?”

“得看你那口剑。到了有地方坐的处所,拿出来,我看看。”

江明立即将长匣背正。

陈生望了他一眼,笑道:“方才还舍不得旧剑。”

“两口都舍不得。”

“倒不贪。”

二狗听着两人说话,走了一段,忽然叫了声生哥。

“我也还有一句话。”

陈生转过头。

二狗的神情难得有些认真。

“你养好伤,那道一印,我再同你打一回。”

“同我打,还是打我?”

“先同你打。打不好,再打你。”

陈生愣了一瞬,随即笑骂出声。

二狗也笑。

官道前方有人驾车经过,看了他们一眼,只当是三个远行的修士,勒住坐骑,让开了被雨水冲窄的一截路。

二狗从车边走过时,伸手扶了扶快要落下的布包。赶车人道了声谢,他点头,继续向前。

身后的灰白山影已经被远处的坡地挡住。

这回,陈生身边的脚步,没有再停在里面。

第373章 国师归来

回到神都,前后又用了二十余日。

三人借过官道传送,也在沿途停下养伤。陈生胸中的余劲散了,提起一口长气时,仍会牵出钝痛。二狗肩上的布换过几回,最后一回是他自己缠的,缠好便提着陈生的药瓶问,剩下这些还要吃多久。

陈生叫他吃完再问。

到了城门外,江明先往紫令堂去。陈生在路上已托官道传讯,将二狗脱困的消息与入宫时辰告知秦林,却没让沿途官吏替他们张罗迎接。

宫门前,药监长迎了出来。

他身后仍跟来了两名内侍,一人捧衣,一人托着金盘。更远些,一名礼官走得很急,手中捏着一卷刚取出来的仪注。

药监长望见二狗,脚步先停了停,随后深深躬下身。

“国师。”

二狗站在门外,看了一眼那两名内侍。

金盘上放着一条新绶带,捧来的外袍也是黑色,领口绣着极细的金纹。他肩上那片被血浸过的旧衣,在日光下已洗得发灰。

“让他们退下。”

药监长抬起头。

“陛下说,衣冠若有不便,可先在——”

“我这衣裳见不得人?”

礼官已经赶近,听见这句,急忙躬身:“国师远归,朝中礼制已有更易,晚辈须先请教入见之仪……”

二狗的脸色冷了。

“那就慢慢请教。我先去见秦林。”

药监长抬手止住礼官,将那两名内侍一并遣走。

陈生跟着进门,没有劝二狗换衣。走过侧廊,二狗回头看了看仍站在门边的几人,低声道:“人还没认清,先知道我该穿什么。”

“宫里向来不缺衣裳。”陈生道。

二狗瞥他一眼,嘴边终于有了点笑。

偏殿的门开着。

秦林没有在座上等。他站在门内,身后隔着一道屏风,屏风边的案上搁着数封刚拆的奏疏。

药监长尚未开口,他已望向了二狗。

两人隔着门槛,停了一会儿。

秦林目光从那道眉骨旁的旧伤,移到黑衣,再回到脸上。他盯了好一会儿,嘴唇开合,却只叫出两个字。

“国师。”

二狗也在看他。

黑色帝袍下,这位皇帝站得很稳。那双眼睛很亮,望过来时带着逼人的锋锐;右手垂在身侧,指节却还不大自如,随目光收紧了一下,又慢慢松开。

“陛下。”二狗道。

秦林没有立即侧身让路,先问了一句。

“父皇最后,究竟是怎样死的?”

药监长低下了头。

陈生站在旁边,等秦林自己回过神来。过了几息,秦林才退开半步,让两人进去,随后挥退药监长,亲手合上了门。

殿里只剩三人。

二狗没有去看上首的座,走到屏风旁,从衣内取出那半面铜令,放在案上。

铜片碰到木面,响了一声。

秦林的手停在半空。

残龙纹已经黯淡,另一面的令印断在中间,边上还有一道极深的劈痕。二狗将它翻过来,露出留在背面的旧刻。

“遇袭那夜,他带在腰间。”

秦林拿起铜片。

他用左手,右手却仍抬着,过了一阵,才以僵硬的指腹轻轻触过断口。

“严承岳。”二狗道。

秦林抬头。

“是他动的手。后阵由他掌着,陛下去接前军,背后留给了他。他先以军阵相助,等皇道之力接上,再翻令,引血入阵,一掌打在后心。”

秦林没有插话,只将铜令握得更紧。

二狗说到秦证回身扯断严承岳手臂时,他忽然问:“父皇认出他了?”

“认出了。也看见他胸前刻着的血纹。”

“那时你在哪里?”

“前军被截在光幕外,我去撕开退路。等我到高台,他已经中了第二回杀招。”

秦林看着他。

二狗没有避开,继续将军士倒下、联阵甲转红,以及秦证最后那一击说了出来。

那些细节,他讲得很慢。

秦林几次想问,嘴唇动了,终究等着他说完。直到二狗说出那两个字,皇帝才将铜令放回案上。

“退兵。”

他自己重复了一遍。

“父皇最后说的是这个?”

“是。”

“后来呢?”

“我接住他时,他还张过口。我俯下去,只听见半声吐息。”

殿中静得很。

秦林望着铜令,忽然又问:“没有别的话了?”

“没有。”

二狗说完,右手在案边握了一下。腕上的勒痕尚未退净,绷紧时,皮肤微微发白。

秦林低下头。

过了许久,他才道:“朕想过许多种。”

陈生没有问是哪几种。

秦林伸手去拿案边的杯子,碰到了杯沿,却没握住。他将那只手收回来,改用左手端起,喝了一口已经凉了的茶。

“严承岳呢?”

“死了。”

“尸身、元婴?”

“都灭了。”

秦林看向二狗的眼睛。

“他管过后阵,见过那些人。朕还没有问他一句。”

“他最后还想开口。”二狗道,“我没让他说下去。”

秦林的脸色终于变了。

“为什么?”

“不想听。”

这三个字让殿里的气息陡然沉下去。

秦林站起身,帝袍的袖口擦过案角,铜令轻轻转了半寸。

“国师是不想听,朕却想。父皇死了,二皇兄死了,八皇兄也死了。那些还活着的人,谁当年伸过手,谁直到今日还在拿冥血养命,朕不能只听一个死字。”

二狗看着他,脸上的神情也硬了。

“芈家老祖活着么?”

秦林一顿。

“齐天鸿呢?”

“朕是在杀敌。”

“我也是。”

两人的目光碰在一处。

陈生坐在旁边,胸中那点钝痛隐隐发作。他原本端着茶,听到这里,索性将杯子放下。

“严承岳的元婴往江明那里跑。我站在台上,胸口刚挨过他一掌。”

他说到这里便停了,拿起茶盏润了润喉。

二狗的右手慢慢松开。

“当年他开口求往上走一步,秦证就在他跟前流血。这一回,我先想着杀他。”

秦林望了他一阵,重新坐下。

“朕知道了。”

二狗也没道歉。他转头,看见屏风后露出来的那张拓片,认出了自己留在撤军石室里的字。

“还带回来一个名字。”

秦林立即抬眼。

“方厚,守渡营。那夜我最后看见他,是举着半面旗,拖一个断腿的兵往西走。”

“活着出去了?”

“出了我能看见的那段路。再后头,我没看见。”

秦林取了一张素纸,左手执笔,写下方厚二字,又在旁边记了守渡营。

笔尖停在末尾,他没替这个名字添上死字。

“朕让人寻。”

二狗点头。

秦林将那张纸收在铜令下,又从案旁抽出另一卷未落玺的文书。

“当夜之事,要让朝中知道。”

“你问,我会说。”

“不只朕问。明日,朕要让他们见到你。”

二狗看向递来的文书,目光刚落上去,眉头便拧了起来。

上头字迹尚新,开篇便是国师镇凶地、护国运,末尾写着奉诏还京。空着几处,显然还待添入入宫之后的说辞。

“哪一道诏,送到了黑崖里?”

秦林没有看纸,只看着他。

“祖师这一趟,朕出过力。”

“出过。”陈生道。

秦林眼中微动。

陈生接着道:“东西我用了,人是我兄弟。”

二狗将那卷纸摊在案上,点住奉诏二字。

“我被困在里面,连一只手都抽不出来。你写得像我自在守了这么多年,等你一句话,便回来了。”

“朕若将你被困、身上有伤,都写给他们看呢?”

秦林抬起头。

“今日有人向朕说,芈家已伏诛,别再翻当年的账。有人说那时的事过于久远,见证的人都没了,查下去只会离心。你明日站在这里,他们再说一句没人见过,朕就让他到你面前说。”

“那便叫他说。”

二狗的声音冷了。

“要听当夜的事,我来。谁要拿一张纸,把我的生死编成一道好看的旨意,先问问我答不答应。”

秦林指腹压着案边。

“你如今仍是元梁国师。”

“我知道。”

二狗俯下身,望着他的眼睛。

“我连旧帝都没护住,用不着拿这两个字提醒。”

秦林的嘴唇抿紧了。

陈生抬手,将那卷纸从两人之间抽开。

纸的一角已经被二狗按皱,字却仍清清楚楚。他没有撕,也没有替秦林改,翻过来扣在了空处。

秦林看着他的手。

“祖师也想把他带走?”

“想。”

陈生答得很快。

秦林转眼看向二狗,眼底方才压下的火又抬了一点。

二狗却先笑了一声。

“你们倒都替我找好了地方。”

他直起身,袖口下露出尚未退净的勒痕。

“我想回广秀,也想把当年在谷外使法宝的那几个人找出来。芈氏铜盘、齐氏短幡,我看见了,但究竟是谁持着,隔了战阵,我没有看清。你要翻旧账,别替我省。明日有谁想当面说,尽管叫来。”

他说着,将那份文书推回秦林面前。

“奉诏那句,划掉。”

秦林没有马上拿笔。

“剩下的呢?”

“余下你自己写。别把我编成一个死人,回来再给你们当画像。”

殿外传来很轻的脚步声,走到门前,又停住了。

秦林盯着二狗看了一阵,终于提笔,将那几个字划去,随后把整卷文书收到了手边,没去拿玺。

“明日不设庆礼。先召在京、有旧军履历的几人。”

“叫他们带脑子来,别带衣裳。”二狗道。

陈生低头,没忍住笑了一下,胸口随即一滞,笑声变成了咳嗽。

二狗立刻看过来。

秦林也看了他一眼,原本还想说什么,最终只伸手,将那只茶盏推近。

陈生缓过气,没说自己无事。

“今日到这里。该说的,明日接着说。”

秦林没有留饭。

他让人取来温养伤处的灵材,交给药监长送出去,自己仍留在殿内。二狗走到门边,却被他叫住。

“国师。”

二狗回头。

秦林手里又握着了那半面铜令。

“父皇的遗骸呢?”

二狗停了片刻。

“我把他放在断石后。折阵的时候,断石沉下去了。后来找过,没寻着。”

秦林眼中的光,慢慢暗了一点。

二狗看着他,没有再说话。

许久,秦林问:“他最后还觉得自己能赢么?”

“我不知道。”

二狗握住门边,回想了一阵。

“我叫他先退。他看见军士倒下,便把我的手推开了。”

秦林低下头,以指腹一遍遍抚过残龙纹。

“明日,”他说,“朕还要问。”

“我来。”

二狗应完,才走出门去。

……

紫令堂已经备好了饭。

江明坐在后院,长匣搁在脚边,见他们进来,先望了望两人的脸。

“打起来没有?”

陈生拉过椅子:“你盼着?”

“我怕饭又热一回。”

二狗坐到另一边,拿起筷子,夹了一块肉,嚼过两口,忽然停下来。

江明看着他:“不好吃?”

“好吃。”

二狗低头,又夹了一块。

赵管家正捧着几封信过来,到了桌边,先看陈生,再看这位黑衣客人。江明只同他说东家兄弟来了,别处一句也没多讲。

陈生接过信,让他坐着吃些。

赵管家忙说前头还有事,退了出去,临走又将二狗面前那碟肉往近处挪了挪。

二狗看见了,朝他点了点头。

“你这个地方不错。”

“还成。”陈生道。

江明刚要说话,见陈生拆信的手停了,便将筷子放慢。

那封信写得很短。

大爷找到了,尚在焚城,我先留下。

瞒我的事,我还在生气。

但你若寄信,我会看。地址就用大爷上回写的,别只报一句无事。

陈生看了一遍,又看了看封上的日期。

封上的日期已在数周之前。陈生又叫赵管家过来,问道:“这之后还有焚城来的信么?”

“只有这一封。”

陈生的手指压着纸边,慢慢抚平了一道折痕。

“墨欢?”江明问。

陈生点头,将信递过去。

江明看完,轻轻出了口气。

“至少赶上了。”

二狗没听过这个名字,望向陈生。

“一位朋友。他大爷是我在守蔵室认识的前辈,出门前,寿数已经不多了。”

“你没告诉他?”

“没有。”

二狗点点头,继续吃饭,没替他说情。

饭后,陈生借后院的案子写信。

他先将黑崖归来的日期写上,随后落笔。

“我已回神都,二狗找到了,是活人,如今就在我身边。我们还动得了筷子。

“出崖前挨了一掌,伤在胸前,已服药调养,长些的气还提不顺。旧肩伤也未尽愈。

“你那句生气,我收到了。那晚我说早知道只是添烦恼,话说满了。

“替我向大爷问安。他那封信我查过了,帮了忙。也请他有精神时再写几句近况,不必长。你若愿说你的,也一道寄来。”

他将信读过一遍,没有把受伤那段删去,封好后叫来了赵管家。

“照墨老上封信的地址,寄到焚城城外。今夜送去。”

赵管家应下,取过传信用的灵石,趁街上还亮着灯,亲自出了门。

陈生走回席边时,江明已经把那口青碧剑取出了半截。

二狗两指虚扣在剑脊上,一线法力从指间落下,才碰到旧主残印,剑中便传出了一声低鸣。

他收回手,没有再往深处压。

“今晚不拆。”

江明看了一眼他的肩,点头,却还望着剑。

二狗笑了:“先把你平日怎么催它,使给我看。”

江明立即起身,让开桌椅,在空处站定。

陈生坐回原来的位置。门外脚步声响起,赵管家从街上回来,说信已经交进传信铺,随后去前堂看灯了。

院中剑光亮了一线。

二狗敲了敲桌沿,叫江明把方才那一下再使一遍。陈生抬头看去,顺手将还温着的半壶酒,推到兄弟手边。

第374章 旧旗

次日入宫,门前没有捧衣的人。

陈生与二狗进偏殿时,里面已经坐了三个人。一个独眼老修,一个鬓发全白的妇人,还有个肩宽背厚的中年男子,身边立着一杆卷起的青黑旧旗。

中年男子看清二狗,猛地站了起来。

椅脚擦过地面,发出很刺耳的一声。

“国师。”

二狗停在门内,目光落到他眉下。

“贺延章?”

中年男子应了一声,嘴角先动,随后竟抬手遮住了半张脸。

二狗看见他缺了两根手指。

“你那时候,管后路的粮。”

“是。粮送到松河,末将便奉令折回了。”

贺延章放下手,眼中已收住了水色,肩背却绷得极紧。

另外两人也站起行礼。独眼老修曾在允泽前军待过,后来眼伤,被送回神都;老妇人掌过军器,龙骧卫第一次出征时,甲上的接阵铜片有一批便从她手里出去。

三个人,都没有到过最后那片山谷。

秦林坐在上首,以左手示意他们坐下。

殿内没有史官,只有一名近侍守在门边。

二狗落座,先说严承岳在高台上翻令引血、从背后下手。说到秦证回身扯断严的手臂,独眼老修的手忽然重重压在了膝上。

秦林把那半面出征铜令摆到案上,等二狗讲到最后一击,才接着问。

“父皇最后斩的是主阵。甲上的余纹,为什么还在?”

白发妇人先动了一下,随即看向二狗。

“你说。”二狗道。

“旧甲有相接的小阵。主阵断了,三人五人凑近,仍能借同袍的力。”

她的双手搁在膝上,慢慢攥住衣料。

“本来是怕主将战死,小队就再无自守之力。后来那一批甲,我给添过这道纹。”

二狗点头。

“那夜它反过来抽人。我打严承岳一掌,台下便有人倒,所以后来叫他们脱甲。”

老妇人的嘴唇白了。

独眼老修看向秦林手边的铜令,却问:“先帝到最后,还站着?”

“斩主阵时,还站着。”

老修点了两次头,手伸进袖里,隔了一会儿,才取出一块帕子,擦那只已经没有眼珠的眼窝。

贺延章始终没有说话。

二狗望向他。

“严承岳调你回去的?”

“不是。松河断了一路粮,是陛下亲手发的令,叫我守后路。”

贺延章看了一眼铜令。

“我那时还怨过。他们在前头破宗灭族,我只管押车。往后封赏,哪里轮得到运粮的人。”

殿中静了片刻。

他抬起脸,直直望着二狗。

“国师,我没见过严反戈。他往粮队里安过谁,我也不知道。今日我能说的,就是这些。”

“够了。”二狗道。

秦林没有继续追问一个运粮的人,要求他讲出山谷中的每一张脸。

他将铜令收入案边,目光转向那杆青黑军旗。

“贺延章,你今日把旗带来了。”

贺延章将旗往身边收了半尺。

“末将是带来见国师的。”

这句话说完,方才还在擦眼的老修抬起了头。

陈生坐在二狗旁边,原本正慢慢饮茶,闻言也放下了杯子。

秦林向门边的近侍道:“叫杜振川进来。”

一个穿窄袖军袍的男子走进殿内,向秦林行礼后,又朝二狗深深一躬。他没有上前攀认旧事,只站在贺延章斜后方。

“京西营副将。”秦林道,“你认得他。”

“末将举荐的人。”贺延章道。

杜振川的下颌绷了一下,没有接话。

秦林问:“东郊两处粮渡,要京西营出二百人轮守六十日。三道军令,你退了三次。今日说给国师听。”

贺延章握紧旗杆。

“我营里有八百人,能出战的六百不到。城中那一夜,西门各路往外逃,我的人死伤了多少,陛下知道。如今又调走二百青壮,剩下的老弱,谁管?”

“留在营里的近四百人,管不了伤者?”

“今日二百,下一回呢?”

秦林眼神一沉。

贺延章知道自己说重了,却没有把话收回。

“换守回来,还是不是京西营的人?陛下想拆这座营,不是今日才想。”

杜振川忽然开口:“轮守的军令上,没有改籍。”

贺延章转过头。

“你急什么?”

杜振川脸上起了一层红,仍道:“二百人已经排出来了。都领过预发的行粮,等了十一天。”

“谁让你排的?”

“军令到了,营主没有点人,我便先排。”

贺延章猛地站起,旗尾碰在地上,卷住的青黑旗面散开半尺。

秦林左手落在案上。

“坐下。”

殿里的气息骤然一紧。

贺延章仍站着,元婴中期的法力在肩头起了一层,又被他自己按了回去。他没有坐,只将军旗送到二狗面前。

旗角缝着一块旧布,金色印纹已经很淡。

二狗认出了自己的手笔。

这是当年的押粮旗。粮路上伪令横行,二狗留过一道印,命这支押粮军只认本部将令,不被沿途世家借官面征走。

“这句话,是国师说的。”贺延章道。

二狗伸手,摸了一下那道褪色的印。

“说过。”

秦林没有催他。

贺延章的呼吸却重了些。

“这些年,旗没倒过。冠军侯起兵,我的人跟着打;陛下回来,我也奉过命。如今国师回来了,这支军,末将可以交给您。”

二狗抬头。

“交给我,然后呢?”

“您掌军,我领营。”

他说得很快,说完,才看见二狗的眼神。

二狗将旗杆向下按了按。

“我若不留在神都呢?”

贺延章脸上的血色一点点退了。

“国师才回来,就要走?”

“我要去哪里,是另一回事。你今日要我说,这道旧印,还能替你挡下皇帝调二百人的军令?”

贺延章没有作声。

二狗的手已落在旗角。

贺延章忽然握住了他的手腕。

“当年迎陛下时,他也许过我,让我守着这座营。”

二狗看向秦林。

秦林坐得很直,右手的伤处被袖子遮着,左掌却仍压在案上。

“许过。”

“现在不许了?”二狗问。

“朕许他领营,没有许他令出不行。三次不发兵,东渡的军士就多守了十一日。那边的人也有伤,也想回来。”

贺延章转过头,眼中终于露出怒色。

“陛下如今能用的人多了,自然不缺我这一个。”

“贺延章,”秦林道,“你护的是营里的人,还是你这张椅子?”

贺延章握着旗杆的手在发抖。

半晌,他道:“都有。”

没人接话。

他索性往下说。

“我替这支军借过粮,垫过药。有人战死,孤儿寡母先来找我,不会先到殿外敲门。我也不是白做的。他们认我,我才有今日。陛下要兵,我就要一个准话,调出去,还送不送回来?”

秦林看着他,过了几息,才道:“此次轮守,六十日原籍归营。伤卒药粮不扣,由药监长与军粮官分别发。你的营田,不拿来填东渡的窟窿。”

贺延章肩背微微松了一点。

秦林却又道:“京西营,自今日由杜振川代领。你交兵符。”

那一点松动立刻消失了。

“陛下已经选好了。”

“第三道令回来时,朕便选好了。”

贺延章盯着秦林,随后又望向二狗。

二狗揭起旗角的旧布,两指合拢。

一道淡金色的光从布中抽出,在他指间散去。

那道曾挡过世家伪令的印,终于只剩下一片浅白痕迹。

贺延章松开了他的手腕。

二狗把旧布叠好,塞回贺延章掌中。

“留下作个念想。别再叫它替我发令。”

贺延章看着掌中旧布,许久没有动。

秦林没有再催第二声。

他等着。

最终,一枚青铜兵符从贺延章袖里取出,放在了案前。

铜符碰石,不重,独眼老修却跟着颤了一下。

杜振川走上前时,贺延章忽然道:“那二百人,是我带出来的。”

杜振川停了一步。

“我也是。”

他说完,接了秦林交下的兵符,与门边近侍一同出殿。门外两名亲卫随他们而去,靴声沿廊下渐渐远了。

贺延章没有向新营主行礼。

秦林免了他的统营职,旧功与原俸不夺,营中私物可以取回,甲、阵器和军中灵粮一件不许带走。

贺延章听完,躬身行礼,转头向二狗道:“末将还以为,今日能把您接回营里。”

二狗看着他。

“我会去看那些伤兵。”

“那已不是我的营了。”

贺延章退了出去。

二狗没有叫住他。

……

另外两人又坐了许久,说起几位旧人的近况。等他们离开,殿里的茶已经凉透,秦林命人换了一回,仍等着京西营的回报。

秦林望向那杆留下的军旗。

“国师觉得朕逼得太急?”

二狗没有立即回答,先看了陈生一眼。

陈生正把一口茶慢慢咽下去,察觉目光,抬头道:“问的是你。”

二狗转回去。

“你怕我当众替他挡。”

“怕。”秦林道。

“所以让他带着旗,来见我。”

“旗是他自己带的。朕知道他会拿出来。”

二狗笑了一声,笑意很淡。

“军令是你的,兵也是元梁的。我收自己的旧印,不是替你往后每一道令押名。”

秦林看着他。

“朕知道。”

“伤兵的药,别叫他们再去向贺延章求。”

“药监长今日会去。”

二狗这才起身。

走到门前,外头一行人赶了回来。日头已经偏过殿脊,距杜振川离开,过了近两个时辰。

近侍手中抱着如今京西营的主旗,比殿内那杆旧粮旗宽出一倍,旗顶的铜兽缺了一只耳。杜振川没有跟回,他留在营中点人,第一队轮守军已经出西营,沿城外道路往东郊去。

“有几人拦?”秦林问。

“贺营主的两名亲校,拦在甲库前。杜代将让他们当场选,留下听令,或卸甲跟旧营主走。一人留下,一人卸了甲。”

秦林点头,命他将旗留下。

二狗看了一会儿那只缺耳的铜兽,才走出殿门。

陈生跟上去。

“还去京西营?”

“去。”

“今日?”

二狗瞧了一眼他的脸色。

陈生先道:“你去。紫令堂的门,我认得。”

二狗没有再拉他同行,只在廊尽头停下。

“生哥,等肩顺些,我们还得打那一场。”

陈生点头。

“记着。到时你若用今日这张脸唬我,我也不认。”

二狗终于笑了,转身向宫门走去。

第375章 同一道印

四十日后,神都城北。

废石场里积着一层薄水,风吹过,浅浅的倒影便碎开了。

陈生把外袍叠在场边一块干石上,抬起左臂,缓缓转了一圈。这些日子,胸口已经能提足一口长气,左肩寻常动作也无妨,只在法力骤然贯入时,筋骨深处仍有一点牵滞。

他试过了,才向场中走。

二狗已经在那里等着,衣袖束起,肩头受过伤的地方留着几道浅痕。

“就在这里?”

“你还想进宫打?”陈生问。

二狗看了看四周高低不平的石地。

“也成。打坏了不用赔。”

江明坐在远处石坡上,长匣横放在膝前。他本来只想出来看看,见陈生将铁剑、宝珠一并收起,身子立刻坐直了。

“真打?”

“不然叫你来看什么。”二狗道。

江明转头去看陈生。

陈生没有笑,先向二狗伸出右手。

一道金印从掌中浮起,向外撑开半尺,凝在两人之间。

二狗也抬起手。

他的掌印初时更深,稍一停,光便一层层收窄,直到与陈生的印势相近。

“法力就用这些。”二狗道,“再往下压,打着不痛快。”

陈生看着他掌下的光,没有急着点头。

二狗笑了。

“不信?你来碰。”

两道印轻轻一接。

陈生肩头一沉,脚下薄水向外荡出了一圈,却没有被逼退。

他收掌。

“不用法宝,只用道一印。谁先认输,谁输。”

“你想赢?”

“这还用问?”

二狗的笑意更深了。

“我以为你只想看看,我这些年把它练成了什么样。”

陈生往后退开五步。

“打赢了,再看也来得及。”

话音刚落,他便出掌。

金光贴着积水冲过去,不高,却将那一层浅水整个推起。二狗没有抬掌硬迎,侧身让过最厚的一线,右手向下一扣。

水墙当中凹进一个掌形。

陈生的第一记道一印,就从那处凹陷向两边裂开。

二狗穿过裂口,已经到了他身前。

陈生左手抬起。

掌印才结了一半,二狗便变了势,原本向下扣的五指忽然翻上,托住陈生左腕,顺着未成形的印力一推。

陈生肩骨被牵,身子向旁边偏去。

他没有硬把左臂扳回来,右脚擦地,整个人借这一推转了半圈,右掌从肋侧压出。

二狗抬肘挡住。

两人之间砰的一声,地上薄水被震成了雾。

陈生连退三步。

二狗只退了半步,随即再进。

这一回,他的掌印不见得更大,却抢先落在陈生将要站稳的地方。陈生脚跟尚未落地,脚下便像压进一块沉石,不得不临时往右让去。

第二道印,已在那里。

江明看得背后一紧。

陈生却将左脚踏下,迎着那股向上的力,双掌同时向外一分。

金光在他身前撑开。

二狗先落的两道印被他向两侧带偏,落在地上,各压出一道浅坑。陈生从中间抢出半丈,右掌随之斜劈,逼得二狗偏头避过。

一缕头发从布带里松下来。

二狗抬手掠了一下发尾。

“有些样子。”

陈生没有接话,第三掌已经到了。

他打得比方才快,落印也重,地上的碎石一片片翻起。二狗不再退,只以双掌交替相接,每次都在陈生的印势将成未成时,先压住一处。

这道由他创出的法门,在他手里实在太熟。

陈生几次试着变势,都被他抢先一步。右掌刚向内收,回印便被堵住;左掌才想托高,二狗的手已压到腕前。

再这样打下去,自己连一掌都落不实。

陈生忽然踏进了二狗身前。

江明眼睛一睁。

这一步,几乎将胸口直接送到了二狗掌下。

二狗没有收力。

他翻掌推来,陈生双臂交叠,硬接这一记。沉重的法力透过臂骨,推得他脚下连滑数丈,鞋底刮起了一条长长的石痕。

最后半丈,陈生右掌向下拍去,借反震止住退势。

石屑溅了满身。

二狗追上来。

陈生仍在退,一边退,一边落印。每一道都迎着二狗而去,有的相撞便散,有的被带偏,砸进脚边的石地。

废石场上,浅坑渐渐连成了一片。

江明皱起眉。

陈生退到一块方石前,脚步终于慢了。

二狗的右掌随之压来。

这一次,陈生没有向旁边闪。他背抵方石,双手一起抬起,将所有看得见的金光都推到了面前。

两道掌印正面撞上。

二狗肩背一沉,右脚向前落下,要将这一印彻底压过去。

脚下的石屑,却忽然向后滑了一寸。

他眼神微变。

先前陈生打偏的一道印,并没有全散。金光贴在碎石底下,极薄,正沿着二狗脚跟向后翻起。

不是要伤他,只是要推他这一步。

二狗立刻变掌,将面前一半印力落向脚下。

几乎就在同时,陈生左手翻了过来。

方才撑在正面的金光向两边分开,露出一直藏在其后的右掌。陈生顺着自己分出的缺口挤进半步,一记极窄的道一印,结结实实拍在二狗肩前。

砰!

二狗向后退出一步。

脚下那道残印又从后迎来,他腰身一拧,横踏出去,才避开了两面相撞。

江明霍然起身。

“中了!”

陈生却没有停。

他等的就是二狗这一步横移。右掌一落,左掌已到,前后两道印追着压来,要把二狗逼进方才踩出的浅坑。

二狗目光一亮。

他不再向旁边走,反而迎着第二掌伸出右手。

掌心金光先向外撑,撑到一半,忽然向内回收,贴住陈生印势的侧缘。陈生要往下压的力量,被带着转过半圈,竟从两人脚边斜斜翻了回来。

陈生立即收左手。

慢了一线。

二狗左掌落下,扣住的正是他准备收回去的那一折。

陈生方才留在地上的残印,也被这一牵,重新亮了起来。前印、回印、藏在碎石里的余力,同向一处汇去,将他退开的路压住。

他想把这些力重新分开。

二狗已经跨到面前。

右掌悬在陈生胸前三寸,掌下金光凝而不发,另外几道相连的印势却还在一寸寸收紧。

陈生双足陷地,左肩被自己的回印牵住,右掌能动,却再找不到先出手的空处。

他看了一眼二狗,又看了看那只离胸口很近的手。

“我输了。”

二狗立刻散印。

地上石屑哗地落下。

陈生抽出两只脚,肩头转了一下,长长出了口气。

江明走近,先看他胸前,又去看二狗衣上的掌痕。

“可是先中了一掌。”

“中了一掌。”二狗承认得很快。

他低头,用手按了按肩前,龇了一下牙。

“还挺重。”

陈生伸手拍掉袖上的碎石。

“还能再重些。我怕一掌把你打远,后面就接不上了。”

二狗抬眼看他。

过了片刻,两人一起笑了。

陈生笑完,忽然问:“你什么时候看出来的?”

“哪一处?”

“地下那道印。”

“踩上去的时候。”

二狗用脚拨开一片石屑,下面还有一缕将散未散的金色。

“我看你一直往外散力,以为是跟不上,只顾盯你手里。没想到你先落下去的那些,还能留下这么薄一层。”

陈生蹲下,将那缕金光收散。

“炉底的火灭没灭,炼丹的人总要留心。”

他站起来,目光仍在地上。

“你最后借走了我的回势。”

“你把每一道力都留下了,我自然也能用。”

二狗走去场边,拿起陈生的外袍,抛给他。

“不过下一回,你未必还这么打。”

陈生接住衣裳。

“知道就好。”

……

三人在石坡上坐下时,日头已经升得很高。

二狗拿过江明带来的水囊,喝了一口,目光落在自己舒开的五指上。

离开黑崖之后,他去过京西营,看过伤兵,也与几位旧识见过面。有人认出他便流泪,有人话说到一半,就把子侄往前推,想让他看一看资质。

他看了两个,第三个没看。

那一次,送他出门的人脸色并不好。

陈生没有问他后不后悔,只把水囊接过来。

二狗却忽然道:“我想过,在里面就把化神这一关冲开。”

陈生停下手。

“知道。”

“我以为只要强过去,连那道口子一并打碎便是。”

二狗看着远处被两人打出浅坑的石场。

“有一回,我连要怎样出去都想好了。结果聚起的力没归到自己身上,倒叫严承岳笑了半宿。”

江明望过来,没有接话。

二狗把右手握拢,又松开。

“现在没人拖着我了。我还想试。”

陈生道:“那就试。”

二狗侧过脸。

“不叫我歇几年?”

“你肯么?”

“不肯。”

“我也不肯。”

陈生低头看了看自己的掌心。

方才被压住的那一下,到现在还有很清楚的余感。他能骗得二狗中一掌,却接不住对方真正摸清来路后的反击。

“等下次动手,我还想赢。”

二狗笑出了声。

“生哥,你这些年,倒是越活越不客气。”

“以前也没说过,让你一直赢。”

风从石坡下吹来,带着草叶的腥气。

二狗起身,向南面神都的方向望了一会儿。

“三日后,我回广秀。”

陈生抬头。

“定了?”

“定了。回太平峰,看一眼,再看看那边如今是什么样。我离山的时候,一门心思想修补金丹,想着总有一天,要把外头那些更强的人也打过去。”

他说到这里,低头笑了笑。

“这一点,倒还没改。”

陈生把外袍穿好。

“秦林那里,你自己说。”

“我自己去说。日子不改。”

二狗停了一下,伸手向陈生。

“这条路,你同不同我走?”

陈生借他的手站起。

“同去。”

江明也提起长匣,却没有马上答话。

陈生看了他一眼,没有替他作决定。

二狗往下走了两步,又回过头。

“今日这一掌,我记下了。”

陈生跟在后头,将袖口最后一点石屑弹落。

“记着下次还账。”

第376章 两炉

墨欢在城南丹坊的柜前站了半晌,终于将灵石推了过去。

“两份,都要。”

许鹤没有立即收钱。他将左手边的玉盒重新打开,露出盒中一株根须如金丝的灵草。

“这株便占去小半药钱。你昨日看过,今日再看一遍,出了门,枯一根须也别找我。”

墨欢俯身,将那株灵草连同养根的玉泥检查一遍,合上盒盖。

“就怕你换给我。”

许鹤哼了一声,这才收起灵石。

两人不是头一回见。墨欢住到邵循院里的这几个月,时常来城中换取灵药,前后问过四次三阶炉的租价。许鹤起初还肯慢慢讲,后来见他总问不租,隔着半条街,便先向他伸出两根指头。

今日那口炉终于空出两日,墨欢把攒着买新炉的灵石挪了过来。

炉租付罢,药材到手,储物袋一下瘪了许多。

许鹤将钥牌搁在柜上。

“后日辰时之前出来。”

“知道。”

“你若临时再要药,坊里凑不出第三份。”

“两份够了。”

墨欢答得很快。拿起钥牌时,他又看了一眼柜后的药架,终究没有把那句话收回来。

他要炼金汤水丹。

滋长金丹不朽之气的三阶丹药,方子早就收在丹札中,翻得边角发毛。这些年,他连金丹都已经结成,三阶丹却总在最后一关败下来。方上那几行字,他闭着眼也能默出,轮到自己开炉,纸上的通顺便全不算数。

许鹤替他开启丹室,他却没进去,先道:“午后才用。”

“已经开始算炉租了。”

“知道!”

墨欢出了门,走得很快。

他还有最后几味辅药放在邵院里。其中一味必须现剥现用,昨夜他不放心,亲自守着玉匣养到天明。

回院时,墨沉和邵循已经摆开了棋盘。

墨沉今日精神尚好,灰袍穿得整齐,身边摆着茶壶。邵循则搬来了那张长案,将一方黑青色的砚台压在案角,正拿袖子细细擦去砚底的粉末。

墨欢忍不住看了两眼。

“哪来的?”

“我的。”两人一起答。

墨欢抬头。

邵循把砚台往自己这边挪了一点:“输给我的东西,隔这么多年,还想拿回去。”

“所以今日下棋。”墨沉道。

“你今日输了呢?”

墨沉指了指腰间的酒葫芦。

邵循的眼睛亮了一下。

“连酒带葫芦?”

“连酒带葫芦。”

墨欢看着那方砚,想起来了。大爷旧时有一本刻禁的手札,第一页便画着一方砚台,角上留了三道浅痕。他问过是谁刻坏的,大爷只说练手之物,不必多问。

如今三道浅痕都还在,砚心却被磨出了一圈浅白。

“你拿它研过矿粉?”墨沉问。

邵循将砚台一翻,盖住浅白处。

“落子。”

墨沉捏着黑子的手,在半空停了停,啪地扣在棋盘上。

墨欢原想说自己今日去炼什么,话到嘴边,被这一声脆响截了回去。他回屋取过药匣,又带上丹火石盏,走到门口时,邵循才在背后叫他。

“桌上有你的信。”

墨欢脚步停了。

信从神都来,封上的日期已经隔了二十余日。

他拆得很慢,纸一抽出来,却先扫到了“挨了一掌”那几个字。读到二狗已找到,是活人,仍与陈生坐在一处吃饭,他紧绷的嘴角才松开一点。

再往下,陈生写,那晚的话说满了。

墨欢看了两遍,将纸折起,又展开,把胸伤那一段重新读过。

“小陈回去了?”墨沉问。

“回了。人也带回去了。他受了伤。”

墨沉伸出手,墨欢把信递给他,仍站在旁边等。

邵循探过半边身子,没看信,只瞧棋盘。墨沉头也没抬,伸手按住他刚要挪动的那枚白子。

“落了便别动。”

“我掸灰。”

“灰长在棋子底下?”

墨欢平日准要笑,这时却没笑出来。他等大爷读完,收回信,仔细放进了衣内。

“他还知道写疼在什么地方。”

墨沉看了他一眼,没有接这句话。

墨欢提起药匣,走出两步,又转身道:“我租到三阶炉了。今日炼金汤水丹。”

邵循抬头:“你昨天不是还舍不得那笔钱?”

“今日舍得了。”

“要我们过去?”

“先别来。”

墨欢答完,觉得说得太急,又补了一句:“丹室小,没地方摆棋盘。”

邵循朝墨沉一笑。墨沉没笑,只道:“药带全。”

“带全了。”

……

丹室里的铜炉,比墨欢原来的青纹炉高出半尺。

炉腹有六瓣云纹,法力自炉足送入,上下火意自行衔接,丹火沿内壁走了一周,竟没有一处明显滞涩。墨欢伸手摸了摸炉耳,心中又羡又恼。

早知道该早些租。

他没急着下药,先把数道常用的火法走过,直到额头微热,才坐稳,打开第一只玉盒。

六种三阶主药,十余种辅药,依次排在膝前。

那株金须灵草离开玉泥,根须立即舒展,像一把极细的金针。墨欢的丹火一卷,将它托入炉中,根须受热收紧,却迟迟不肯化开。

他双手一分,火光从下往上穿过根隙,将最粗的三根托高,其余细根则伏在低处。

片刻后,金色的药液才一滴滴渗出。

墨欢吐了口气。

他不是头一回炼这几味药。哪一株先化叶,哪一株要留根中苦气,他心里都有数。铜炉蓄火稳,往日得反复照管的地方,如今只需留一道神识,他的两只手便腾了出来。

午后的日光挪过窗沿,案前的空玉盒渐渐多了。

丹火石盏开了一线。

那粒灰白余焰被他养到如今,也不过比豆子稍大些。他只引出一缕,卷过已经焙好的辅药,将叶筋间最后一点燥气带去,随即收回石盏。

炉里的淡金药液渐渐结成一团。

墨欢的神色郑重起来。

这一步之后,他失过手。药性一合,金气结在外,水意却还伏在其中,既难相融,又不能全用法力压平。他过去总怕金气散得太快,这一回有好炉在手,终于能将几处火候同时稳住。

炉中金光流转,药团缓缓收小。

他将火势提了半分。

淡金色的外皮渐渐凝成,表面浮出一线润泽。墨欢心里一喜,手指依次扣下,要将最后一缕药气收进丹内。

就在这时,药团中心轻轻跳了一下。

不是火在动。

一股极细的水意贴住丹皮,又迅速退回,金色外皮随之一亮。墨欢神识紧追过去,想将两者合在一处,那水意却比他更快,已经从另一面冲出。

咔。

丹皮上开了一道裂口。

墨欢脸色变了,立即撤去合丹法诀,想把金气重新化开。只是外皮已经收紧,内中的水意一泄,整团药液便猛地塌下去。

一股酸苦的气味冲出炉口。

他仍撑着手,试图从裂开的药团里分回一些金液。丹火裹上去,金液表面却浮起细密的黑点,连最后那点润泽也熄了。

墨欢的手慢慢垂下。

炉中只剩一小团灰黄的药渣。

许鹤在外面敲了敲门。

“我闻见了。炉子可有事?”

墨欢没答。

过了片刻,他起身开门,声音发硬:“炉子没事。”

许鹤绕着铜炉看了一圈,确认内壁无损,这才望向药渣。

“金汤水丹?”

“嗯。”

“我不炼这个。”许鹤道,“不过这丹皮,外头已硬,里头却被冲开了。你是先收的外圈?”

墨欢猛地抬头,缓缓点了一下。

许鹤用夹子夹起一点焦皮,搁在白瓷盘上。薄片碰到盘底,碎成几块,露出的内面有水流冲过似的细纹。

墨欢盯着那些纹路,没有说话。

“再来一炉?”许鹤问。

“歇一晚。”

他回答得很慢。

剩下那份灵药,就摆在几尺之外。他看了许久,伸手将盒盖逐一压紧,最后从袖中取出一个旧药匣,把满炉废料收了进去。

许鹤要替他倒掉炉底的碎末,他也一并要了。

回邵院时,晚饭已经摆在廊下。

邵循看见他,先向后望了一眼:“丹呢?”

墨欢把旧药匣往桌上一放。

“这儿。”

邵循掀开一条缝,立刻又盖上。

“怎么苦成这样?”

“你花这些灵石烧一匣,也苦。”

墨欢坐下来,抓起筷子,夹了一大口菜。他咽得太快,被呛了一下,端起茶杯又发现是凉的,脸色越发不好。

墨沉把热壶推来。

“明日还去?”

“去。”

“嗯。”

墨欢等了一阵,大爷却已经端起碗,没再问。他憋了半天,终于道:“你们怎么也不问问坏在哪里?”

邵循朝那匣子一指。

“我一口没尝,你就嫌我说苦。问出什么,你肯听?”

墨欢张了张口,拿筷子拨了一下碗里的菜。

片刻后,他把药匣重新打开,放到灯下。

“不是火不够。”他说,“我把丹衣收早了。”

邵循移过灯,皱着眉看那些薄片。墨沉坐在另一头,喝完半碗汤,忽然想起什么,伸手将自己的酒葫芦从邵循身边拿了回来。

“棋还没下完。先放我这里。”

邵循立即抬眼:“你那一角已经没了。”

“中间还在。”

墨欢低头望着碎裂的丹皮,耳旁两位老人又争起来。他将最薄的两片挑出,在掌中轻轻合拢,顺着内侧断开的水纹,摸了很久。

第377章 金水相逢

天尚未亮,墨欢便到了丹坊。

许鹤揉着额角给他开门,见他眼底也带着倦意,不由得皱眉。

“一夜没睡,还敢开炉?”

“先坐一阵,午前才炼。”

“坐也收钱。”

“昨日已经收过了。”

许鹤被噎住,侧身让他进去。

墨欢合上丹室的门,没有立即动灵药,而是取出昨晚挑出的焦皮,放在炉旁。

两块薄片已经拼得严丝合缝。

金色留在外面,水意留下的细纹却在内侧绕了一周,到缺口处戛然而止。墨欢盯着它,指尖燃起一缕丹火,将火光压得极薄,照进那道细纹。

昨夜他照着丹札推过数遍,最先觉得是火收得太早,后来却把那一页翻了过去。

他怕金气散,急着结出丹皮;水意还未与金气真正相济,便被封在了里头。等水意再动,外皮已经硬了。他追着那一点动静强合,反而将方才萌出的灵性撕断。

可若一味拖着,金气又会被丹火慢慢耗去。

墨欢将焦皮收好,盘膝坐下。

他运转功法,丹田里的金丹缓缓转动,将昨日耗去的法力一丝丝补足。丹室外渐渐有了人声,门缝下的天光由青转白,他始终没有睁眼。

直到呼吸重新绵长,指尖那点轻颤也消失了,他才伸手揭开药盒。

这回,他先处理了那株最难化开的金须灵草。

粗根、细根分开受火,叶片却不再一道化尽。他保留叶心最后一点药汁,悬在炉中较冷的一侧,随后逐一投入其余主药。

最先凝出的金液在炉底回转,清透的药液随火势升起,两团药性一高一低,隔着薄薄的热雾,并不相接。

墨欢额头渐渐见汗。

他从石盏里引出那缕灰白余焰,仍只借它处理辅药的燥气。余焰绕过药叶,细小的火头忽然暗了一下,他立即收手,盖上石盏,以自己的丹火接过未尽的部分。

昨日连同今日,已经用得太久了。

半个时辰后,许鹤送进一壶水,放在他抬手可及的地方。

墨欢没转头,只道了声谢。

许鹤看了看炉内分开的药液,原本要走,脚下却慢了。

金液每转一周,墨欢便削去一点外围火意,随即将存下的叶心药汁送入。那点青色很快消融,金液却没有像昨日一样急着收成圆团,仍带着细长的尾,缓缓追在清液之后。

二者相触,细尾一颤,又分开了。

墨欢下颌绷紧,没有强拉。

他将炉中火势移成高低两层,让清液从上方落入金液刚刚绕出的凹处,再用一道细火截住外散的金气。

炉中忽然响起极轻的一声鸣。

墨欢的手指一顿。

那一缕清液并未留在凹处,而是穿过金液的尾端,往中心一钻。药液里随之生出一抹细光,微弱得几乎看不清,却不是从炉火里映出来的。

“有了。”许鹤低声道。

墨欢没答,眼中却一下亮了。

他神识追上去,到了跟前,忽然停住,只把外沿将散的药液拢回。那抹细光往左一转,他便撤开左侧的火;细光穿回金液,他再让右侧药性接上。

几次之后,金液终于不再拖着长尾,清液也不再一触便散。两种药性绕着彼此流转,交会处浮起一圈细密的金纹。

墨欢这才送下合丹法诀。

手指才扣到第三下,炉中的药团猛地向外一胀。

先前分开的药性要一齐归入,那点刚生出的灵性却还太弱。金纹被冲开,清液沿裂隙淌出,眼看便要重演昨日的塌陷。

墨欢吸了一口气,双掌同时按在炉腹。

丹田里的法力涌入六瓣云纹,丹火骤然分出十余股,将外溢的药液逐一托住。他没有再往回硬挤,而是以火丝牵成一道细环,沿着药团旋转。

每转一圈,他便放回一滴。

药团再鼓,他就停。

铜炉的鸣声渐渐高了,墨欢掌心烫得发红,额上的汗沿着鼻尖滴下。他顾不得擦,神识始终跟着那一点细光,直到它第三次穿透中心的金液,才将最后一滴送回。

炉里的药团忽然安静下来。

淡金色的丹皮从内向外长出,包住最后一点水光。先前断开的金纹在表面缓缓衔接,虽然留下一处浅浅的凹痕,却不再有药气从那里泄出。

墨欢只觉胸口一空。

他强提精神,法诀一变,炉火向下伏低。

一颗淡金丹药悬在炉心。

他伸手去摄,那丹药竟轻轻一偏,避开了最先落下的法力,随即又被后一道柔力托住,缓缓送出了炉口。

玉盘就摆在旁边。

墨欢看着丹药落下,却没敢马上拿,连呼吸都放轻了。他等了几息,那颗丹仍好好地伏在盘中,表面一线金光游转,经过浅痕时略略变淡,随后又亮起来。

许鹤俯下身,神识绕过丹面,没有强行探入。

“金汤水丹。”

墨欢抬起眼。

“三阶。成色差些,灵性已经生了。”

墨欢的嘴角动了动,没发出声音。

他忽然伸手,把玉盘往自己这边挪了半尺。

许鹤一怔,随即笑道:“我还没问价。”

“不卖。”

“我也未必买。”

墨欢低下头,肩头轻轻抖了一下,终于笑出了声。

“不卖。”他又说了一遍。

许鹤直起腰,将案上的水壶递来。

墨欢接过连喝了两口,第一口太急,呛得眼角发红。他一边咳,一边仍看着玉盘,左手压在盘沿,好像只要一松开,方才那颗丹就会自己跑了。

过了好一会儿,他才将丹药收入玉瓶。

许鹤问:“下旬这炉还空两日,留给你?”

墨欢已经点了半个头,摸到瘪下去的储物袋,又停了。

“等我凑药钱。”

“那我不替你留。”

“我来得早,你也不能先给别人。”

许鹤笑了一声,收起钥牌,去看铜炉的内纹。

墨欢坐在旁边,一直等到炉火彻底熄灭,才带着空药盒和那只玉瓶出了丹坊。

……

邵院的棋局还在。

昨日那盘棋封到今日,墨沉午后才起来接着下。墨欢进门时,两位老人都坐得很近,邵循的手按着膝盖,眉头越皱越深。

黑棋在边角被吃掉一片,墨沉却没有去救,转而侵入中腹。邵循应过一手,他便在另一边断开,将白棋分成了两块。

邵循捏起一枚白子,举了好一阵。

“你昨日便想着这一步?”

墨沉道:“昨日你要是少惦记我的葫芦,便不会吃得那么痛快。”

“少说两句。”

邵循低头数了又数,终于将那枚白子丢回棋笥。

墨沉立即伸手。

那方黑青旧砚被他拿了过来,三道浅痕朝外,磨白的砚心藏到掌下。他用拇指抹了一遍砚角,脸上的笑压都压不住。

“研矿粉。亏你想得出来。”

邵循盯着棋盘,忽然朝墨欢一招手。

“来,帮我看看这边——”

“他哪会看。”墨沉道。

“我会。”墨欢立即接话。

两人一同转头。

墨欢往前走了一步,看见满盘交错的黑白,默了片刻,将玉瓶放到了棋盘外边。

“先看这个。”

邵循拔开瓶塞,原本还带着不服气的脸,忽然静了一下。他将瓶口凑近,望见丹面那一线自行流转的金光,慢慢挑起眉。

“真成了?”

“金汤水丹。”

墨欢说出这四个字时,背不自觉挺直了。

邵循把玉瓶递给墨沉。墨沉接得很稳,看了一阵,又将瓶中丹药托到掌上。那点灵性被他的法力一触,轻轻往回缩去,丹面上随之浮出一丝润泽。

“有个坑。”墨沉道。

墨欢的脸顿时垮了一点。

“大爷!”

墨沉终于笑起来,将丹药放回瓶中,塞紧。

“成了。”

墨欢接过玉瓶,低头收好,转身走了两步,又停下来。

“我再去买两样菜。”

“我想吃城里的炙鱼。”邵循道。

“太贵。”

“三阶炼丹师,说话还这么小气。”

墨欢本已走到门边,听见这句,抬脚又退了回来。他看了邵循一眼,像是要争,最后却只抬起下巴。

“要多辣?”

邵循笑得棋子都拿不稳了。

傍晚,三人坐在廊下分了一条炙鱼。邵循嫌鱼小,自己却吃得最多。墨沉只吃了小半碗饭,随后搬去躺椅上歇着,手中仍扣着那方旧砚。

墨欢收过碗,取纸写信。

他先把白日炼丹的经过记进丹札,哪处留了药汁,哪处险些失手,写得满满两页。纸写尽了,他另取一张,抬头向躺椅看去。

“大爷,砚借我。”

“才拿回来。”

“写信,给陈生。”

墨沉把砚递来,仍叮嘱了一句:“别磕角。”

墨欢磨了墨,先写下今日日期。

“你的信收到了。二狗回来了便好。胸前挨掌,不要一句已服药就算说尽,下一封再告诉我疼得怎样。

“那晚的话,你认了,我看见了。我还生气,这封信先不谈这个。

“我今日炼成一颗三阶金汤水丹。丹上有一处浅痕,不过灵性没散,许鹤验过,大爷也看过。这一颗我自己留着,下次见面给你看。

“旧炉坏了,已收好。我租的炉,第一份药烧坏了,第二份才成。药钱去了不少,短时还不回神都。

“大爷这两日能坐着下棋,今日赢回一方旧砚,饭只吃了小半碗,吃完便去歇了。他让我借砚写信,嘱咐的话比赢棋时还多。”

墨沉听见最后一句,睁开眼。

“你念出来干什么?”

“问你写得对不对。”

“写你的丹去。”

墨欢笑了一下,把纸和笔都递过去。

“陈生还要你的近况。你自己添。”

墨沉拿过笔,在后头添了几行。

“我仍住邵家院里,取炉后容易倦,下一程尚未定。你先把伤养好,也替我向你兄弟问声好。”

写完,老人把笔交回,先将砚台拿走了。

墨欢等墨迹干透,封好信,趁城门未闭,亲自去了一趟传信铺。掌柜收了信资,将信放进待发的木匣,他仍站着,直到看见匣盖落锁才转身。

回来时,邵循已经把棋盘重新摆开。

墨沉靠在躺椅里,闭着眼不应。

“再下一盘。”邵循道。

“今日不下。”

“砚让你留着,我只要那葫芦。”

墨沉把葫芦往怀里一收,眼睛仍没睁。

墨欢从旁边走过,袖中玉瓶轻轻碰着手腕。他伸手扶住,想到白日那一线绕过指尖的金光,脚步不由得又快了些。

他的丹札还摊在灯下。

第378章 归山

临行前一夜,焚城的回信到了。

信封比上回厚,墨欢在头一页便写自己炼成了金汤水丹。三阶,两份药材只剩下一颗能用的,租炉的灵石也花了。丹上有一处浅痕,他自己收着,特意写了下次见面要给陈生看。

陈生看到中间,笑了一声。

第一段还有一句。

“胸前挨掌,不要一句已服药就算说尽,下一封再告诉我疼得怎样。”

二狗坐在对面,正把一件新黑衣的袖口往上挽。见陈生笑,便问:“那位还生气的朋友?”

“气还在,丹也成了。”

陈生又往后看。墨沉和邵循这段日子仍在同一院里,能见面,能拌嘴。这一封写在他回神都之后,已不是他从黑崖带伤回来时,翻看数周前旧讯的那一次。

他将纸收好,放在自己常用的丹册里面。

次日,江明背着两口剑,站到了紫令堂门口。

二狗朝他看去。

“你也回广秀?”

“我是去,哪来的回。”江明道,“我只听你们两个说过,那里有山,有丹师,还有一群肯打架的人。”

陈生提起袋子:“最后一项,不一定对你客气。”

“我在神都也没少听不客气的话。”

江明走下台阶,忽然又回头看了一眼。弗陵正在柜后拨算盘,见他望来,抬手将算盘拦住。

“你要找黄芽服气丹的药,沿路替我看着些。不是叫你一整袋搬回来,先把价寄给我。”

“还没走,先给我添活?”

“你不是想结婴?”弗陵道,“我也想闯过三阶。总不能都盯着东家的袋子。”

江明笑了,伸手接过他递来的小笺。

赵管家送到门边,告诉陈生,院子的信还照旧收,若有急事,再往祝霞山寄。陈生点头,没把钥匙索回来。

昨晚,二狗自己进宫,将明日离京的时日告诉了秦林。秦林没有再送衣冠,只让药监长回了句话:旧夜还有要问的,信不会少。

二狗听见,挑了一下眉。

“问得多,得让他备酒。”

“你要在信里喝?”江明道。

三人出了街口。

……

这一趟回山,用了将近半年。

走过的路不全是陈生当年出边地那一条。他如今知道几处大宗的传送落点,肯花灵石,不必见一道岔口就重新问方向。有时连过数城,有时又要凭遁法赶上数千里;到了荒无人烟的地方,二狗会自己停下,仰头看天。

他没有催着一天走完。

有一回,江明在落脚的石楼外练剑,青碧剑第三次转折时,忽然向外偏了一寸。二狗隔着栏杆看见,伸手按住剑脊,让江明先把那一段法力收回。

“你总想着接着它原来的路走。”

“好剑上的路,难道还比我差?”

“不是差。那是司马言走的。”

二狗将剑横在两人之间,逼江明照自己的惯用御剑法,从较浅的一处重新起势。江明试到手腕发酸,终于将那一次转折接稳,没有再照旧残印的弧度绕过去。

剑中更深的气息动了一下。

二狗收回手,没向里面硬压。

江明低头看着剑,又看自己掌心。下一日赶路,他先用旧剑把这一下使了几十遍,才换回青碧剑。

到边地时,迎面而来的灵气薄了些。

陈生越过一处旧山口,看见谷底有两名炼气修士在斗法。两人各使一口小剑,剑光相碰,脚底的草被削飞一片。旁边一个筑基老者喝骂,谁越过圈子,便把谁提起来扔回去。

二狗看了许久。

“以前这一路,争一条水渠,都会死人。”

“如今也争。”陈生道,“只是有人管着。”

江明收了神识,没有下去看热闹。

山口后面,开始有往广秀去的车队。不同道脉的人穿着各色衣袍,到了路边的歇脚亭,也会各自拿出一枚相同的外门牌。有人往宗内送草药,有人带弟子来求入门,还有人空着车,等山中的丹出炉。

旧日各宗的山界,早已不能将这些路隔开了。

再向前,广秀的群峰从云中露了出来。

陈生先看见祝霞山。

山顶那一片云,仍被晨风推向东面。半山小院的屋瓦换过,檐角多了一小串铜铃。铃声传不上高空,他却一下认出了院前那株树。

周显从山中迎出来时,遁光快得有些不稳。

落地后,他先望着陈生,像有许多话要说;目光移到二狗脸上,所有话又都停了。

陈生道:“我把二狗带回来了。”

周显猛地望向他,随后又看那位黑衣修士。他听过这个名字,也见过宗里留存的旧记,却从未与本人相见。

过了几息,他才轻轻吐出一口气。

“陈前辈。”

二狗笑了一声。

“我回来住山的,别照神都的仪制叫。”

周显低头笑了一下,随即转向陈生:“祖师,您总算回来了。”

陈生握住他伸来的手。

掌中气息深厚,金丹已至圆满;周显的鬓角却有了几根银丝。他抬眼时,仍有从前那股敢说、敢要的劲。

“你现在这么迎我,我倒有些怕。”陈生道,“是不是又要我定一条路?”

“路已经铺出去,弟子不敢叫您回来便接这些。”周显道,“但丹道上的事,确实攒了一些。”

“我便知道。”

二狗在旁边笑出了声。

周显把三人迎进小院,没有叫山下的人都来。常安正在东侧诸峰,莫龙云近年闭关,消息送过去便是;眼前这位祖师刚落地,他可不想一群人将门槛踏坏。

院中石案仍在原处。

陈生坐下,周显取出茶,先问神都,再问失踪的二狗。二狗只答了几句,周显便听出其中不是寻常远游,原本含笑的神情渐渐收起。

“没了?”他问。

“没了。”二狗道,“那个动手的人,我杀了。”

周显没有追着问人名。他望了二狗一会儿,起身到内屋,翻出一坛酒。

陈生看着坛上旧封,笑道:“给我备的?”

“给您回来备的。如今多一个人,正好。”

江明将茶换成酒,尝了一口,眉头微微一动。

“这个我认。比你们说的山还实在。”

……

当日午后,二狗去了太平峰。

陈生陪他走过最后一段山路,到了那处旧石壁前,便停了下来。二狗伸手触到山石,沿自己从前留下的印势,慢慢按了下去。

草根间响起细细的石裂声。

一条被树影遮住的旧阶露出来,洞内冷气向外涌,带着沉积许久的尘味。二狗站在洞口,看见自己留下的那面晶碑,还立在原来的地方。

碑上的光,和他的记忆一样。

人却已经不一样了。

陈生没有再取陈字牌。

二狗自己走进去,停在碑前,伸手摸过边沿。离山时那颗裂损的金丹,早已不在。洞里的旧刻,却还留着。

他如今收着元婴圆满的气息,指尖碰上碑面,旧印便有了极轻的回应。

“这几处,当时收得太急。”他忽然道。

陈生望着他。

“你是回来看自己,还是挑自己的错?”

“都看。”

二狗转身,指向洞外。

“这一块地方,先给我留着。别急着拿新的住处堵我。”

“没人堵你。”

“那你站门口做什么?”

陈生笑了一声,侧身让开。

二狗从他旁边走出去,看着下面层层的山林。山风吹动黑衣,他肩头的旧伤已只留下一道浅痕,手臂再没有向身后的石座扯回去。

“生哥。”他道,“我想在这里待一阵。把以前那一步,重新走走。”

陈生点头。

“我也有自己的事要做。”

“什么?”

“这身法体。”

陈生伸出手,掌背在日光下微微发亮。多年炼丹养出的血气,沿指节流动,光到了腕骨深处,却没有像他心中所想那样贯到底。

这点不尽如意,他在神都便知道。

二狗看了一眼,没拿自己的手往他腕上按。

“你想怎么走?”

“先找一股能用的阳气。金丹时我能容得下,到了元婴,旧法还不够。”

“回山就能找?”

“不知。问过,才知道。”

他收起手,望向祝霞山。

那间小院的门,正在树影里敞着。

……

三日后,周显将一块焦红的石头摆到了案上。

东西不过半个拳头大,外头还有黑色的砂皮。陈生托在掌中,立刻感觉到一股沿掌纹游走的热意,浓,却浮,触到体内元婴法力便散了。

“这是外缘采的。”周显道,“赤乌岭。十七年前,雷火劈开了一道旧脊,里面露出一片赤色石层。有人想采,进去便撑不住,后来换了三名金丹,也是一样。”

“以前没人知道?”

“那一道裂脊从前藏在山里。外层还能采些东西,深处能逼退金丹的那一股,我也不知道究竟是什么。”

陈生将石头放下。

“如今归哪一脉管?”

“广秀的地界。看岭口的是外门弟子,只守外围。我叫他们不许再向深处采,已有两个人伤了经脉。”

二狗拿起石头,搓下了一点砂皮。

“里面还有活的东西?”

“有人远远见过红翼的鸟,后来再进,什么也没看见。”周显道,“我只知道它占着那一道裂脊,未识出到底是哪一种。”

陈生看向展开的山图。

图上没有军纹,也没有赤色锁链,只是一道山岭、一片被日光直照的坡,还有新画出来的裂口。

他从案上拿起石头。

“这一块,我留下。”

周显望着他。

陈生已将地图卷起。

“岭口的人不要去迎。明日,我自己过去。”

二狗把指间那一点砂弹进废盘,起身拍了拍衣袖。

“我也去。”

“你不是要看旧印?”

“旧印跑不了。”二狗道,“外面我也要看。”

“那就早点。”陈生道。

第379章 日轮石

赤乌岭的山口,有一座搭了半边的木棚。

陈生落地时,棚中的两名外门弟子正在争一锅水。一个嫌茶叶放多了,另一个说再不放,便只剩热砂味。二狗先闻见那股苦涩,笑着朝棚里看了一眼。

两人站起来,发现面前的气息根本探不到底,顿时将茶争忘了。

陈生没有叫他们进岭。

他把周显的短笺放到案上,问了旧裂脊的位置,便将木棚前的歪柱扶正。山里的热风迎面涌来,柱脚晃了两下,被他压回土中。

“今天往外站些。有人问,便说里面正在取料。”

一个弟子看着他手边,迟疑片刻。

“前辈,周真人叮嘱过,不许再进去。去年那个孙师兄,回来时手臂上都是……”

陈生向他望去。

弟子的话停了。

“我知道。”陈生道,“你们守外头,不必跟。”

他转身入岭。

二狗跟在后面,嘴边还挂着一点笑。

“只差把你拽回来。”

“能替周显拦人,也不坏。”

“你如今是被拦的那一个。”

陈生没有接,脚下已越过第一道断沟。

……

越向里走,地上的草越少。

到裂脊前,连砂砾都透着红。风从一人宽的石缝里往外喷,吹过掌背,有针一样的细痛;再向前,一面新露的斜坡直朝着太阳,数十道赤亮细纹正顺坡往下,汇进一块凸起的玉石里。

陈生停了下来。

那块玉石比拳头大些,外皮有九条半合的光纹。日光落上去,没有立刻散开,而是沿纹聚入石中,过了片刻,才从底下散出一层赤色。

“日轮石。”

他在守蔵室读过这类灵材的记述,真正见到,仍是头一回。

外缘那些碎石只接了它散出的余气,周显带回去的那一块,连这层余气也存不久。眼前却不同,神识只探到半寸,便有热意倒卷回来,逼得他体内的元婴睁开了眼。

四阶的料。

二狗蹲在坡沿,望了一会儿。

“拔出来,会带着这片坡一起裂。”

陈生也看见了。玉石的底座深入石层,附近被雷劈开的裂口,恰好将它露出;若一掌将岭脊打碎,最先散的恐怕就是正在石底汇拢的那一股阳煞。

他取出乌玄炉,放到较低的一块平石上。

炉沿的旧尘被拂去,丹火向外铺开,先在石底围了半圈,没有触上玉石。

就在此时,岭上响起一声长鸣。

一只赤翼大鸟,从背光的山壁后冲出。

翼展开时,几乎盖住了整片裂脊。长羽之间流着细细的金色,头顶短冠像一簇凝住的火。它没有落在玉石上,而是先在高空绕了半圈,双爪忽然向下抓来。

气息一压,坡边几块碎石当场炸开。

二狗已经抬起手。

陈生先道:“这只我来。”

“它可不肯陪你切磋。”

“我也没打算同它讲和。”

陈生一跃而起,金翅大鹏法卷开热风,整个人斜掠到裂脊外侧。那只赤鸟转头追去,爪下的光又偏回坡心,想将靠近玉石的炉打翻。

陈生回手。

宝珠从袖中飞出,清光向下,扣住它两只利爪之间的罡气。铁剑同时离鞘,迎着鸟翼底下刺了过去。

叮的一声。

剑锋切中一根硬羽,斩开了半截,余势却被羽间灼热的罡光推歪。大鸟扭身,短冠骤亮,一道极细的金光从口中射出。

陈生侧头,金光从耳旁擦过。

背后石壁被穿了一个洞。

他在半空收势,已经认出这是什么。

阳翎隼。

那股压着元婴法力的气息,只在初期层次;它倚着向阳裂脊,吐出的这一线却格外锋利。金光在石壁中又透了数丈,才终于散去。

二狗站在坡边,右掌没有落下。

他向岭背望去。

又有十余只赤翼鸟飞出来,气息低些,未到四阶,刚现身便分散冲向乌玄炉。二狗一步跃到炉外,手掌横推,将冲在前面的三只全卷到半空。

“这边我看着。”他道,“你别把那只打回石头上。”

陈生应了一声。

阳翎隼转回头,刚想扑向炉,铁剑又到了。

它张翼横扫。

陈生没有与那一整片长羽硬撞,身形沿翼锋滑过,握剑的右手探进羽下,一线星光擦着翼根斩下。鲜血溅出,他也被翻起的热浪推了出去。

赤鸟第一次发出凄厉的叫声。

它没有逃,口中的金光却忽然收短,不再远射,而是贴在喙前,像一柄极窄的刀。

下一扑,便是贴身。

陈生以左掌迎上。

掌中日熙之光亮起,与喙前的金光相碰,热意立刻向上倒卷。十万窍穴随气血张开,原本不断向外压的锋锐被他全身血气接住,左臂却也在这一撞里麻了半截。

隼爪随之落下。

宝珠的清光挡住爪锋,尚有一道细光沿边缘划过,在陈生右前臂上拉开一条血口。

二狗向这边看了一眼。

陈生没有后退。

阳翎隼既然把金光收在喙前,两边长翼便要承担落身的转折。他任那一线热光沿掌外擦过,左手结印,先打向它正在收拢的左翼。

道一印没有完整落下。

他先收住一角,将向下的重压折向自己脚边。

赤鸟左翼猛地一沉,原本该从陈生头顶翻过去的身子,被拉低了半尺。它立即扭头,喙前的金光划向陈生颈侧,速度比上一扑更快。

陈生的元婴法力,在此时全涌了出来。

方才收着的一角印势张开,金光并不托它,反朝翼根压下。铁剑从右手中松开,贴着掌后的空隙,一线斩星之光贯进了隼胸。

这一剑没有再斩硬羽。

隼的长喙刚触到宝珠边上,胸中已经被星光照透。它奋力抓了一下,利爪落在陈生身旁的石块上,整块黑石当即崩裂。

陈生落地,双手一合。

道一印压住了它最后那一次振翅。

铁剑从胸中斜挑而过,丹腑里正要聚起的灵性随剑光散尽。赤鸟撞在岭外的空坡,扬起大片黑砂,双翼抽动几次,终于伏了下去。

陈生站在旁边,手臂上还在滴血。

二狗望过来。

“那一掌,手不麻?”

“麻。”

“还往前走?”

“贴到这一步,它转身比我还急。”

二狗嘴边一动,没再问。

他手中十余只低阶赤鸟,都被同一片印光压着。看见那只大隼倒下,其中几只扑得更厉害,另几只却开始往岭外挣。二狗将掌势分开,将它们远远甩到另一道山沟里,抬手封住这边一片坡口。

鸟群再飞起来,没有立刻冲回。

陈生收剑,走到乌玄炉前。

丹火围着玉石的那一圈还在,石底也没有被斗法余波震裂。

他抬起受伤的手,先将血封住,随后把火伸到玉石底下。那里有几道从大地生出的细脉,不断将凝住的阳气送向石中。

陈生以火护住石中那一团凝实的赤光,让二狗从外沿拨开紧贴底座的碎岩。二狗没有向岭腹打印,只将已经被雷劈松的一侧托起,给铁剑让出下行的位置。

剑锋一点点切进石层。

露出最后一寸时,玉石上九道光纹同时亮了。

陈生掌中的丹火往外弯去,像有一股强风从石底喷出。炉口的火立刻迎上,他将火沿左右分开,留出中间一条路,托着整块日轮石,缓缓转入炉中。

二狗撤手。

石底断开的脉中,还有赤光想向外涌。陈生将炉盖压下一半,右掌落在炉腹,丹火一圈圈收窄,终于将那股尚未稳住的阳煞包进石外。

炉没有离地。

他就在这片仍然发烫的山岭上坐下,守了两个多时辰。直到炉中再没有冲撞,才将炉盖合拢,收回围在石底的丹火。

地上那个石座空了。

斜坡上依旧有日光落下,却不能再像方才那样,聚成一道刺人的光。

二狗从空坡回来,拖着阳翎隼的尸身。他只割了几根较完整的硬羽,连同爪骨收好,碎了的胸骨没有再去翻。

“这些倒能给江明看。”他说,“省得他一口好剑,老拿旧匣装。”

“他已经有鼋甲。”

“有一块甲,就不能再要一根羽?”

陈生抬眼。

二狗笑了。

“我也想拿些东西回去。不然他又问我,这一趟到底得了什么。”

陈生将乌玄炉收入袋中。

日轮石落袋那一刻,指间还有细细的热意。他将手背翻过来,伤口已经不流血,掌背的日熙之光却仍只能沿旧路流动。

他没有在岭上把那股阳煞引入体内。

“我准备闭关一阵。”他道。

“多久?”

“这一回,先留二十年。”

二狗望了他一会儿,突然道:“你倒舍得和我比了。”

“你元婴圆满,还不是想往前走。”陈生起身,“这二十年,可别只看以前那几道印。”

二狗抬手,指间道一印亮了一角,方才封在坡外的光随之散开。

山沟里的赤鸟惊叫着飞远。

“二十年。”他道,“到时候,我再来叫你。”

陈生跨过空了的石座,向岭外走去。日光落在他的肩背,映出袖口那一点已经干了的血。

第380章 今夜入关

出了赤乌岭,风里的热气才渐渐淡下去。

木棚前那两个弟子还站着,茶锅已经熬干了一半。看见陈生袖口的血,两人同时低头,又忍不住往他身后的山口看。

二狗把一根赤色长羽往肩后一收,笑道:“别看了,那只大的没了。”

一个弟子刚露出喜色,陈生便道:“剩下的也不是你们能捉的。告诉周显,里面的料我取了,旧裂口还在,别急着放人进去。”

两人连忙应下。

陈生没再停留,与二狗一道越过山口。身后木棚渐渐变小,斜阳从云缝间落下来,照在他右臂那道干涸的血迹上。

二狗看了一眼。

“回去让江明看见,他又有话说。”

“他先看你的羽毛。”

“这么有把握?”

“你若拿个空袋回去,才先看我的伤。”

二狗笑出了声。

……

傍晚,祝霞山清净小院。

江明确实先看了羽毛。

二狗把那几根硬羽和爪骨摆在石案上,赤金光泽顺羽脊一闪,江明便把自己刚端起来的杯子放了回去。

“四阶?”

“那只隼的。”

江明伸出手,快碰到时又收住,用一缕法力托起最外面那根。羽侧细锋轻轻一动,竟将他托着它的法力削开了一丝。

他看了二狗一眼。

二狗把东西往他面前推。

“拿着。你那块鼋甲若真要炼匣,找会用这些的人问问,别全堆上去。”

江明没有说不要,取出几片旧软皮,将硬羽一根根分开包住。爪骨另装了一袋,最后才望向陈生。

“我就知道,不会只进去捡块石头。”

“皮肉伤。”陈生卷起一点衣袖,给他看了看,“左手还麻过一阵,现在好了。”

“我还没问。”

“省你一句。”

江明把材料收好,伸手从长匣底下抽出一张折了多回的小笺。

陈生认出了弗陵的字。

小笺旁添了几列更小的数目,墨色有深有浅,还有两处被划去了。

“路上问的?”

“你们赶路也没把我嘴堵上。”江明道,“黄芽服气丹那几味药,有两样,边地的价确实比神都低。可人家给我看的都是未久存的新采货,装袋子里一路运过去,未必还值原来的钱。”

陈生拿过小笺,看了其中一个数。

“这家便宜得太多。药龄问过没有?”

“问过,掌柜先说足年,见我不接话,又说还差两年。”

二狗在旁边笑。

江明把被划掉的那一列指给他看。

“所以我没买。这一份先寄给弗陵,能炼不能炼,他自己算。若真有赚头,我再去看货。”

“你还替他跑第二回?”

“第二回便不是白跑了。”

江明把小笺收回,重新抄了一张清楚的。

“我自己要往上走,灵石和药都得有。总不能等他丹成了,再问能不能匀我几颗。”

陈生没替他把那笔账算完。他坐在石案另一端,取出纸笔,将临行前收到的焚城来信也展开了。

纸上的日期,已经隔了将近半年。

墨欢说丹成时的那点得意,还在字里;墨沉的几行亲笔,也仍是那几行。陈生望了一阵,在新纸上写下回到祝霞山的日子。

“金汤水丹的信收到了。那颗带浅痕的,你若仍留着,下次见面让我看看。第一回两份药成一颗,不丢人;第二回若还把药烧去一半,便该肉疼了。

“我如今在广秀祝霞山,二狗也回来住山。胸前旧伤已能提足长气,左肩用重招还有一点涩。今日入赤乌岭取料,右前臂又添了一道浅口,已止血,没伤骨。

“取到一块四阶日轮石,准备拿来养这身法体。信寄出,今夜便开炉,先给自己留二十年。

“你们那边呢?墨老近来的饭量、精神,烦你照实写。能否落笔,也请说一声。我还记着他上封信里的棋局,输给邵老的东西,别到最后全要你出药钱赎。”

陈生写到这里停住,重新读了一遍,把最后那句的“输给”划了,改成“往后若输给”。

二狗刚好看见,伸手点了点前面。

“你这句话,像在骂人。”

“哪句?”

“第二回烧一半。”

陈生将纸往回收了收。

“他能烧成,才有得骂。你烧过么?”

“没有。”

“那别替他心疼。”

二狗笑着站起来。

他没有拿凳子往炉边搬,而是望向太平峰的方向。

“我也回去了。”

陈生抬头。

“以前那一步,你准备怎么走?”

二狗在掌中凝起一点金光。那点光极沉,往外放开时却有一角先散了,他盯着那一角,看了一会儿,握掌收尽。

“先把黑崖里练出来的习惯改掉。一发力便想着压住什么,想往上走的时候,也忍不住先收回来。”

“那可不轻省。”

“轻省的事,你做。”

二狗说完,便向院门外走去。到了门边,他回头看了看案上的信,又看陈生。

“真从今夜开始?”

陈生把信封好。

“炉都占着,再不炼,我还怎么开张?”

二狗摆了摆手,出了门。

……

山下传信的铺子尚未关门。

陈生与江明各交了一封信。江明的往神都紫令堂,收信人写弗陵;陈生的照墨欢旧封上的地址,寄往焚城城外。

掌柜收了灵石,将两封信分别封入传信筒,放进当晚要送出的阵内。阵光亮起,筒上青纹连闪数次,随即从架上消失了。

江明还站在柜边,问起整份药材送去神都的运价。

那数字一出来,他啧了一声。

“怪不得只肯让我先寄价。”

陈生先出了门,没等掌柜劝他凑足一箱再便宜些。

回到小院,天已全黑。

江明没有跟进静室。他抱着刚收的材料去了侧屋,石案上还留着一张药价的底稿,角上压了两块小石子。

陈生将乌玄炉放到静室中央。

炉盖打开一线,里面的赤光便映到了屋梁。日轮石仍比拳头稍大,九道半合的光纹缓缓起落,石外裹着的阳煞时聚时散,撞在炉壁上,发出极轻的鸣声。

他没有先去动石心。

丹火分开,一股贴炉底托住石头,另一股绕向外附的赤色。水云柔控火法随指尖展开,将最外面一缕游离的煞气牵了出来。

只这一缕,碰到血气,便陡然亮了。

陈生右腕上的皮肤一紧。

那股热意顺掌根向内钻,半途就撞上了他自己涌来的日熙之气。两股气息没有乖顺地融在一处,反而把那一小段血脉顶得发胀。

陈生截住后续,丹火向外一卷。

赤色退回炉边,掌根却已留下了一道红痕。

他看了看自己的手,没有合炉。

以前熔炼两种法体时,他也没少把身体折腾得七零八落。如今试进这一缕,虽还伤不了根本,逼出来的热气却已散了小半。皮肉能长,炉里这块日轮石,可没第二块给他糟蹋。

他把那缕煞气重新扣回火中,缓缓收紧。

细小的灰星从赤色里剥出,落在炉边的废盏内。煞气中最躁烈的一层渐渐薄了,剩下的热意仍重,却不再一碰便向四面刺开。

这一次,陈生只引了一半。

日熙神照体从胸腹间缓缓运起,肌肤透出琉璃光泽。十万零八个窍穴依着旧法吞吐,他却没有将手中这点热意一齐往全身送,只让它贴着掌根,随那里已有的血气走过一小段。

腕骨微微发烫。

那一小缕赤色被血气带走,过了许久,才真正淡了下去。

窗纸外,山中的灯一盏盏熄灭。

陈生盘坐在炉前,将袖口再挽高一点,取出了第二缕。

第381章 炼入身中

闭关第四日,日轮石外包着的散煞已经用尽。

陈生隔着炉火看它,才发觉少掉外面那层耀眼的赤色,石头本身的光反而更沉。

他伸指在炉壁上点了三下。

丹火收窄,贴住石侧最浅的一道纹,细细磨进去。过了近半个时辰,纹边才有一点红光松动,如熔开的金汁,从石中渗出。

它比先前那些散煞凝实得多。

陈生将火向外分开,没有继续加力,只把已经渗出的这一点托到炉口。

石侧留下了一处米粒大的浅缺。

这一点用了,他便再不能把它塞回去。

……

第十九日夜里,静室中的灯忽然炸了。

火芯飞出去,撞在门上。陈生无暇去拦,右臂猛地向外一震,皮肤下那一层琉璃光泽竟裂出了几道细纹。

紧接着,肩前也传来一阵灼痛。

他立即收住炉口的火,体内养生经运转,将翻涌的气血往胸腹间压回。已经引出的三缕阳气却回不去了,两缕在肌肤下冲撞,另一缕从掌缝间泄出,打在云床旁,将石沿灼黑了一角。

半晌,陈生才缓缓松开右手。

前臂上有两处皮肉烧破,血刚渗出,便被残余热意烘干了。

他低头看了一阵,先去看炉里的石头。

日轮石侧面的缺口,又深了一点。

“倒真不替我省。”

方才那三缕,原是他分开炼好的。

这些日子,一处处轮着引,已经能让气血留住少许阳精。只是同样一线法力,从手掌贯到肩背,越过几处转折,便又散得厉害。他嫌慢,想将前臂、肩前、胸侧三处一并养起。

三处各自承得住,连在一起,却把回流的血气堵住了。

他比谁都知道,该先撤哪一处。

可体内血气不像炉中三味药,给一味撤火,另外两味还能老实待着。掌根稍一缓,肩前的热意便顺势压来,三股一撞,全成了乱流。

陈生将那只装灰星的废盏拉近。

盏里的灰越来越多,里面有真正剔出的杂质,也有几次失手后白白散掉的阳精。他捻起一粒,看它在指间碎去,脸色比看伤口时更难看。

屋里已经够亮,他没有再点灯。

他取过丹册,将原先记下的三条线划去两条,笔尖停了停,又把最后一条的走向改了。

先归胸腹,再出肩臂。

不是三处齐抢。

……

接下来的几日,炉火始终没有大过。

陈生右臂的灼伤收了口,肩前的热痛也退去,周身血气却仍一遍遍沿旧路行走。他将每一次先热起来的地方记住,不急着往里面添更多的阳精。

渡元婴劫时,雷霆早已打进筋骨。

那场雷锻留下的底子还在,只是后来杀敌、行路、炼丹,用到哪里,便向哪里倾注法力。他习惯了仗着肉身硬接,也习惯用更大的血气补住撑不住的地方。

那样能打。

可一口气贯到底时,最先发热的总是表层,真正要承力的筋骨深处,反而比外面慢了一拍。

他这次不再催着表层先亮。

炉中依旧分火:一股托石,一股缓缓炼出阳气,最后一股剔去燥杂,把可用的那一点单独送出。

到了体内,却只让它赶上一回完整的血气流转。

先在胸腹间散开,等温热从背脊透出,再沿肩头行向右臂;掌中的光未亮,肘骨深处已经有了热意。他压住急着往外冒的血气,将这点热一点点留住,不让它刚到掌根便从窍穴中散去。

第一次走到腕骨,还是断了。

第二次,热意越过腕骨,却在归回胸腹之前淡尽。

陈生没有为这两回再添火。他把引出的那一份用完,便封住炉口,单凭自身法力继续运转,直到筋骨中那点热意真正散尽,才去取下一份。

石头一点点少下去。

他留下的东西,却不再只是一身烧得发亮的皮。

入关第三十七日,陈生在收火之后睁开眼,右掌中仍有一线淡金色。

他没有向墙上拍,也没有唤人来接。

那线金色随着握拳收进血肉,又随着松掌透出,细得几乎看不清,却在炉火撤掉后仍未散尽。

陈生看着它,慢慢露出了笑。

这一次,他没有趁着高兴,再添第二份。

……

入关第四十二日,院外传来两下敲门声。

陈生正到一轮收尾,过了片刻才开门。二狗站在树下,右边袖口焦了一小块,提着一只酒坛,却没有往石案上放。

“还活着?”

“酒留下,再问。”

二狗把坛子往怀里收了收。

“这是我的。路过看看你,别想顺走。”

陈生望着他的袖口,笑道:“那一步,也烧衣裳?”

二狗抬起胳膊看了一眼。

“我嫌旧印收得太死,往外多放了一分。结果这一分没听我的。”

他说得轻巧,眉眼间却仍有些未散的恼意。

陈生侧身让他进。

二狗没有动。

“不坐了。我去西面的高处再走一回。你这里炉气太稳,待久了,我又想照着原来那条路收手。”

“那你还带酒?”

“收得回来,喝一口。收不回来,也得喝一口。”

陈生笑出了声。

二狗向静室里看了一眼,目光落到炉边那只盛了灰的盏上。

“赔了不少?”

“开始几回亏了。后头要挣回来。”

“别等二十年到了,只给我看一盏灰。”

陈生伸手,关住一半院门。

“你最好也别只剩一条好袖子。”

二狗大笑,提着酒走了。

陈生回到炉前,没有因为方才那几句话加快火势。他把下一份阳精暂压在炉内一侧,重新理顺了还未收尽的那一轮血气。

要赢,也得先把自己的东西拿稳。

……

入关第五十四日,天将明时。

乌玄炉中的日轮石,已比初入炉时小去约一成。最外一道光纹磨掉了半边,余下的九成石身仍有沉沉赤光,收在炉火之内。

陈生将这日最后炼出的一点阳精送入体内,随即合上炉盖,把丹火尽数收回。

静室暗了下去。

他坐在云床上,没有再从炉中借力。

元婴法力自胸腹间缓缓运出,日熙之气随之展开。温热先透过背脊,越肩入臂,行至前些日子最容易胀痛的地方,既没有猛地鼓起,也没有沿皮肤向外散开。

肩骨下,一缕琉璃光泽缓缓沉入血肉。

陈生握住右手。

那股温热越过肘弯、腕骨,抵达五指,继而顺着未曾断开的血气回势,重新归入胸腹。

一周天。

他没有立即睁眼,继续走了第二回。

到了第三回,最初引入的那点外来阳气已经分不清形状,筋骨间却仍留着一层沉实的暖意。元婴气息沿这一整条路通过,身上的光只微微一亮,便重新收了进去。

陈生的肩膀慢慢松下。

以往要催起满身血气,才肯越过的几处转折,如今只需顺势一送。省下来的那股力还在体内,他没有顺手把它再压向左肩。

左侧的路,还没这样养过。

他抬起右臂,在眼前曲伸两次。前臂上新烧过的两处皮肤仍偏红,赤乌岭留下的浅口则已经只余一线淡痕。

那只手看起来并没有大多少。

陈生却知道,若换这只手去接阳翎隼喙前那一下,他不必先让掌骨硬受,再等身后的血气涌上来了。

他从云床起身,将丹册翻到第十九日那一页。

当初被划掉的两条线,还在纸上。

他没有把它们描回来,只在改过的那条旁边写下今日的日期,又添了四个小字。

“可离炉行。”

随后,他走到乌玄炉边,将尚余的九成日轮石封在炉中,合严炉盖。废盏里的灰另装一罐,放到最下层。

陈生盘算了一会儿余量,把下一轮原想取出的分量减去两分,重新坐回云床。

窗外第一缕晨光落到肩头时,他已经开始了第四回运转。

第382章 这炉归谁

那封回信寄出去将近半年,邵循终于揭去了青胎上的罩布。

墨欢站在长案旁,还没来得及伸手,便听见身后有人道:“肩上这两道温火纹,也磨了吧。”

说话的是陶恪。

此人是焚城一位三阶丹师,眉长,眼窝深,衣襟上连药灰也不沾。他带来三只玉匣,其中一匣已经打开,里面的凝火晶透着暗红光泽,照得邵循的手掌都亮了一边。

“再加一重烈火禁,我那两味丹能少熬半日。”陶恪道,“材料我添,先前说的价也不减。”

墨欢望向邵循。

邵循没有应,指腹沿炉肩摸了一遍。

青胎在火窟养得太久,半年来,邵循将内里的杂性分了又分,才把原先被压住的几处胎纹理顺。如今炉色由青透白,手靠近了,能感觉到一股温热的气息顺着指缝回旋。

墨欢看过邵循重画的炉图。

那两道纹,一道走长火,一道接细火,合在一起,正好可以在温药时留住低处的火意。若磨掉改成烈火禁,烧炼确实更快,他想借炉养住的那点余焰,却连个能伏的地方也难找。

“原先这样就好。”墨欢道。

陶恪转眼看他。

“好在哪里?”

“火分得细,凝丹时能少用神识去补。”

“我用不着补。”

陶恪说得很平,没有笑。他伸手点了点炉腹:“我每月炼的火性丹多,不在这里养水药。等这炉买下来,你若要用,来我那里,我给你留两日。”

墨欢的脸色一下变了。

“已经卖了?”

“谈价。”邵循道。

“我怎么不知道?”

邵循抬头看着他。

院内安静了一瞬。

墨欢嘴唇动了动,后面那句话没说出来。他帮着取过炉,自己的青纹炉也坏在那一日,这些都是真的;可青胎自始至终是邵循炼的,邵循要谈价,确实未曾答应过先问他。

陶恪合上玉匣。

“我今日是来买炉,不是来抢谁用过的东西。”

“他也还没说卖。”墨欢道。

“所以我在等。”

邵循忽然有些烦躁,将袖子挽了起来。

“先坐。我说过,炉路未定。”

陶恪没有催,拣了廊下的椅子坐着,拿起旁边一块炼废的纹板看。

墨欢跟着邵循去了后院。

才绕过门,邵循便转身道:“你想说什么,现在说。”

“我想用这炉。”

“听出来了。”

“不改烈火禁,就照你先前画的。”

邵循抱起双臂:“材料呢?”

墨欢没答。

“青胎里的东西,能留下的大半已经留下了。最后几道禁要刻得住,还得添料。陶恪肯出,成炉再给灵石。他花钱,买合他用的东西,哪里错了?”

“那你为什么没应?”

邵循的眉头皱紧了。

墨欢盯着他:“那两道温火纹,是你昨夜还在改的。”

“我爱改,跟你出不出材料,是两回事。”

邵循说完,转身便走。

墨欢一把拉住他袖口。

“我出。”

邵循停下来。

“先拿出来。”

墨欢回屋,取了储物袋,将里面几堆灵石倒在小案上。这半年,他接了不少二阶丹单,城里药铺有人看他越过三阶门槛,便想多压些货来。他嫌价格低,退过两回,后来照旧接着做了。

想买一口合用的三阶炉,单凭不服气,换不来东西。

邵循看过,摇头。

“少了。”

墨欢从袋底又倒出一小堆。

“还是少。”

“这些连回神都的路钱都算上了!”

“你要回哪儿,材料也不会因此便宜。”

墨欢的脸憋得通红。

屋门在这时开了。

墨沉站在门外,外袍只披了一半,望了望案上,又看向院里的陶恪。

“大清早,吵到别人屋里来了。”

墨欢立即站直了一些。

半年过去,墨沉又瘦了。灰衣套在身上,肩头空出一点,午后睡下,有时要到日落才醒。今日他却起得比往常早,发髻已经束好,腰间还挂着那个酒葫芦。

“没说你。”墨沉对他道,随即看邵循,“你嗓门大。”

邵循将头扭开。

墨沉进屋,拿起案边一卷图。墨欢这才瞧见,图下还压着一枚船牌。

“你要出门?”

“去临潮城。”

“哪日?”

“九日后开船。”

墨欢往前迈了一步。

墨沉却已经将图摊开,指着其中一道弯曲的水线问邵循:“你从这里走过没有?潮退时,船能靠到石壁下么?”

“得换小舟,大船过不去。”

“那就在渡口换。”

“你去看那几行石刻?”

墨沉点头。

临潮城外有两面旧石壁,退潮后才能露全。墨沉早年只见过残拓,拓上第四行有一处,旁人读作夕水,他一直疑心两笔被潮痕接错了。近日城中有人带来一幅新拓,偏偏又把那处拓糊了,他看过之后,便去问了船。

墨欢仍站在旁边。

“我跟你去。”

墨沉看了看他案上的灵石。

“炉不炼了?”

“先去,再回来。”

“我未必只住两日。”

墨欢还想说,墨沉已经收起图,坐下来给自己倒茶。他没有再看炉价,只道:“你先把眼前的事说清楚。”

邵循重回前院。

墨欢独自站了一阵,走进内屋,抱出了少一块底的青纹炉。

铁箍仍松松地躺在炉腹里。炉底那道断口,他每隔几日总要看看,邵循说缺料,他便想着等手头宽裕了,再把料找齐。

如今他将炉放在小案上,手掌沿青纹摸了一遍,忽然问:“这炉还能拆出多少青髓铜?”

墨沉抬起头。

墨欢没看他,抱起残炉,径直回了前院。

邵循还在与陶恪谈那两道温火纹。墨欢把残炉放到青胎旁边,两件东西原是一同炼出来的,如今一个温润生光,一个连足都立不稳。

“缺的青髓铜,用这个补。余下凝火晶,我买。”

邵循没有立即伸手。

“你原来要修它。”

“现在不修了。”

“里面的旧禁得全断掉。化开以后,青纹也没了。”

“我听见了。”

墨欢答得很硬。

陶恪将纹板放下,站起身来。

“我不要半口炉,也不与人排着日子用。邵道友,你若留原来的炉路,我便不买了。”

邵循看向他。

陶恪并未收回目光。

他确实愿意出那笔钱,也确实只要一口趁手的烈火炉。邵循静了一阵,伸手将案上的玉匣逐一合起,推还给他。

“这回不做你的。”

陶恪点头,收了匣子。

临走前,他望了一眼墨欢:“以后若来借我的炉,还是照价。”

墨欢本想顶一句,话到嘴边,最终只道:“知道。”

送走客人,邵循将残炉翻过来,检查炉足里面还未崩散的旧材。

“这些只能抵一部分,不能把青胎算给你。”

“我没说全要。”

“你脸上写了。”

墨欢深吸一口气。

“炉成了,我用三年,不另给炉租。”

“两年。”

“三年。你用炉,我让;你不用时,别把我赶出来再借旁人。已经下了药的,谁也不许叫停。”

邵循皱着眉,手指在断口处敲了两下。

“炼坏了呢?”

墨欢的目光落在残炉上。

隔了好一阵,他才道:“我出的料认了,不找你赔原来那口。”

“好。”

邵循把残炉按稳。

“三年。炉就放这个院里。”

次日,两人进城挑齐灵材。墨欢付钱时,数灵石数得极慢,付过之后,又将找回的零碎仔细收好。

第三日开火,他亲手将铁箍取出,放到墙角。

邵循切下一只炉耳,递给他。

墨欢接过,看了片刻,将它包在布里。随后,他取过小锤,沿邵循指明的位置敲下,第一道尚好的青纹,就在眼前断了。

炉腹拆成数片,三足相继落下。

邵循的真火将碎片托起,青色逐渐褪去,露出里面柔和的铜光。墨欢站在一旁,没有移开眼睛。

等最后一点旧纹融尽,他伸出手,将准备好的凝火晶送了进去。

第383章 开船

合炼的第七日,邵循没让墨欢再睡。

炉胎悬在院中,底下三道火流交错,五根细长的铜丝已经嵌进炉腹,只剩最后一根仍在外面,随着火势微微发亮。

“看住右边,不许它先冷。”

墨欢盘坐在石台上,眼底发青,闻声立即送出丹火。

七日里,邵循借自己的真火重整炉胎,将融开的青髓铜填入原先几处薄弱的地方,再一道道刻入禁纹。墨欢能做的是接管剥离后的细火,让新填的铜汁冷得均匀些。

先前已经坏过一处。

邵循想将长火、细火两路同时合上,铜丝刚嵌进一半,交接处便裂了。他当即挑出重炼,连带废去一小块凝火晶。墨欢亲眼看着那点红光在真火里散掉,心疼得嘴角直抽。

“还能刮下来?”

“刮你的眉毛去。”

邵循自己也恼,后来足足半日没同他说话。

这一次,两人没有再赶。

先合长火,再接细火。炉内一道青光自底部升起,绕着炉肩转了一圈,渐渐走向旁边尚空着的浅槽。

邵循将最后一根铜丝压了下去。

火流忽然向右偏转。

墨欢肩头一震,掌前的丹火险些被带散。他立即撤开最外那一股,换细火贴着浅槽的边沿送入,炉肩上将要冷凝的一小片铜汁才重新亮起。

“慢点!”他道。

邵循没有答,手势却停了半息。

青光从铜丝下穿过,原本分开的两道炉纹第一次衔接起来。炉胎发出低沉的鸣声,院墙上的尘土簌簌落下,墨欢身前的火势也一下变得沉重。

他提起法力,要将那股细火留住。

“别跟着主火。”

墨沉的声音从廊下传来。

老人早已出来,面前铺着邵循画的炉图。他没有伸手去压炉,只拿笔在图上点了一下。

“右边接回时,先留这一寸。”

墨欢目光一扫。

图中那道回纹并非直接接向炉底,中间还折出一个浅弯。他方才见主火已经贯通,下意识便要将细火也引到底,反让两股火挤在了一处。

他立即换诀,把细火往外提开。

浅弯亮起,炉内那股沉沉的压势终于退了一点。

邵循趁势按下双掌,剩余铜丝全部隐入炉腹。六道新禁依次发亮,青光从炉底走到炉肩,再沿着右侧的回纹缓缓落下。

这一回,火没有断。

墨欢盯着那道光,直到它第三次绕过原先开裂的地方,才缓缓松开手。

邵循却仍站在炉前。

他将自己的真火一点点抽出,每抽去一分,便让炉内的火意独自行走一周。最后一缕真火离开时,青胎轻轻一震,炉口向外吐出一线清光。

院里忽然安静了。

三足落地,压在黑石上,发出一声沉响。

邵循走过去,掌心贴住炉腹。法力送入,新禁从里向外亮起,火势有高有低,却不再像开炼时那样乱撞。

他又试了两遍,才慢慢笑起来。

“三阶。够了。”

墨欢已经站到了另一边。

新炉比旧青纹炉重得多,炉耳也是新铸的,摸上去仍有些粗涩。他握住炉耳,分出一缕丹火,顺着邵循让开的禁纹送进去。

火在炉底燃起,向上一升,又被他牵回,稳稳伏在低处。

墨欢的呼吸停了一下,随即再换一种火法。

丹火化作数只小雀,从炉腹各处分开飞起。最高的一只绕过炉肩,最低的贴着底部掠行,上下相隔,彼此没有牵乱。

邵循在一旁看着,脸上的笑渐渐变成了挑剔。

“你右手太急。”

墨欢瞥他一眼,右手却慢了一点。

那只偏出去的小雀重新落回火流,收翅,化成一抹细光。

“叫什么?”墨欢问。

“青回。”

邵循早已想好,取出刻刀,在炉底避开禁纹的地方,刻下两个小字。

墨欢蹲着看他刻完,随即取来丹火石盏。

里面的灰白余焰比半年前暗了些。他试过不同药根养火,能留住,却总不敢多用。近些日子,连替二阶药逐去燥气,他也舍不得再引它出来。

如今盏盖打开,小小的火光在白日里几乎看不清。

墨欢以丹火引着,将它慢慢送到青回炉内。

那道余焰一入主火边沿,便被压得伏低。他立即将它带开,放进右侧浅弯,再将主火向中间收去。

灰白的一点亮在那里,晃了几下,终于不再被外头的热浪扯动。

墨欢盯着看了好一会儿。

“这样能养回来么?”

邵循伸指探了探炉温。

“先少耗一点。你若日日揪着它替你做事,换什么炉也一样。”

墨欢没有接话,只把那段小火槽外的禁纹细细封好,留下能取出的口子。

“我先用。”他说。

邵循立刻看他。

“你昨日还说药钱没了。”

“二阶药还有。”

“用三阶炉炼二阶丹,你倒舍得。”

“本来说好了,你不用时就归我用。”

邵循伸手指了他一下,最终自己也笑了,挥手让开。

墨欢没再炼金汤水丹。他温过炉,投入几味熟悉的灵草,依着从前的手法炼成一颗二阶碧生丹。这一回,丹气收得稳,炉火也没有因他分心照看灰焰而散开。

丹落玉盘,他俯身闻过,终于长长出了一口气。

旧炉那只拆下来的炉耳,就包在他衣袋里,隔着布,触手仍凉。

墨欢没有取出来。他将碧生丹收好,擦净炉口,把三足旁最后一点碎料扫到一边。

青回炉就此留在邵循院中。

……

次日天明,墨沉去渡口。

墨欢直到院门开了,仍没有将自己的储物袋收起。他昨夜翻过两回,丹札放进去,又拿出来,最后只给墨沉另装了一包常用药。

如今他提着那包药,站在门边,问:“我先送你到临潮城?”

墨沉看着他。

“到了之后呢?”

“再回来。”

“几时回来?”

墨欢没有答。

墨沉的目光从他脸上移到院里的新炉,又移回来。

“你昨夜看了它三趟。”

“我怕火没收好。”

“第三趟连盖子都没揭。”

墨欢抿住嘴。

邵循站在后头,正低头系袖口,没有插话。

墨欢望着大爷比从前瘦削的脸,隔了一阵,才问:“我留在这里,你是不是觉得我……”

“你这几日心全在炉子里,跟我上了船,就能收回来?”

墨沉的声音不高,问得却直。

墨欢的手指收紧了,纸包的一角被捏出褶皱。

他既想去,又舍不得青回炉。邵循答应的三年已经开始,炉里那点灰焰终于有地方暂伏,他还有好些在丹札里改过的法子,想立即试一遍。

“我想留下炼丹。”

他说完,抬头看墨沉。

墨沉点了一下头,把那包药接过来。

“那便留下。”

“你也别说过两日就回来。”墨欢忽然道,“没定的事,不许又随口应我。”

墨沉看了他片刻。

“我没说过两日回来。”

墨欢低下头,替他将药包塞进储物袋。

三人一起去了渡口。

客舟停在水湾里,船身不大,帆下绘着避水的纹路。墨沉取出船牌递给船家,自己挑了一间临水的舱,先探过船底几道阵纹,才将行李放下。

黑青旧砚和酒葫芦,都带在身边。

船家过来问老人要不要换上层的静室,墨沉往窗外看了看,摇头。

“这边离水近。”

邵循把一卷自己画的沿江水道图扔给他。

“到石壁前,别听渡口那家黑篷船喊价。往里走,还有两家。”

“记着了。”

“那一处若真不是夕水,拓回来给我看。”

墨沉将图收起,眼中终于有了点笑意。

“你先备好下回的赌注。”

邵循哼了一声,转身下船。

墨欢仍站在舱门口。

他原想再说一遍如何用药,看见大爷已经把自己装的那包放到近处,便停住了。片刻后,他取出装着第一颗金汤水丹的玉瓶,拔下塞子,给老人看了看那处浅痕。

“还留着。”

墨沉看着丹面,点头。

“这回别忘了塞瓶口。”

墨欢被他说得一愣,才发觉自己手里还捏着瓶塞。他连忙塞好,又忍不住笑了。

“下一颗要更好。”

“那就去炼。”

船工在外头喊了一声,开始收跳板。

墨欢走回岸上,立在邵循旁边。墨沉坐到窗前,以一手撑着窗沿,向两人抬了抬手。

客舟离岸,绕过渡口的石桩,沿着宽阔水道缓缓驶去。

墨欢看着那扇舱窗渐渐变小,直到看不清里面的人,才将玉瓶收回衣内。

回程走到城门边,邵循问他去不去丹坊。

“今日不去。”墨欢道,“我还有一炉药没试。”

邵循点头,两人沿城外的路往院中走。

院门推开,炉腹上的青纹仍透着温润的光。墨欢走过去,伸手探过炉内火气,随即将丹札摊在旁边,压上纸镇。

他翻到那两页一直未曾寄出的旧记,提笔在末尾添了几个字。

青回炉,温火可用。

第384章 不赊

江明在河口的药市上转了一圈,终于知道周显为什么不肯把这差事包下来。

每一家的价,都能说得有道理。

有的货从州外运来,路上死了两车灵草,剩下的当然得贵。有的药藏了七年,玉盒、养药泥、看守的修士,样样都要灵石。还有一家什么也不说,只把空架子给他看,意思是你不买,后面有的是人买。

江明看完,也没买。

离陈生入关已过七十日。他在广秀住了下来,最初还有人客气地喊江公子,听说他不打算进哪一脉,也不替祖师传话,后来见他在丹坊里问价,便渐渐只叫江道友了。

他倒不觉得难听。

周显手里的丹方他看过几张,广秀府库的存药却没供他随便取。结婴所需,他更不曾凑齐。当年在浣衣巷,陈生两炉黄芽服气丹引来丹劫,他站在旁边看,连一声都不敢出。如今再想起,先想到的已经不是雷。

是那两炉药。

江明低头看了一眼自己的储物袋。里头有从神都带来的积蓄,也有这些年攒下的器物,不至于空。他却不再像从前出入凤楼那样,进门先将灵石扔出去,等着人来奉承。

一炉丹,烧进去的可不只一晚酒钱。

“江道友。”

一个穿褐色短袍的修士从楼梯下走过来,手里托着两只黑玉盒。

“你在我摊前停了三次,没看中那块矿精,倒盯着旁边的根。眼力不错。”

江明看向他。

“根还在矿里,你却放在卖矿的摊上。”

“好东西分开卖,才能都卖出去。”

褐袍修士笑了一下,自报姓岑名渡,是来河口出货的金丹散修。他带来的货不多,架子上最贵的两件,还用普通黑布盖着。

岑渡领江明过去,揭开其中一块。

“货不赊。等我离开河口,你就是寻到我的洞府,也未必能再凑出这两株。”

半尺宽的赤色矿石里,长着两株火红灵草。叶子蜷卷,根须则深深嵌在矿中,一眼望去,倒像是石头腹里长出了两簇火。

三阶火神草。

江明曾在道藏中见过,也知道火中矿精能养出这东西。他却不敢仅凭名字便掏灵石,俯身看了许久,又将一缕法力送到矿石背面。

岑渡没有拦。

“五年前得的,养到今日。原想在内地出手,那边的买主压得狠,我又不想只换他家的丹,才带了出来。”

“到了边地,价就由你说?”

“你可以还。”

江明还了一个价。

岑渡将黑布盖回去,做得很快。

“你也可以去别家。”

江明笑了。他没有立即走,转到摊后,指着一块刻有缺口的火铜,又问了几句。那铜只二阶,价便宜,岑渡却因此重新打量他。

“你还做器物?”

“不做。我有东西,怕你认不全。”

江明取出一根赤金色硬羽。

羽端尚有一点没有磨去的血痕,羽脊却在离开封盒后,缓缓亮起。附近几件火属材料随之有了极轻的感应,连那块黑布底下,都映出一线红。

岑渡的手停住了。

他伸出两指,没去碰羽尖,先按在羽根的一处断面。过了数息,才道:“四阶妖禽。谁取的?”

“我朋友取的,给我了。”

“你想卖几根?”

“这一根。”

岑渡抬起头。

江明将硬羽轻轻一转,躲过他伸来的第三根手指。

“别拿拆开的价,问我一副翅膀。”

他只是要买药,不打算把自己的剑匣材料都铺在一个生意人的眼前。二狗给他的其余硬羽与爪骨仍收在袋内,鼋甲也没动。几样凑在一处,将来能做什么,得由真正的炼器师看过;岑渡显然不是那个人。

两人重新谈价。

这回岑渡揭了布,拿出养药的玉泥,让江明看矿石下面仍活着的两截根。江明指着一处叶尖,问过卷叶下的火意,岑渡便抹去价纸上最后一笔,重新写了一个数。

两株草的品相、保存难易,一根羽如今能用到哪里,全被两人一句句压到了实处。

最后,硬羽抵去六成药价。江明再补灵石,连矿带草取走,岑渡不包采根,另送一层能压住散火的玉泥。

江明在药匣上扣下第一道封禁时,身后有人开口。

“我以为是来了位阔客,原来还要拆东西抵价。”

一个高瘦男子停在摊前,身上披着玄灰短氅,腰间悬了一口窄刀。江明刚入药市时便见过他,这人跟着岑渡的货看了很久,一直没有开口买。

岑渡道:“罗道友,价已谈妥。”

“没说草。”

男子的目光从药匣上挪开,停在江明背后的长匣上。

“我姓罗,名鹫。你若缺灵石,我倒能帮忙。”

江明的笑意还在,扣禁的手却慢了一下。

背上那只临时长匣,靠近匣缝的一线青光极淡,寻常修士多半以为是江明自己的御剑法力。他为了不让器中残气被一路牵动,平日压得很紧,仍不是时时刻刻都能压得毫无痕迹。

罗鹫看见了。

“你怎么帮?”江明问。

“先给你灵石。那口剑押给我,等你手里宽裕了,原数取回。”

“原数?你做什么生意。”

“交个朋友。”

岑渡站在另一侧,一声没接。他将收到的硬羽封回盒中,又在江明药匣的底角加了一点玉泥,慢慢抹平。

江明侧过身,让出匣口。

“交朋友不收利,倒是好事。只是这剑,我还没用惯,押给你几日,更用不惯了。”

罗鹫看他片刻,笑意淡了。

“剑器太好,用不上也是白费。真到能用的时候,不知还剩几年。”

“那我更得抓紧。”

江明将药匣收好,抬手向岑渡点了点头,走出了摊前。

江明下阶时,岑渡在后头道了一声:“草别拔。矿中的火意还没退,回去自己慢慢收。”

江明应了。

……

河口药市搭在三条水道交汇处,房舍沿岸错开,向广秀去的修士,多从东面出。

江明没往东走。

他先去西岸喝了一壶茶,又在卖药种的铺子里问了几样价。弗陵交给他的那张小笺仍在袋中,他拿出来比过,只有两项值得记,其余运到神都,路费便能把差价吃尽。

午后的药市渐渐热闹。

他从铺里出来,发现对岸那件玄灰短氅又在一处楼下。罗鹫与旁边的修士说话,头始终没朝江明转,腰间窄刀却换了位置。

先前悬在左,如今到了右。

江明伸手取背后的剑匣,指尖刚碰上,便有一股极细的神识扫过来。

他像没觉察到,把匣背带收紧了一格。

随后,他去药市的石亭买了一张周围水路的简图。河口向西不远,有一条夹在两片石壁间的支流,舟船走不通,金丹修士从上方遁过倒很方便。

过了石壁,才是开阔山野。

江明将图折起。

罗鹫在柜前试探时,金丹气息曾压近剑匣。江明已经记住了那股法力,此时取出传讯符,望了望对岸,又将符收了回去。

河口离广秀不近。他向岑渡买东西,没打算再把自己的回山路也交过去。

在岸边整理袋子时,他将药匣挪到里面,鼋甲压在它外侧,其余硬羽与爪骨也各自封好。然后,他解下旧剑,亲自摸过剑柄上的穗。

河风将那几缕旧线吹向掌背。

这一口,没有青光从匣缝透出去。

江明将旧剑放到最顺手的一侧,起身出了药市。遁光起到半空,他略停一下,像不熟此地,辨过方向,才沿那条支流向西。

身后并没有立即出现刀光。

江明也没加快。

石壁在下方合拢时,他从水中看见了第二道影子。那影子离得不近,始终压在他遁光后方的亮处,借着日色,连一线灰气都不肯露。

他收起简图,落向西壁。

一块被水冲平的石头上,还留着半片青苔。江明站稳,解开背带,将长匣斜搁在石旁,掌中亮起自己的御剑法力。

匣中的青碧剑微微一动。

后方那道影子随之停了。

江明向水面看了一眼,开口道:“这里没有柜台。罗道友,还要跟我谈押剑?”

第385章 两口剑

石壁上方,一道灰影停了下来。

罗鹫没有立即落地。他向两侧扫了一眼,确认附近没有第二名修士,才将右手搁在窄刀柄上。

“你倒会挑地方。”

“柜前你愿意给灵石,到了柜外,反而把刀拿出来。”江明道,“你的朋友,做起来有些亏。”

罗鹫笑了。

“剑留下,药带走。”

“我没答应典。”

江明的法力托着青碧剑,将它从长匣中提出半尺。水面照上来的光落在剑锋上,显出一线清碧;匣底仍贴着石壁,没挪。

罗鹫的目光立刻落了下去。

他拔刀,却不是先砍江明。

一道灰白刀光斜斜落在两人之间,将石壁上的青苔削去大片。水面被压出一个窄坑,紧接着,两片浪朝左右涌起,裹着细碎的刃光,封向江明身侧。

长匣正好处在左侧。

江明伸手一引,匣子离石而起,飞向身后。他右手的旧剑却向前递去,剑尖没有穿进那片浪,反沿着水坑的上沿,挑向尚未完全散开的刀势。

叮。

极短的一下相碰,罗鹫的身子已往下落。

他立在另一块石头上,刀锋横过,灰白灵光在水面上铺成一条平线,随后骤然抬高。

江明所在的石头被削平半尺。

他跃起时,鞋底被余势擦过,背后长匣也被一线刀光赶上。匣面响了一声,扣子崩开,青碧剑未伤,外头那层临时封匣却多了一道裂口。

江明伸出左手,将剑摄在身旁。

罗鹫已经追来。

刀上光色更淡,贴到近前,却比刚才那两片水浪都重。江明以旧剑挡了正面的一下,手腕一沉,脚下再无石头可站,整个人被压到水面上。

罗鹫左掌探出,掌心浮着一圈细细的灰线,径直向青碧剑抓去。

他一直在等这一刻。

旧剑要挡刀,江明便得将更多法力分往右手。那口不肯老实伏在匣中的高阶剑,只要被他扣住剑脊,一时间就别想再顺畅掉头。

江明看见那只手,将青碧剑向外一送。

清碧剑光横过半空。

上面的法力只沿着较浅的路走,到过掌前,又折向罗鹫肋下。正是回山途中,江明在石楼外接稳的那一处转折。

罗鹫的手一缩。

剑光掠过袖口,削下半片玄灰衣料。

江明借机离开水面,旧剑收回胸前,脚尖点着西壁凸起的石沿,向上挪了两丈。

他的右腕在痛。

对手金丹法力颇厚,那口刀又用得顺手,硬接下去,旧剑就算撑得住,他的手也未必撑得住。

罗鹫看向自己的袖子,没再伸手抓。

窄刀在掌中一转,灰线沿刀背亮起来,从高处往下,罩着水道划了一个弧。

两侧石壁上的砂土无声脱落。

江明的神色一凝。

这一刀不求逼近,要把他从石沿上刮下来。

青碧剑迎了上去。

他没有把神识往剑器更深处压,只沿自己已经摸熟的一段,催出较厚的一片剑光。清碧与灰白相撞,石壁上方一阵大亮,刀光被截断了小半,余下的仍落在江明脚前。

石沿碎了。

江明横身移向侧面,窄刀紧接着转回,逼他再出一剑。

他这次出得快了一点。

青碧剑上浅处的法力尚未收回,内里的一股残势便被连着牵动。剑尖忽然向里折去,原本对着刀口的光,反有一道偏到了江明左腕前。

护身法力被割开一线。

血从腕侧渗出来。

罗鹫笑了一声,灰白刀光随之压下。

“还没收伏,便拿出来卖弄。”

江明没答,手里旧剑向上翻,挡住了紧随而至的刀锋。半空一阵金铁声,他左手急收,青碧剑上的光终于敛住,人却也被震到石壁边,肩后重重撞了一下。他勾回落在下方的长匣,让它靠在脚边。

方才崩开的扣子,被水冲出去了一丈多。

江明的脸上没了笑。他将右手松开半指,缓过掌中的麻意,看向罗鹫。

罗鹫落在水道中间,刀尖稍垂,没有立即挥第二刀。抢器物与杀人不同,这口剑太好,他不想让江明临死拼命,将里面尚存的禁纹一起震毁。

“现在放手,来得及。”他道。

江明忽然弯下腰,将裂开的长匣拾起。

青碧剑落回匣中。

罗鹫的手指在刀柄上紧了一下。

江明扯断旧背带,用它在匣外绕了一圈。剩下那枚扣子合不上,他也没再合。长匣半敞着,抵在西壁脚下,青碧剑的一角剑光仍能从裂处看见。

“你要的是这个。”江明道。

他提着旧剑,沿石壁向南移动。

罗鹫没有去拾匣子。

灰白刀光再次升起,朝江明的膝前落下。他从刀后跟过去,一步就越过了水道,将两人之间的距离拉到不足两丈。

江明抬手一刺。

剑势比方才短,也比方才低,直奔窄刀护不住的腰侧。

罗鹫侧过身,刀背压下。两口兵刃交错,旧剑向外一滑,在水面上拉出半道细痕。

江明的脚也向外退。

罗鹫没有追到最远。他用余光看了一眼剑匣,指尖的灰线顺着刀锋又向外延长半尺,掠向江明腰间。

江明以剑拦截,旧剑锋上崩出一个米粒大小的缺口。

他还是向南退。

两丈,三丈。

到第四丈时,罗鹫终于露出了笑意。匣子已被抛在后方,江明想回身取,得先从他的刀下钻过去。

这人舍不得剑。

他收紧灰线,将刀光聚在身前,再次贴了上去。

江明抬起右腕。

旧剑挑开先来的刀光,没有向远处走,剑势在身旁兜了一个极短的圈。剑柄重新贴回掌心时,他的肩向左侧让去,腰腹收紧,刻意容那道较重的灰白光从身前掠过。

外袍裂了。

护身法力被剖开半寸,腰侧立刻多了一道血口。江明眉间跳了一下,脚下却没有再退。

旧剑跟着他的转身,刺向罗鹫胸前。

罗鹫挥刀来截。

就在此时,江明左手的两指猛地向上一提。

长匣裂处,清碧之光亮了。

那口剑没有沿着司马言留下的深路起势,只从匣底向外滑出,朝水道上方平平掠来。起手时慢,到石壁中间,才完成那一下浅转折,剑锋对准了罗鹫的后背。

江明把它留在四丈之外,不只是为了引罗鹫近身。

这口剑连续转急,会牵动残印。留出这一段距离,便能先收尽上次的法力,再走一遍已经接得稳的路。

剑光没有在他腕前折回来。

罗鹫察觉背后锋芒,身形一顿,窄刀转向后方。

原本追着江明的灰线,也跟着刀锋向外翻去。

江明的旧剑到了。

剑尖没去劈那一整片灰光,反从收力时露出的半寸空隙里刺入,顶着罗鹫胸前的护身灵光,猛然一送。

罗鹫闷哼,胸口见血。

他来不及将刀拉回,左掌便压向江明,掌中法力爆发,要先把这个还敢贴身的人震开。

江明没有硬吃。

他弃开剑柄,向侧面滑出去,神识仍扣着旧剑的浅禁。剑上法力没有断,随着那一掌越过他的肩,剑锋又往里进了一寸。

背后的青碧剑也到了。

窄刀与清碧之光撞在一处,罗鹫的手臂向外荡开。那剑器本身的锋锐,从刀势尚未铺满的下沿划过,切破了他的后肋。

罗鹫的身子猛地向前倾。

他拼命撤刀,胸中气息却在此时一滞。江明的旧剑横着一挑,断开了最后那股尚要涌出来的法力。

血洒进水里。

窄刀从手中落下,铛地撞上石头,再滑向浅滩。

江明落在两丈外,没有立即走近。青碧剑悬在身侧,他右手虚握,旧剑仍压在罗鹫胸前。

那人挣动了一下,又一次。

江明眼中光色一紧,旧剑带着最后一股御剑法力斩下。

青碧剑随之贴落,截住胸腹间最后一点灵光。

护身灵光彻底灭去,罗鹫伏进水中。那颗金丹中尚存的气息,也在两剑合压之下散尽,没有再从血水里腾起。

水道重新响起流水声。

江明喘了几口气,右手才缓缓放低。

……

他先封住腰间与腕侧的血,吞了一颗自己带的伤药。

药匣还在。

贴着匣外的鼋甲也完好,没有被方才那一刀划中。他取出药匣,检查底角的封禁,见玉泥仍裹着矿石,才重新收好。

江明走到水边,收回旧剑。

刃上的缺口很小,摸过去,却仍能感觉到一点不平。他的拇指在旁边停了停,随后翻转剑身,洗去了上面的血。

旧剑穗湿了,贴在掌边。

他没有剪掉。

青碧剑重新落回长匣。裂开的匣面压不拢,江明索性取出一段素布,将匣外紧紧束住,再收进储物袋,不让它一路贴在背后散气。

两口剑都带走。

罗鹫的刀和储物袋,他也没有丢下。袋中有灵石、几块普通矿料,两张没有用出的符,别的暂未细看。江明封了袋口,将窄刀包住,沿水道飞上高空。

这次,他没有再停下来等人。

……

回广秀时,已经是次日傍晚。

江明先到周显的丹坊,托他辨那两株火神草。周显看过活根和矿中火意,点了头,又将药匣推回去。

“能收进丹方。你想卖?”

“先不卖。我自己留着。”

“火属的三阶方不少,结婴那一炉却还缺许多。你若只是想拿草来换成丹,价未必合算。”

“我知道。”江明坐下来,“我先把自己的药攒起来。”

周显此时才注意到他腕上的浅伤。

江明没有说是练剑擦的,将河口药市和石壁水道的事讲了。罗鹫的刀被摆到案上,周显伸手按住刀背,看过两处沾血的刃口,脸色稍稍沉下。

他没有急着将药市里每个人都归成一伙,只叫来门下修士,把这名金丹的姓名与夺剑经过送往河口的执事处。

“你还要去?”周显问。

“别处也去。”

“下一次找个同行的人。”

江明看了他一眼。

“你也要结婴,要不要同行?”

周显被问得停住了,过了片刻,才道:“我手里存的药,比你多。”

“所以我更要跟你去。”

江明靠回椅中,终于笑了。他把自己问来的两项药价说出来,抬手在案上写了一个数。周显原本只肯听,听到后头,也开始问哪一家、什么品相。

天色暗下时,江明从丹坊出来。

祝霞山上有一片淡淡的金光,亮过半边檐角,又缓缓收了回去。云影掠过屋顶,静室的门仍合着。

江明停在山道上看了一会儿。

他收紧药匣的封口,将今日记下的两项药价添在旧底稿旁。先前寄给弗陵的是回山路上的询价,这回两项的品相与运价,他还得另写一封,明早才去寄。

随后,他提起布束着的裂匣,朝炼器一脉的灯火走去。

腰侧的伤还牵着。江明换了一只手,脚步没停。

第386章 匣与剑

炼器一脉的灯,比丹坊熄得晚。

江明沿石阶上去,还没到门前,便听见里面有人骂了一声。紧接着,半块烧裂的铜坯被扔进水槽,白汽扑到檐下,挂着的一串铜片哗啦作响。

一个卷着袖子的男子走出来,袖口沾着黑灰。

他看了看江明手中的布束,又看了看江明左腕。

“修器,还是寻仇?”

“寻仇的已经死了。”

男子抬眉,让开了门。

屋里三座炉,只有一座还亮着火。江明把裂匣放在空案上,松开素布,青碧剑便从匣缝间透出一线冷光。

男子的目光落了下去。

“好剑。”

他没有立即伸手,先绕过案子,在另一边坐下。

“晏衡。这一脉的三阶器,我接些。你要补外壳,还是要重新做一个?”

“新的。”

江明取出鼋甲,随后是剩下的硬羽与爪骨。几样东西依次铺开,晏衡原本还在转的一枚铜环停在了指间。

他先托起鼋甲,敲过边缘,一缕金丹法力透入甲背,又以细火照了照里面。

“三阶石鳞鼋,甲心还整。”

放下甲,他又看硬羽。这次看得久些,直到羽根上细细的赤金光被引出来,才抬头问:“这些也要一起用?”

“能用便用。要结实,背着它赶路,不必我一路拿法力替它捂着。”

晏衡看向那口青碧剑。

“抬起来,照你平日的路走一次。”

江明右腕还在酸痛,没有伸手去握。他以神识牵住剑器浅处的禁纹,剑身缓缓离开裂匣,在案上横移尺许,便停住了。

晏衡指尖的铜环轻轻一颤。

“里面还有别人的东西。”

“有。”

“我做匣,不替你把这个洗干净。”

“我没叫你洗。”

晏衡这才点头,让他把剑收回去。

“鼋甲作骨,羽脊和爪骨取能用的部分,另添些温铜、柔银。我能做三阶。它能收住寻常外泄的剑气,进退也比这只临时匣顺。至于你斗起来连续催剑,里面的残势跟着翻,匣子管不到你手上。”

江明低头看了看左腕结痂的浅口。

“我知道。”

晏衡将一根硬羽放到较远处,没与青碧剑并排。

“这东西火性重,得先收拾。不是级数高,就能整根往里塞。”

“多久?”

“四个月。我的炉上还有别人的活。”

江明抬眼。

“能不能提前?”

“能。把人家的料钱、误期的赔付,一并给了。”

晏衡用下巴点了点里头那只仍亮着的炉。

“炉里正在养,不能为了你的匣,叫它今夜就长好。”

江明没有再催,先把旧剑也放到案上。

刃上那个米粒大的缺口,在灯下不大显眼,晏衡却一下便摸到了。

“这口也补?”

“一并算个价。”

“匣子,两万上品。先一半,取时再一半。辅料我出,添进去的四阶料仍按你的东西算。旧剑要另外开火、调锋,另有料钱。”

江明没说话。

他本来已经将储物袋放在案上,听完,又把袋口合了一半。

晏衡指了指裂匣。

“两道护匣的禁,最费工。你若要省,我劝你省别处。这一只怎么裂的,你自己清楚。”

江明的手停在布带上。

河口那一刀并没有真正斩中青碧剑,匣扣先崩,匣身又开裂,他不得不把剑摄在外头,接着才有左腕那道口子。

他把旧剑从案上收了回来。

“这一口先不补。”

晏衡没有劝,拿两指夹住剑背,看过缺口两侧,松了手。

“还能用。别拿这一处硬接重刃。”

江明又打开一个包袱。

罗鹫那口窄刀落在案上,刀身已经擦净,灰白纹路却没有随主人一起散尽。晏衡只看了一眼,便伸手取来,掌心贴住刀背,从头到尾摸了一遍。

“抵灵石?”

“你先开价。”

晏衡报了个数。

江明笑了。

“你方才还说,护匣的禁最费工。这把刀里现成的几道,你却只肯按破铁收。”

晏衡的嘴角终于动了一下。

“刃是好的,内里的禁得重理。我卖给谁,都得先花这份工夫。”

“那也不止这个价。”

两人隔着案子谈了一阵。

炉内火声时高时低,晏衡中间起过一次身,往炉口添了半片料,回来继续看江明,既不赶人,也不让价让得痛快。

最后,窄刀折作七千上品。

江明另取出三千,凑足定金。灵石落到案上时,他腰侧的伤牵了一下,动作慢了半拍。

晏衡没有装作没看见,只将案旁的高凳踢近些。

江明坐了,却不急着把材料全推过去。

“余料呢?”

“成器一起还。”

“若做坏了?”

晏衡望着他。

“我下火失手,误了你这份料,定金退,赔料。你中途把剑炼成另一种路,拿回来要我重做,不在这里头。”

江明点了一下头。

“青碧剑不留你这里。”

“我也没要。量过长短,辨清收势,够了。成器时你带剑来,再合最后一处。”

晏衡取过一条细铜尺,等江明将青碧剑重新托起,沿剑身记了几个落点。尺子放下,江明便将剑收回旧匣,用素布重新束好。

两口剑,他都收在自己身侧。

鼋甲、剩余硬羽、爪骨,却被他一件件送到晏衡那边。晏衡将羽与骨分匣放好,鼋甲搁上架,再把抵价的窄刀收进自己的柜子,取一片留有火纹的铜签给江明。

“四个月后来。别过三天便问它长好没有。”

江明收起铜签,提了提布束的裂匣。

“你若提前做好,我也不会嫌快。”

晏衡哼了一声,转身去了炉边。

……

次日清晨,江明先把药匣放到窗边。

养药玉泥仍然湿润,矿中两株火神草蜷着叶,靠近根须的火意没有散。江明重新合好封禁,将药匣收到身边。

陈生的静室里,偶尔传出一声很轻的炉鸣。

江明没去敲门。

他铺开昨晚带回的底稿,另取一张纸,把河口新问的两项药价重新写清:哪家、怎样的品相、药龄与保存方式,还有送到神都的运价。

前一封记的沿途报价,已有些不适用。他在这封末尾添了几行,叫弗陵先算实际能收多少,再给个回话,别只说越多越好。

最后,他犹豫了一下,又写道:

“若能做,走这一趟的份子,也先说好。我这边刚订了剑匣,灵石不是只出不进的。”

写完,江明低头看了一眼空出不少的袋子,觉得这句不能删。

他把信封好,亲自下山。

传信铺的掌柜刚开门,正在给架上的空筒换封。江明报了神都紫令堂与弗陵的名字,付清这一封的灵石,站在柜前,等掌柜将新信封入筒内。

晨间的传信阵亮起来。

那只筒从架上隐去,江明才转身离开。

山道上有人背着新法器从他身旁经过,绶带鲜亮,鞘面还带着炼成不久的光。他手中的裂匣却仍是布束的,走快了,腰侧还会发疼。

江明没有追着问那人的价。

他把旧剑换到右手最顺的位置,想起晏衡说的四个月,先在心里把取匣时那另一半灵石,留了出来。

第387章 向潮去

陈生入关第八十二日,太平峰来了一位客人。

来人是个青衣女修,发间横着一根铁簪,衣上没有宗门纹饰。周显将她领到山外,先传了一句话,随后便被山中的二狗放了进去。

二狗没有在晶碑前等人。

他坐在洞外石上,膝头摆着一件脱下的外袍,正把坏掉的袖口撕开。焦黑的布料一片片落下,露出里面被法力扯断的织纹。

女修看了一眼。

“陈道友的法衣,比传闻里省钱。”

“这件穿着顺手。”二狗把剩下半截袖子也扯掉,“你要谈的是海上的事,不是买衣裳吧?”

女修笑了一声。

“裴素岚。散修,常在东南一带走。”

元婴后期的气息,在她身上收得很稳。周显方才说,她进山时先见了值守修士,报明来意,既没有硬闯,也不肯把要说的话隔着三四个人传。

如今见了二狗本人,她也没多绕。

裴素岚从袖中取出半面铁盾,放在石上。

盾不是被刀剑砍开的,断处向内卷着,几层炼在里面的禁纹挤在一处,边沿还留着极细的青白色泽。

二狗捏住一角,没有往里面送法力。

“雷击?”

“先是风罡把外层削薄,潮头随后压过来,盾里的回势反打到自己身上。最后那一下雷,才把它折断。”

二狗抬眼。

裴素岚用指尖敲了一下断口。

“回风峡,东南外海。这盾是我上回从那里带回来的。还有一只护腕,没带回来。”

她伸出左手。腕上皮肤光洁,却只有两道很浅的环痕,像是曾被什么硬生生勒进去过。

二狗把铁盾放回石上。

“里面有什么?”

“玄罡铁,四阶。峡底露过两处,我采到过一点,余下的当时带不走。”

裴素岚又取出一块指节大的矿料。

灰黑的石皮下,有一层近乎透明的青光。二狗将它托在掌中,隔着皮壳便感到一股极沉的阻力,微一偏转,阻力又从指侧滑开。

“这是你采的?”

“我从那片岩里敲下来的。别处有没有一样的,没去替你找。”

周显在旁边听到这里,忽然觉得自己再留下也没什么用处。他向二人告退,裴素岚点头,二狗只挥了挥手,没有让他在山外候着。

山路上脚步声远了。

裴素岚这才展开一张海图。

“十七日后,湾外两股潮在这里相撞。海水往后退的那段时间,峡口会露,风却向里灌,夹着前一轮没散尽的雷。上次我就是看见口子开了,急着往里走,才把盾折在半道。”

“你这次想让我顶在前头。”

“我想请一个能顶住的人。”

她答得坦然。

“陈道友昔年在边地的名声,我听过。前些日子才知道你回来了,境界也到了。你守峡口,我进去取矿,按事先说好的分量给你,不让你白挨浪。”

二狗看向海图上的窄口。

“不去。”

裴素岚的手停在图边。

“价还没谈。”

“价高些,我就该在门口站着?”

他把矿料放回去,指向峡内。

“门开了,我也进去。”

裴素岚望着他,过了一会儿,道:“有个圆满在里面抢矿,我未必落得到好处。”

“你不信我?”

“今日第一次见。”

二狗忽然笑了。

“说得好。”

裴素岚却没有跟着笑,伸手将矿料收回匣里。

“我的通路、潮时,前后用了三趟才摸清。你不能看过一面破盾,就当作这地方人人都能进。”

“所以路由你领。到了峡口,正面那一下我先接,进去后各凭手段,得到的东西一起分。”

“怎么分?”

“一半。我要先挑。”

裴素岚把海图收窄了一寸。

“你倒是不客气。”

“你叫我来挡正面,也没客气。”

两人对视片刻,谁也没让。

最后还是裴素岚先开口。

“所得我六你四,你可以先挑一件,也得算在你那四成里。若只取到一件,想拿走的人,补另一人的那份价。”

二狗想了一下。

“成。但浪真顶不住,我会走,不替你把一个出不来的口子撑到死。”

裴素岚按着海图的手稍稍松了。

“我也一样。”

她把图重新展开,指向外侧一处弯回的礁带。

“退到这里,海面会有一刻的平缓。那时还不走,下一股潮就要越过礁头。我有舟,能接这一下,但损了器物,各算各的。”

二狗记下位置,没有问她能否保证全身而退。

裴素岚却又打量了他一眼。

“陈道友要玄罡铁,是准备炼一件重器?”

“能得料当然要。”

“但你先问的是门开后能不能进去。”

二狗望着那半面铁盾。

内层禁纹被反打得纠成一团,外层却还保着原本向前撑开的样子。用盾的人那时必然以为,撑住这一面,下一面也能照样撑住。

他在黑崖里,太熟悉那种用力了。

“我要看看,来路不停变的时候,我的印还能不能这样打。”

裴素岚轻轻扬了一下眉。

“想再上一层?”

“想。”

“那你找错地方了。我在那儿丢了护腕,回来还是后期。”

二狗笑了。

“我没问你那里能不能把人推上去。”

他将撕坏的外袍从膝上拿开,站了起来。

“我去自己打。”

……

裴素岚在山外等到次日。

二狗把这些日子记下的几片玉简收进袋里,换了一件不缺袖子的黑衣,离开了旧洞。

从峰上下来时,他绕了一次祝霞山。

江明正在院边磨一截旧匣的裂棱。布已经重新束过,磨平突出的地方,提起来才不会总刮衣裳。

看见二狗,他把匣放下。

“出去?”

“东南外海,回风峡。”

“听着比河口远。”

二狗瞧了一眼他的左腕,没去揭那块已松开的药布。

“你的新匣呢?”

“说是四个月。现在还在别人的料架上。”

二狗笑着走到石案边,取了一张纸,写下去处与先要经过的栖海渡,又添了裴素岚的名字。

江明看见末尾还有半句。

二十年还早,这回我先走几日。

“你跟他比这个?”江明问。

“不比。他在屋里有事做,我在外面也得有。”

静室里,炉鸣沉沉响了一声。

二狗停了一下,没有向门上送神识,只把纸压在石案的空茶盏下。

“他歇火出来时,让他看。若有事,信先寄栖海渡,我回来会取。”

江明点头,又问:“什么时候回来?”

“还没进峡,不定。”

“那你写走几日。”

二狗低头看了一眼,将“几日”划掉,改成“一趟”。

江明看着他改,笑出了声。

二狗把纸重新压好,没坐下来喝茶。

山门外,裴素岚已经在等。她不再取出海图,见二狗走近,只问:“同行的人呢?”

“没有。”

“我以为你还要带几个门人。”

“去采矿,不是去给人讲法。”

二狗从袖里取出灵石,交给守传送台的修士。裴素岚也付了自己那一份,两人先借宗外商路的阵台往东南走,再自沿海城镇出海,行程尚有十余日。

阵光从脚下升起时,二狗向广秀群峰看了一眼。

太平峰隐在云后,祝霞山的屋脊也只剩很浅的一点。

他没有等它们重新露出来。

东南方向的阵光一亮,二人的身影便从台上消失了。

第388章 分水处

墨沉抵达临潮城时,先嫌这里的水声太响。

客舟在外湾停下,潮头从两面石壁间挤入,轰隆隆撞过船底。窗边的茶杯跟着响,老人伸手按住,等船稳了,才把最后半口茶喝完。

此时陈生已入关将近四个月,墨沉离开焚城,却已有四个多月。

他中途换过两次船,在一个满街卖竹器的小城住了十余日,还进山看过一场炼器人试钟。钟声不好,卖钟的人却极会吹,他听得起兴,索性留下来,看对方如何将一口哑钟说成镇心之宝。

如今真到了临潮,倒没人同他多讲。

船家只朝岸上指了指:“看字往东。落潮时去,晚了只看得见浪。”

墨沉收好茶杯,下了船。

他在听潮街找了间客舍,放下行李,将旧砚与纸带在身边,先去东岸吃了一碗鱼汤。店家撒了一把红色细虾,他尝过,眉头立即皱起。

“这么咸?”

“都这样吃。”

墨沉把虾拨到碟里,另叫一碗清汤兑上,才继续吃。

午后潮退,两面石壁渐渐露出。

左边的壁面较宽,留有七行字;右边有几道弯曲长痕,看着像天然水纹,又隐约与左壁末尾相接。墨沉沿石阶下去,走到最后一级,却被一股翻卷的水气拦住。

壁前有人。

那人穿灰蓝长衣,眉毛极淡,两臂裸露,手掌每向前推一次,湾中便有一道白浪折回。白浪在两壁间转过,到了他身前,已经薄成一片,贴着指掌飞起。

元婴修士。

墨沉看了一阵,开口道:“借道。”

灰衣人侧过脸。

“看字?”

“看字。”

“等我收了这股潮。”

墨沉望向两壁之间。潮已经往下落,那七行字却被灰衣人的水势盖住,才露一点,便又没了。

“你几时收?”

“顺了便收。”

墨沉又看了片刻。

“照你这么推,今日顺不了。”

那人手掌一停,海水仍沿着身前的弧线翻卷,眼神却转了过来。

“道友尊名?”

“墨沉。”

“俞淮。”

他将水势向旁边拨开些,露出岸边一张石案。案上压着数幅拓片,最外那幅的第四行,赫然也是夕水二字。

墨沉俯身一看,忍不住笑了一声。

“我就是被这个骗来的。”

俞淮淡淡道:“那是我拓的。”

墨沉抬起头。

“难怪。”

俞淮的脸色沉了。

他在这里住了数十年,借两壁回潮磨炼水法,也卖几份拓片,换些养水的灵材。墨沉一句话,将拓片与练法一并贬了,他自然不爱听。

“你若能拓得更清楚,就自己下去。”

“你把水压下来。”

“这股回潮我聚了半日,收掉还得重来。你一句借道,便让我白费工夫?”

墨沉捏着自己的旧残拓,眼看壁上那几行字又被浪头淹没,心里也起了火。

“我替你拓清,你把贴壁的这一股收一刻。”

“两面壁都要。”

“你倒会做生意。”

“你也不肯少说一句。”

墨沉把残拓收入袖中,抬脚下了最后一级石阶。

俞淮抬手,将贴壁的薄浪牵开,另一股回潮仍在壁前打旋。他向内湾抬了抬下巴。

“里面有船,别整股掀过去。”

“用不着。”

墨沉伸掌一按。

湾中浪头向下一沉,离壁三尺的水面骤然低了下去。老人袖口垂着,法力却已穿过涛声,像一片厚重的青影,将两面石壁前的水气压开。

七行字一齐露出。

墨沉目光落在第四行,眼里顿时一亮。

就在这时,俞淮原先留下的回潮从侧后卷来,撞在青影边缘。墨沉掌心微沉,脚下石面发出一声轻响,水光擦过衣角,把灰袍下摆打湿了半截。

俞淮没有趁势加力,却也没有替他收掉那股潮。

墨沉瞥了他一眼。

墨沉又将青影压低半尺,刚露出的壁脚旋即被后潮淹住。两壁间的水一股接一股,他的手掌一直托着,指节渐渐发酸。

他没有继续往下压,而是向前一步,神识贴着第四行扫过。

夕字上方,覆着两小片黑绿水垢。

其下并非平石。

墨沉屈指一弹,一线法力挑开垢层,两笔向外分开的短痕终于露了出来。下面那一折,也并不是拓片上画出的斜钩,被水痕连上的细处断开,字形一下完整了。

“分水。”

他低声读了出来。

俞淮也看见了,脸上的神情微微一变。

墨沉的目光已沿着水字往后走。古刻末尾,两道浅痕分向左右,正与另一壁的长痕相应。一道先低后起,另一道贴底回转,并非将整股回潮一口吞下。

老人忽然翻过手掌。

那片压水的青影从中央裂开,左右各留一线。迎面回潮顺着两道不同的高低被分开,一股向石壁侧方滑去,一股沉入壁下,原本不断挤来的水势顿时轻了。

墨沉抬起另一只手,纸从袖中飞出,贴住了壁面。

旧砚悬在肩边,墨汁被法力牵成细雾,均匀落上纸面。字痕一笔笔显露,边缘残缺与水蚀处也全留下来,没有被他补成心里想的样子。

俞淮望了一阵,忽然撤去掌前的水光,向右侧一拂。

那股沉下去的回潮被他接住,顺着长痕一转,竟比先前平顺得多。

墨沉肩头一松,立即换纸拓右壁。

等最后一道长痕落在纸上,老人双臂缓缓垂下,退回石阶,坐了下来。

潮水重新漫上两壁。

他的衣摆仍湿着,掌心也轻轻发颤。他没去掩,先服了一粒随身药,取出酒葫芦,想了想,又放回腰间,向俞淮要了一杯热水。

俞淮把水送来,手却伸向那两幅新拓。

墨沉抬眼:“等干。”

“我看看。”

“我也还没看够。”

俞淮只好在旁边坐下。

两人等纸上的水气散去,各自盯着一面。过了许久,俞淮指着右壁的一道转折,说:“这一下,我原先接早了。”

墨沉喝着热水,鼻子里哼了一声。

“字都少两笔,还接什么。”

“你刚才也不是照着字下的手。”

“现在看见了。”

俞淮盯了他一会儿,忽然笑了,伸手将旧拓里写夕水的那一张抽出,折了起来。

墨沉休息了一阵,手不再颤,便将两幅新拓各复制一份,依约给他。

俞淮接过,不肯白认一句谢,又道:“后日还有大潮。你若愿看,我换个走法给你瞧。”

“先说好,这回不替你拓。”

“也没叫你拓。”

墨沉把原拓收妥,起身往岸上走。走到石阶顶,他回头看了看,俞淮已经站回水边,手中那股水势分成了高低两线。

老人嘴角慢慢翘了起来。

他回客舍,睡到日落,醒来时天边还剩一点红光。楼下有人在叫卖热鱼汤,他隔窗喊了一声。

“不要红虾,汤淡些!”

第三日,墨沉没有急着登船离城。

他去看了俞淮重新催潮,站在岸上指点两句,又被对方嫌站着说话不累。两人争了一阵,末了各自留下几笔用法,没有再争高下。

傍晚,他将两幅原拓再复制一套,附上短笺,亲自交到临潮城的传信铺,收信处写焚城邵院。

“第四行是分水,不是夕水。新拓带回了,两壁都全,叫邵循备好赌注。

“我住临潮听潮街,打算再看几回潮。取拓那日出了些力,回来睡了半日,今日已去岸上走过。这里鱼汤尚可,红虾太咸,莫照店家的吃法。

“你炼你的丹,有了新结果再写来。”

信封好,他在铺子旁买了一沓较厚的拓纸。

纸铺掌柜问他要多少,墨沉比了比两面石壁的宽度,嫌一卷太窄,便叫人把柜后那卷也取下来。

第389章 照这个价

陶恪来验丹那日,墨欢将两只玉瓶摆在长案上,特意把自己的旧瓶收进了屋。

那颗带浅痕的第一丹,仍留在旧瓶里。

从祝霞山寄来的信,就压在瓶旁。信到焚城时,封上的日期已隔了将近三个月,陈生写右臂添了浅口,又说当夜便要入关。墨欢看见“第二回若还把药烧去一半”那句时,气得把纸往桌上一扣,过了一会儿,还是重新拿起来读完。

他原想立即回嘴,拿起笔,却只写出一个称呼。

直到这笔生意有了着落,那页纸才又被他摊开。

如今,距陈生寄出那封信,已过了四个多月。

这几个月,墨欢仍接二阶丹单,却不肯将所有炉期都拿去赶货。手头攒够一份金汤水丹的料,便留出一段时间,仔细炼上一炉。青回炉的长火、细火渐渐用熟,他不必再为每次收火临时腾挪,终于能把更多神识留在药性相济的那一刻。

案上这两颗新丹,是前后两炉所得。

一颗金色稍深,一颗偏淡,表面都没有第一颗那样的凹痕。陶恪把两丹托在掌前,先看外皮,再以神识轻轻触过。

淡色那颗动了一下,丹面细光随即沿外围游走。

陶恪点头。

换到另一颗时,他另分出一线法力往内试探,丹面细光却在掌心上方一滞,边缘散出一点极薄的雾气。

“这颗不如那颗。”他道。

墨欢原本立在案边,听到这句,立即俯下身。

陶恪的法力带着微微火意,金色稍深的那颗遇到火意,外皮上的水光便向另一边偏。他没有强催,只等了一会儿,丹面仍没能完整转过一周。

“你留了些燥性。”陶恪道,“一颗照原价,这颗要减。”

墨欢没有立即答应。

他伸手将丹托回,神识慢慢绕过丹皮,再触向内层。昨日封瓶前还顺畅的那缕药气,如今确实在边上留了一点滞意。

“不是燥性。”

陶恪抬眼。

“你别拿火丹的法子催它。金汤水丹的金气在里,水意在外,先让它自己走。”

“我只是探了一下。”

“你的火意没收尽。”

两人看着对方,谁也没移开目光。

邵循坐在廊下磨一柄小刻刀,听见声音,把刀翻了个面,继续磨。

陶恪收回法力,将一块凉玉从袋中取出,放到案上。

“那就再看。”

墨欢没有借机往丹内送力。他把两颗丹一并放在凉玉上,停住手,等表面那点被带起的热意自行散开。

院中只剩磨刀的轻响。

片刻之后,深色那颗丹上的水光重新合拢,一线金色自内而外透出,绕过方才停滞的地方,终于完整地转了一周。

陶恪微微皱眉,重新探了一遍。这次他将惯常附在法力上的火意尽数收住,两颗丹的灵性便都有了回应,清润的药气并未从边缘逸出。

墨欢的下巴不自觉抬高了一点。

“我在炉前坐了这么些日子,还看不出来自己留下什么?”

“既然没留下,卖我就是。”

陶恪把两颗丹收入原来的玉瓶,依着先前议好的价,将灵石放上案。

墨欢数过,收钱的手比说话还快。

邵循在廊下笑了一声。

墨欢朝他望去,邵循把刻刀举到日光里,像是在看刀口,没理他。

陶恪并未立即走。

“下一批,我可以替你备料。”

墨欢抬起头。

“每月一颗金汤水丹,先供半年。成丹我都收,你也不用一份份攒药钱。”

“料钱从丹价扣?”

“自然。”

“成不了呢?”

“那份料钱照扣。你刚才不是说,在炉前坐了这么些日子?”

墨欢的脸微微一热。

陶恪将玉瓶放回袖中,道:“我也要算用药的时候。给了料,到日子却拿不出丹,我得另去找人。你若肯接,就按期来。”

墨欢伸手摸了一下刚收进袋里的灵石。

这一笔足够买下一轮主药,另有一些余钱。可陶恪要的不是六次开炉,而是月月拿得到一颗丹。若哪月失手,便得接着买料补炼。他原想留些灵石,试那几种从未摸熟的三阶药性,把时日答死了,这笔钱又未必动得了。

“先接一份。”他道。

“一份,你照旧自己买料,我只收成丹。”

墨欢皱眉。

陶恪已经转身,显然不打算替这一份另跑药材。

“等等。”

墨欢叫住他,却没有改口接半年。

“这两颗用得好,下一颗还照这个价。别到时候又拿火意试我。”

陶恪看了他一眼。

“你炼得成,拿来。”

他带着两瓶丹出了院门。

墨欢站在案边,看着人走远,忽然把刚才那张凉玉拈了起来。

“落东西了!”

门外传来陶恪的声音。

“给你。下回自己先验。”

墨欢握着凉玉,半晌,低声道:“又不是我不会验。”

邵循终于忍不住笑出声。

……

两日后,临潮的信到了。

墨欢先拆出一卷新拓,展开时,比他想的长出许多。他不得不把长案上的玉瓶全挪开,招呼邵循过来按住另一端,才看见那两面石壁的完整纹路。

邵循低头看了片刻,指向第四行。

“还真少了两笔。”

墨欢已经在读短笺。

笺上落款在两旬之前,墨沉写取拓那日回来睡了半日,后来又去岸边看过潮;他仍住在临潮听潮街,连鱼汤里放什么都要挑剔。

墨欢读到最后一句,嘴角动了动。

“叫你备好赌注。”

邵循把拓片往近处拉了一点,盯着那两个新露出来的短笔,半晌才道:“先前也没说赌什么。”

“你自己同他说的。”

“我只说拓回来看看。”

墨欢笑起来,又把短笺从头读了一遍。

傍晚,他将给陈生写了一半的信取出,把原来那张只留称呼的纸丢到一旁,重新落笔。

“你的信到焚城时,日子已经隔了近三个月。你说第二回再烧去一半便该肉疼,我读见了。前两日我卖出两颗新的金汤水丹,陶恪验过,按原价收了。第一颗带浅痕的还留着,没拿它充数。

“旧青纹炉拆化了,只留下一只炉耳。邵循的青胎炼成了青回炉,三阶,仍归他。我出料换了三年共用,不另给炉租,炉在他院中。灰白余焰暂伏在炉里的细火槽,我没再日日借它逐燥气。

“大爷去了临潮。我今日才收到他两旬前寄的拓和短笺,第四行原是分水,他看清了,两壁全拓也拿到了。信上说取拓那日出了力,回去睡半日,后来又去岸上走过,仍住听潮街。他没说哪日回来,我也没替他定。

“我还留在焚城,接着炼丹。你闭关不用特意出来回这封,出来后再说左肩养得怎样。右臂别只报止血,我想知道后来有没有好。

“还有,这两颗金汤水丹遇带火意的法力,外层水光会偏,散开后又能自行收拢。验丹时碰上,已经试过。我把经过另记在丹札,等你出关,再同你讲。别只答一句慢慢练。”

他读完,在末尾记了当日日期。

这时已是陈生寄信后的第五个月中旬。

墨欢没有把墨沉送来的这套新拓一并寄走,也没有将自己的旧丹札夹进去。他将拓片仔细卷好,收进屋中,随后拿着封好的信去了城里的传信铺。

收信处写广秀祝霞山,陈生。

掌柜核过去处,收了信资,把信装入待转的筒中。墨欢看着筒口封妥,才转身去隔街的药铺。

他已把下一炉的主药列在纸上。

到了柜前,掌柜问是不是还要从前那一份,墨欢先点头,又指了指高架上另一只小匣。

“那株也取下来。我看看,不是现在就买。”

第390章 养体一程

入关第十一个月,陈生把乌玄炉的盖子提了起来。

炉里已没有拳头大的赤石。

最后一小片日轮石伏在火底,薄得能透光,原先九道半合的光纹,如今只剩一道残弧。丹火沿残弧磨过,石片一点点软下去,化出一滴沉赤色的光。

陈生伸出右手,却没有立即接。

他的掌背已经不像初入关时,一催法力便浮出耀眼的琉璃光。温热藏在筋骨里,手指弯下,沿腕骨、肘弯、肩背往回走,直到腹中那尊小小的元婴睁开眼,才从掌心透出一层极薄的金色。

炉前那只废罐满了大半。

头两个月,失手时白白散掉的阳精最多。后来他已经知道哪里该先养,哪里得等血气回转,炉中的分火反而越来越细。有时一整日,只炼出米粒大小的一点,入体之后,要在旧路中走上许多遍,才能真正留下。

右臂,左臂,背脊,双腿。

一处处都有了变化,合在一起时,又不是简单相加。

第七个月,他原以为能够收尾,结果血气从双腿回到胸腹,竟将原先养稳的右肩顶得发亮。没有烧破皮肉,筋骨中的热意却散了一夜,耗去的那一份料,也没能留下。

他把准备提早出关的念头压回去了。

这一次,最后那滴赤光悬在炉口,陈生等了足足一炷香。

双腿中的温热归来,越过腰脊,胸腹间的血气才缓缓舒开。他在此时伸出手,让那滴炼好的阳精落在掌中。

热意钻入血肉。

陈生没有再给右臂多分一份,而是让它沿已经养过的旧路,先回到胸腹,再随下一回血气散往全身。

十万零八窍依次吞吐。

窗外风声停了一瞬,静室里却没有金光冲上屋梁。元婴法力经过筋骨时,原先总要添一股血气硬推的地方,如今只微微一热,便让它过去了。

那尊元婴的气息仍在初期。

只是这一次,它的法力往四肢散开,再归回来,肉身没有先亮起满身光华,去追着接住最后落下的一截。

陈生的呼吸渐渐绵长。

一周天,两周天。

炉口的赤光淡了。

他没有睁眼取下一份,炉里也已经没有下一份。最后一点石皮在丹火中卷起,落成细灰,被他顺手摄入废罐。

屋里暗了下去。

陈生将炉火尽数收回,单凭自己的法力继续运转。日熙之气随血气行走,神照体的琉璃光泽沉在血肉深处,一回比一回安稳。

到了天明,他才睁开眼。

手掌按住云床,整个人轻轻一撑,便站了起来。左肩没有再随着这一下牵出旧日的涩痛,脚下也没溢出大片赤色。

陈生在原地站了片刻,将全身法力往内一收。

筋骨随之承住。

这才是他要的。

他抬起手,指尖忽然扣住一角道一印。

印势刚成,便被他收回掌内,没有往墙上打。肩背与掌骨之间,那股重力来得齐,也退得齐,身上没有哪一处先绷住,再等别处补上。

陈生笑了。

若此刻再与二狗交手,胜负他仍说不准。那人元婴圆满,道一印又是亲手创的,知道的变化,比自己多得很。

但当初追着那一掌向前时,被回势反压住的肩背,已经不再是原来的肩背了。

他俯身拂净炉底的灰。

日轮石用尽了,法体的第一程却留下了。他将废罐封紧,把乌玄炉内壁重新擦净,放在石案上,终于腾出了一口真正能炼丹的炉。

陈生伸手去开静室的门。

门外的树,又长了半尺。

……

江明从侧屋出来时,还以为他只是出来取东西。

“今日要什么?”

“先要口吃的。”

“你总算记着我不是伙房。”

江明话说得快,人却已经回屋提起茶壶。小院里这段日子,他住得比陈生更熟,石案旁新添了一只矮凳,连门边哪块砖下容易积水,也被他踩过许多回。

陈生坐下,目光先落在他的剑匣上。

浅灰匣身,背脊嵌着一线暗金色,扣上时听不见剑器在里面轻碰。江明将它放下,揭开一线,青碧剑上的气息才往外透了少许。

“拿到了?”

“四个月,一天都没少。”江明道,“晏衡拿够余款,才肯让我带走。切下的甲片、用剩的羽骨碎料,也都还了。”

他在匣侧一按,青碧剑缓缓伏回,匣缝的青光收住。

三阶的匣。

不是当初那只临时拼出的长盒了。

陈生看了看,又看向放在旁边的旧剑。刃上那个米粒大小的缺口还在,剑穗洗净后,已经重新理开。

“旧的没补?”

“先把新的安稳放下,才能腾出灵石补旧的。”江明握住剑柄,“这口照样能用。”

陈生将茶杯放在案边,没有伸手替他把缺口抹平。

江明忽然向前凑了半寸。

“你真炼成了?”

陈生把右手伸给他。

江明没碰,先以神识探过一线。方才还显得很淡的气息,在他靠近掌骨时,忽然变得沉实,连自己的神识都像挨了一层暖而厚的玉。

他立刻收回。

“怎么还不如以前亮。”

“以前亮了,你替我接掌?”

江明笑出声,坐回去。

“那二狗回来,得先看这个。”

“他还没回?”

“去东南了。跟一位姓裴的道友走,回风峡。说是看潮,也说里头有能争的东西。我问他什么时候回来,他反问我,难道要替他留饭。”

江明从案上木盒里抽出一张短纸。那是二狗启程前留下的,去处写得清楚,回期却只算“一趟”。陈生亲自看过,才将纸重新压好。

陈生望了太平峰一眼。

出关后少一个能立即敲门的人,他心里空了一下,随即又笑。

“下回他带酒回来,你也这样问。”

江明伸手指向案上的一只木盒。

“你的信,都在那儿。几封是周显转来的,焚城那封到得早,我没拆。”

陈生将盒盖打开。

墨欢的字,他一眼认得。

信上写了收到广秀来信的日子,也写了两颗新金汤水丹已经交到买主手里。第一颗带浅痕的仍留着,新炉也没卖,三年共用的炉期,只肯拿自己能做出的丹换灵石。

后半页,又写了墨沉。

老人已抵临潮,取到了第四行的完整拓文,原先糊成一团的两笔,读出来是“分水”。取拓时出了力,回去睡了半日,后来又到岸上走过,寄那封短笺时,仍住听潮街。

纸上的日期,已经过了几个月。

临潮之后,墨沉又去了哪里,信上还没提。陈生将纸压在掌下,坐了好一会儿,才取出新纸。

“出关了。”江明问,“又准备走?”

“不走。”

陈生落笔,先写今日的日子,又写日轮石用尽、养体的这一程有了结果。墨欢在末尾问左肩,也不肯让他只报右臂止血,他便照实写肩头已不牵涩、前臂两处灼伤也已长好,又问临潮后可还有新讯。

这封不长。

江明看他写完,从壶中添了一杯茶。

“二十年,才过去这些。”

“所以还有得炼。”

陈生封好信,抬眼看向静室里的乌玄炉。

他从储物袋中取出三只封存许久的药匣,先放下最大的那只,再将自己在关中重新推过的丹方压在匣上。

纸页最上方,写着玄黄温婴丹。

四阶。

江明看见阶数,眉头动了一下。

“这是给谁炼?”

陈生将最后一只药匣放下。

“给我。”

第391章 先给自己

周显到祝霞山时,陈生已经把药匣全打开了。

院中不是一片扑人的药香。

最中间那只匣里,只有半寸厚的一块玉髓,紫色藏在里面,表层看着近乎乳白。旁边几株四阶灵药各自封着,叶片不动,药气也不肯向外散,唯有陈生的丹火贴过去时,才有一线柔光沿叶脉游走。

“这一份,从神都带回来的?”周显问。

“在那里攒的。”

陈生拿起玉髓,没有先往炉里放。

玄黄温婴丹的原方,他在守蔵室见过。养元婴、积法力,算是四阶修行丹中的一路。神都那些年,他也服过别的养婴丹,不至于连丹药怎样化开都没经验。

只是同一份药力,落到不同人身上,未必都往最想要的地方去。

自己资质平平,厚厚一层药气压下来,经脉中热闹,真正能留住的那一点,却总得慢慢熬。他不缺等的时间,也不肯因此把好药随便耗掉。

关中这一程养体,他已将原方重推过。

少取一味催散太快的辅药,将玉髓中那股沉厚之气留得长些,让丹力先随养生经归入元婴,再缓缓化散。现在的筋骨能承,他才敢把这一步做实。

周显俯身看了看方上两道改过的字,忽然伸手去拿旁边一只三阶药盒。

陈生先按住了盖子。

“做什么?”

“帮您备药。”

“帮忙就帮忙,怎么先挑最像原来那一味的。”

周显的手停在半空,过了片刻,自己也笑了。

“我看着熟,想先看您换在哪里。”

陈生松开手。

“今日把眼睛放炉里。你在这里拆盒,我还得分神看你有没有替我配回去。”

江明正在门边,听见这话,肩头抖了一下。周显朝他望去,他便把已经封好的信举了举。

“我送信去。药的事,没伸手。”

陈生让他把新回信交到山下传信铺,江明走了,周显仍站在炉边。

“这一炉,要炼多久?”

“没定。”

“我封山?”

“把近处的人叫远些。要看,站下风口以外。用不着把整座山都拦死。”

陈生将玉髓托进乌玄炉。

炉底原先存阳煞的那一圈火,已经撤得干净。新的丹火铺开,温度却没有一齐提上去,四周低,中间高,正好让那块药髓缓缓悬着。

周显退到了廊下。

江明当晚回院,说信已经交铺送走。炉里的药才开始温养,他在门边看了片刻,便把近处空着的椅子搬给周显,自己回了侧屋。

……

一日过去,炉中玉髓才化。

紫色的药液比别的都沉,陈生以水云柔控火法承着,让其他药性先从两侧靠过来。丹火像数道不相混的细流,药液落在哪一道,便在哪一道慢慢舒展。

第二日,院里的树影全被火光压淡。

周显只回来过一回。他在低处闻见一股清寒药气,以为火性出了偏,走近后才发现,那股寒意被留在炉顶,底下的紫色药液却仍在稳稳融汇。

陈生甚至腾出手,朝他摆了摆。

周显站住,没有开口问。

他见过祖师许多次炼丹。过去看三阶时,能辨出哪一味药刚刚化开;到了眼前,只知道每一股都在动,想盯着其中一股追下去,旁边又已经变了。

他握紧手里没来得及放下的茶壶,索性找了个离炉更远的地方坐下。

第四日午后,药液终于合成一团。

外头灰金,内里一线紫,尚未成丹,便有沉厚的元气往外推。乌玄炉轻轻震了一下,陈生没有立刻压住,先将手贴在炉腹,感觉那股力从哪一处回转。

他在原方中改过的地方,正在这里。

若像旧方那样,将所有药气一道收紧,丹衣会先凝,内中的沉气却仍要往外走。成丹未必失败,服下后先散出的那一层,也未必就是他想留住的。

陈生将掌中丹火分开。

金烈叩落在炉壁,短短三响,将正在外聚的药性轻轻震回;摄春之法随即展开,院边草木逸出的少许生机,被他牵到药团外沿,没有往紫色的中心硬塞。

炉内一阵光华流转。

他先让那股沉厚药气结稳,再把外层余气一丝丝收进去。

药团小了。

天上却暗了一角。

陈生抬起头,望见云中闪过一道银白,随即将手重新按在炉上。药还没收尽,他没急着起身,仍坐在炉前,先完成最后那一层丹衣。

山下传来几声惊呼。

江明已经回院,刚走到门边,便被周显抬手拦住。两人一齐看向上空,阴云正在向祝霞山收拢,雷光没有铺满整片天,却直直对着炉口。

“丹劫。”江明道。

周显点头,叫近处的人再退。

陈生在此时站了起来。

玲珑宝珠从袖中飞出,先落在乌玄炉上方,清光向下垂,将炉口与已经成形的丹一并护住。

他自己迈出两步,站在院中。

第一道雷落得极快。

银光贯进树影,陈生抬起左掌,掌心只亮出一层淡金,便迎了上去。

轰。

衣袖向后扬起,脚下石砖裂开了两道。他的手掌被劈得一麻,雷光却没能像阳翎隼那一线金光一样,先压住掌骨,再逼他仓促从肩背送来血气。

日熙之气已在整条旧路上。

筋骨承力,温热同时流过肩背与腰腿,余下的雷意沿皮肤游走,灼出几道极浅的红线,随即被自己的生机压住。

陈生将左手一收。

不是全无痛意。

他抬眼时,却已经在笑。

第二道雷比第一道更粗。

这回陈生没有只用肉身迎上。道一印从掌中升起,先将落雷最前面的重势截住,再向侧面一带,银白雷光在半空折开,余下的一截,被他用掌中血气接住。

衣摆边缘烧去了一角。

丹炉没动。

清光之下,那颗灰金色的丹正在收敛最后一丝药气,紫色内蕴,丹衣表面也渐渐浮出极细的光泽。

第三道雷落向炉口。

陈生伸出右手,将宝珠的清光往上一托。雷光撞上去,没有全被弹回,仍有几丝细弧沿光罩下行,触到了尚未收入瓶中的丹。

炉内响了一声。

不是丹裂。

那一线药气受雷弧淬过,忽然收回丹中,原本稍显浮动的光泽彻底沉了下来。陈生的右臂也在这一托中绷紧,肩背与腰腿同时承住回来的力,没让护丹的清光偏开。

他没有再将雷丝全部引向自己。

过了片刻,云里最后一点银白淡去。

院中仍有焦味。

周显先望了望炉,再望陈生,似乎一时不知道该问哪一件。江明却已经看见他左臂那几道红线,伸手点了点自己的袖口。

“你这关,养得倒挺费衣裳。”

陈生低头一看,笑着将烧坏的衣摆拂开。

“这回没费药。”

他回到炉边。

一颗玄黄温婴丹落在玉盘上,灰金丹衣完整,内里的紫色随着元气缓缓转动。神识探入,最先触到的不是向外冲散的药气,而是一股沉沉留住的温厚。

四阶。

他把丹收进玉瓶,合好盖子,才坐回案边。

周显长长吐出一口气。

“祖师,这方能给我看看么?”

“你方才不是已经看过?”

“没看明白。”

陈生将方纸推到他面前,指着那两处改过的字,让他把原先的走向也看清。周显没有立刻拿走,先在石案上用指尖写出自己看到的合药先后,写到第三处,便停住了。

陈生给他改了一处。

“其余的,等你自己看出来再问。”

江明在旁边托着下巴,忽然道:“你给自己炼一炉,倒比给至尊炼的时候笑得多。”

陈生手一顿,随后将玉瓶往自己这边挪了挪。

“那时候他等着用。今天我也等着用。”

“现在就吃?”

“先稳两日。”

江明看了一眼瓶口,终究没去碰。他如今攒着的两株三阶药,连一炉结婴所需都没齐,看这颗四阶丹,眼中也难免有一点羡慕。

陈生却已经把空药匣收回袋里。

这一份用过,再配下一炉,得重新筹药。他没有许诺先替谁都炼一颗,只将乌玄炉放好,叫周显把看丹所得带回去慢慢推。

两日后,左臂雷灼的浅痕已退了大半。

陈生坐在静室里,取出玉瓶,第一次服下自己按这副法体重配的玄黄温婴丹。

丹力并没有一口气涌向四肢。

养生经缓缓运起,温厚的药气先归胸腹,在元婴旁一点点化开。那尊小小的元婴睁着眼,将最先归来的法力接住,周身金光微微一盛。

仍是初期。

陈生没有停下来去数还差多少,照着丹方推过的先后,把剩下的药力继续化入。他的双掌落在膝上,筋骨中养成的暖意随着法力缓缓回转,窗外日色暗下去,屋内那一点金光却没有随之熄灭。

第392章 潮门开

回风峡这一行,要从陈生入关第九十八日说起。

那时,江明的新剑匣还没有炼成,祝霞山里的炉火也仍在燃着。二狗却已立在东南外海的舟头,看见了裴素岚所说的两股潮。

一股水色深黑,自北面压来;另一股翻着细白沫,贴近南边的长礁。两者相遇,海面中间没有掀起高浪,反倒凹下去一片,水声沉在底下,隔着舟板传进脚心。

“明日才会退。”裴素岚道。

她将舟停在一片背风的弯礁后,抛出两枚青铜舟钉。钉头咬进石缝,连接船身的索上依次亮起细纹,小舟才不再往外偏。

二狗抬头看向峡口。

两壁很高,中间只夹着一条黑缝。每过片刻,缝里便闪出青白的光,随后才有沉雷滚来。尚未退去的海水不断涌入,落到深处,像被什么东西从两边反复挤压。

裴素岚从舱里拿出一柄薄刃,沿刃背擦了擦。

“进去以后,先往右,过一道斜坡,再下到中间的石台。左边看着近,下面是空的,上回我站过。”

“两处矿都在石台?”

“一处在东壁,一处从台底穿过去。上回只露了外皮,今次水退得更低,能看见多少,要到里面再说。”

她抬起手,指向湾外那道弯礁。

“退出来也到这里。舟能撑一阵,撑不了下一股潮。”

二狗记住了位置,顺手压紧腕上的铜扣。

这是经过栖海渡时,他自己付灵石买的一副三阶护腕。铺里摆得更贵的那副,护光层层叠叠,戴上却连五指聚印都慢了一线,他试过后,选了眼前这一副薄的。

裴素岚看见他的动作,唇角动了一下。

“现在倒肯花钱了。”

“手打坏了,修起来更贵。”

“能算这笔账就好。”

她将薄刃收入鞘中,忽然抬头,望向高处。

二狗也看见了。

峡口右壁,一块凸出的黑石上坐着个人。灰袍,阔肩,面前横着一根尺状的法器,两端各有一道铜箍,尺身却黯得几乎不映天光。

那人坐得很低,先前被壁影遮住,此时站起来,元婴圆满的气息才越过水面。

裴素岚皱了皱眉。

“邢越。”

灰袍人低头,朝她看了一眼。

“这回找了同伴?”

“邢道友上回还说,水里捞这点东西,不值费力。”

“你送去卖的那点不值。铺里的人剖开矿皮,里头的东西,我觉得值。”

他提起黑尺,尺下的石面露出一道浅痕。那件四阶法器只是平搁了一夜,便将坚硬的礁石压下去半寸。

裴素岚道:“原来你也会看走眼。”

“所以这次自己来看。”

邢越说完,看向二狗。

二狗没有报出宗门,只道:“陈二狗。”

邢越的眼睛微微一动,手却仍垂在尺旁。

“陈道友,东壁的碎料多,足够两人取。我只要台底那条整的。”

“那条我也想看。”

“看自然可以。”

邢越将黑尺提到肩头。

“拿的时候,再说。”

他退回壁影里。裴素岚望着上头,半晌后,低声骂了一句。

“我卖一点,还给自己卖出个人来。”

二狗却笑了。

“你也没说,这里只准咱们两个来。”

“我没说让他来。”

裴素岚坐回舱前,将原先收好的薄刃又取出来,换了一条缠柄的绳。

二狗在另一头坐下。

这夜没有人再谈如何分矿。天色黑尽后,邢越所在的石台偶尔有铜光亮起,裴素岚则隔一阵便去看舟钉。二狗合着眼,掌心向下,一缕很浅的金光贴着舟头,随水势升落。

天将亮时,那缕金光忽然向前沉去。

二狗睁眼。

湾里的水,退了。

……

第九十九日,峡口露出了第一段湿黑的石脊。

风声一下变尖。

南北两股潮在湾外压到一处,湾内海水却沿底下的暗道向外泄。原先埋在水里的窄缝露出,峡中积着的风向着低处猛灌,碎石裹在里面,在岩壁上打得火星四溅。

裴素岚收了舟钉,将小舟缩起,放进自己的袋中。

“走!”

她先掠到右侧斜岩,二狗随即越过她,落在石脊最前端。

他答应过的第一击,已经来了。

一道裹着白沫的水墙从峡里倒撞出来,底下还有刚刚露头的礁石。风走上方,水压下方,两股力错着推来,要把站在入口的人从腰间折开。

二狗抬起右掌。

道一印在身前撑开,金光从掌根向外铺出,压住上面尖厉的风,再向下沉。裹在水里的石块接连炸开,印前海水向两壁冲去,露出一条丈许宽的路。

裴素岚踏着这条路往里走。

到二狗身旁时,她没有停,薄刃出鞘,削断了右边三条横悬的硬石棱。

“右转,别沿水走!”

二狗收掌半寸,正要跟上,峡底忽然响了一声。

先前被他压开的风从上方绕回,撞在另一股贴地的回潮上。一线余雷随水跃起,贴住金印右下角,金光顿时向内一凹。

二狗右肩一沉。

他把散开的印力一并收紧,脚下石脊却承不住,两块湿石同时碎了。身体向后坠落的刹那,他左掌拍在旁边岩上,借那一下冲力,斜斜落进裴素岚削出的缺口。

水墙从背后刮过去。

袖摆裂了一线,左膝也在石上擦出热痛。

裴素岚把薄刃插进前方石缝,撬落一块挡路的岩石,回头道:“脚下还有一次!”

二狗望见了水色。

他这回没有将印铺到两边,掌下金光收窄,压住正前方的一线水。身体随即跨过破石,任另外两股风从身旁冲走。

两人一前一后,真正进了峡。

内里的石台比裴素岚记得的低。

水退后,东壁露出灰黑的矿皮,长短不一的青纹埋在里面,有一条沿着石台往下斜走,伸进台底。风从上头横缝灌下,打在那层矿皮上,响起细密的金铁声。

裴素岚眼中一亮,立即走到东壁。

她手里的薄刃沿一处原有的裂隙送进去,先切掉外层空石,再用两根细钎向两边撑。拳头大的一块灰青矿石被她剥下,托到眼前,只看了一眼便收入袋中。

二狗落到台心时,邢越也从上方横缝下来了。

灰袍下摆被风扯去一角,黑尺前端的铜箍上还缠着半缕雷光。他走的是高处的另一条路,脚下同样并不轻松,却比二狗更靠近台底那道矿脉。

“这条归我。”

邢越将黑尺往下一插。

尺头扎进矿脉旁的石缝,整片台面随之一震。

二狗左脚踩住被震起的石片,右掌已经压向尺身。

“还没取出来,归得太早。”

黑尺上的铜箍忽然向下一转。

掌印与尺面相接,二狗本以为那股重压要从正面撞来,脚下的石台却先往左一斜。尺身在他掌下竖起,竟要沿着印侧,将他整个人抬离矿脉。

他五指一合,改压为扣,金印锁住尺身,右臂向下重重一沉。

石台轰然裂开。

邢越没有松手,黑尺另一端的铜箍也亮了。

同一根尺,一头往下坠,一头朝上挑,两股相反的力绞在金光里。二狗脚下一轻,后背随即撞向自上而落的风。

他抬左手接住身后风罡,右掌仍压在尺上。

这样一来,两手之间的印势全被拉紧。

邢越向前走了一步。

尺身贴着金印转过半面,边缘从二狗左侧肋下擦过去。护身法光被压开,衣料里立刻透出一道血色。

二狗撤出两丈,脚跟在石上拖出短痕。

那块被震开的岩层也在此时掉下,台底露出一截乌亮的长芯,只有腕粗,青色却远比外层的矿石深。

裴素岚一刃剖开另一块石皮,转头看见那截长芯,目光立刻停住。

邢越伸手去取。

二狗抹过肋下,甩去掌上的血,向前又走了一步。

“手快一些。”

他对裴素岚说。

“这里,得打了。”

第393章 印从身外起

裴素岚没有应声。

她一脚抵住东壁,将薄刃转了半圈。方才被她剖开的矿皮向外翘起,里面还连着一道青线,离得近了,能看见极细的罡气沿线游走。

台心的二狗却已到了邢越面前。

这一掌落得极快。

金光压住黑尺的下端,二狗人随掌走,肩头撞进尺身另一侧。邢越刚要向上挑尺,他的左手便从腰侧翻出,一印贴住那道正在发亮的铜箍。

两道印前后相接,黑尺第一次慢了。

邢越抬眼,右手在尺柄上滑过半尺,竟顺着二狗的推势,往东侧跨了一步。

脚下正有一道回水。

灰袍人踏在水上,身体低下,尺尾却抬高,借那一步将整件重器斜斜架起。二狗压在两端的金光随之扭转,原本相接的地方,被尺脊顶出一线极薄的空隙。

紧接着,一声沉响。

尺头的铜箍亮到极处,重力全从那条缝里挤了进来。

二狗左臂被震得抬起,护腕与尺棱相碰,铜面上立刻迸出一条裂纹。头顶的风同时落下,沿着抬起的手肘,重重打在肩背。

他踉跄半步,胸腹间气血一翻。

邢越已经向前压来。

二狗双脚钉住石地,两手同时合拢。道一印的金光骤然加深,向前冲起,把黑尺连同后面的灰袍人,一并推出数丈。

轰!

邢越的后脚撞碎半块突石,才站稳身体。

二狗也吐出一口血。

硬把两头合回来,确实能逼人退,可肩背吃下的风劲,也随自己那一下回收,压进了胸口。

邢越擦了一下嘴角,向旁边看去。

裴素岚已剥下第二块矿石。

她没有贪大,顺着矿质最连贯的地方下刃,将后面杂着砂眼的一截直接切掉。矿石入袋,薄刃立刻换了方向,刺进第三处。

邢越的黑尺向她那边一横。

尺前一道沉光掠过台心,将她插在岩里的两根细钎一并压弯。

裴素岚脸色一变,立即抽回薄刃,避开砸下的石屑。她站稳后,左手扬起,一串细小的青芒贴着尺背,直取邢越握尺的手。

邢越以袖扫开青芒。

袖口被削出三个小洞,他的尺也慢了一瞬。

二狗从这一瞬间抢了进去。

他没有扑向邢越,右掌向下,先将长芯外头还连着的石壳拍碎。

半尺长的乌亮矿芯露出了大半。

邢越眼神陡沉。

“陈道友的手,也不慢。”

黑尺转向,直砸他的右肩。

二狗左掌迎上,右手却继续往下,扣住矿芯旁边的一块石棱。金印向外一震,石棱碎裂,整个台底的缝隙随即扩大。

尺未落稳,邢越便改砸为扫。

他要二狗松手。

二狗确实松了,却没往后退。他贴着尺身往前挤,左臂上的铜护腕卡住尺棱,五指之间的金印借着这一下近身,朝邢越胸前重重压去。

邢越架尺来挡。

金光与乌光在两人之间炸开,周围水雾被打成了一圈极细的白线。

二狗掌根发麻,眼前却亮得厉害。

黑尺太重,邢越每次变势,都要先从一端收力,再把另一端压上来。两道铜箍替他把这点空隙连住,于是尺往哪里转,哪里的力便先到。

他再把印同时压满两头,邢越便总能从中间抬尺。

二狗忽然撤去左侧半道金光。

黑尺压下。

护腕上的裂纹骤然贯通,一片铜壳崩进水里。左腕裸露出来,尺边擦过,带走一线皮肉。

邢越没有料到他会先散印,动作却半点不迟。尺锋追着那只手横扫,另一端则扬起,要把二狗重新逼回落风的地方。

二狗低身从尺下穿过。

左手三指仍扣着一缕金光,金光连到尺侧,却不再正面顶住它。外沿散开的印力被风卷走,他把这道窄得几乎看不见的印意,留在了自己身后。

右掌同时向前。

邢越看见了新掌,尺身立刻回旋,铜箍往右一转,沉重的压势提前迎住二狗的去路。

二狗没有把右掌推尽。

他向左挪了一步。

右手的新印跟着人走,左手留下的那一点却停在原处,金光被拉成一条斜线。两处法力隔着风水强行相系,二狗的手指当即一颤,眉心也隐隐作痛。

慢了。

黑尺的沉光压到胸前,他只能侧身硬接,肋下的伤口重新裂开。

一股热血沿腰流进衣里。

他却终于看清,自己留在身后的那点金光,并没有随着这一下正面碰撞,一起回到身上。

邢越压住了他眼前这一掌,压不住他还未合起的后半印。

二狗右手一翻。

留在尺侧的窄印忽然抬高,新掌却沉了下去。两股力错开片刻,才从黑尺的侧面合到一处。

黑尺被推偏了一寸。

只一寸,邢越握尺的手便与胸口错开。

二狗迈步,一肘撞了进去。

邢越闷哼,胸前法光被撞得一凹,后脚离地,整个人滑向石台的斜边。他立刻将尺尾插入地中,拖出一串火星,总算停住。

二狗没有追那只握尺的手。

他回掌拍向台底。

剩下的石壳一层层剥开,那截乌亮长芯终于从岩里松了出来。旁边另一道石缝里,还嵌着一块短而厚的扁矿,质地略杂,却同样有四阶灵性。

二狗以法力托起长芯,向后收。

邢越的黑尺再次抬了起来。

他这回不再去碰二狗眼前的掌印,尺身从高处直落,连刚刚松开的两块矿石都笼在里面。尺下海水被压出一道深槽,藏在水底的余雷也跟着跃起,朝两边窜去。

裴素岚立即抽回正在采矿的手。

薄刃仍留在第三块矿石底下。

她看见矿已松动,索性向刃背加了一掌。岩层裂开,她把第三块带皮矿连同一捧碎粒收入袋中,抽刃时却听见清脆的一声。

刃口裂了寸许。

裴素岚脸色发冷,左手掐诀,一道青光抽向黑尺落下的右边。

“你再碰一下我的料试试!”

邢越的尺偏了一线,仍然落向二狗。

二狗却抬起了头。

他左手松开长芯,先在矿石前方落下一道窄印,右掌紧跟着向外压。前后两道金光没有急着连满,中间留着空处,容黑尺压进来半截。

邢越的目光一凝。

尺下铜箍转动,他准备从那道空处震散金印。

二狗却已跨过了自己先落的印。

人到尺侧,右掌向前,左手的三指忽然向内一扣。

两道印在他身外合上。

黑尺先被前印挡了一挡,随后便挨上从侧后方挤来的第二股力。邢越的尺头来不及抬,整件重器被压向斜下,轰然撞进先前裂开的岩缝。

二狗脚下的台面同时碎了。

他以法力托住自己,双臂经脉却像被绷紧的弦划过,左手三根手指一时竟伸不直。

邢越弃尺半瞬,抬掌直击他的胸口。

二狗早在等这一击,右手迎上,两掌碰到一处,谁也没有留力。

沉闷的爆响里,两人同时向后退。

二狗退向长芯,身形尚未站稳,袖子便已将它卷起。

邢越重落到外侧石壁上,喉间一动,吐出一口带血的气。他伸手拔尺,眼睛仍盯着那截乌亮长芯。

水声忽然变了。

石台下的海水不再向外泄,薄薄一层白沫贴着两人的脚底,开始往峡深处爬。

裴素岚收了弯钎,掠向来路。

“回潮了!”

二狗将长芯彻底收入袋中,转头看向邢越。

邢越已经到了那块扁矿前。

他用尺头一挑,连矿带石整个掀起,收入自己的袋子,随后向上纵身。黑尺托在脚下,他沿着来时的横缝冲去,灰袍迅速消失在风里。

二狗没有去追。

头顶一块数丈宽的石层被风抬起,又朝他们退路砸下来。

裴素岚抬刃想斩,看见刃口那道裂纹,手腕微微一顿。

二狗已经从她身旁越过。

他将发麻的左手收在腰侧,只以右掌落印。金光贴住石层的一边,向外一推,大石擦着两人身侧,砸入刚刚涨起的水中。

水花没过头顶。

二狗重新落到斜坡上时,两条腿都沉了一下,体内法力比入峡前少了大半。

裴素岚一把提住他后领,把人往右拉出半步。

脚下的空石随即塌了。

她松手,继续往前走。

“回去再看你的矿。”

二狗笑了一声,牵得肋下发疼。

“看过了。”

他把腰间的袋口按紧,追上前面的青衣。

第394章 矿归两家

退到峡口时,来时那条石脊已经看不见了。

裴素岚贴着右壁走,薄刃换在左手,右手则提着缩小的舟。只等看见弯礁,她便向前一抛,船身迎风展开,舟底两条青铜护条先撞上水面,发出一声闷响。

二狗比她晚了半步。

风从背后推来,前方的水又往峡内涌,两股力量在船尾相撞,小舟横过半个身子,向近处的礁石偏去。

裴素岚落在船头,将法力直送舟心。

船首抬起了,左后侧却仍擦上石角。

青铜护条断下一截,没入浑水。

二狗抬起右掌,金印压在船尾斜后方,硬生生把船推正。他收掌时,左肋抽痛了一下,嘴边又有一点血腥味。

裴素岚看也没看,立即驱舟沿弯礁向外疾走。

几息后,海面忽然平缓下来。

礁外的浪暂时分开,小舟借着这点空当,越过了回风峡外最后一道水脊。二狗扶着舱沿回头,身后的黑缝已经被白浪封住,先前露出来的入口,连同台心那些碎石,全沉进了水里。

船没有停。

一直驶到远处水色转浅,裴素岚才松开按在舟心的手。

她蹲到左后舷,摸了摸断去护条的位置。里面的船板还整,外层护光却薄了一片。

“这一截,白丢了。”

二狗在另一边解护腕。

左腕那只只剩下半圈,断口嵌进肉里。他把铜片一块块取出,最后一片带了血,便先搁在掌边,没有往海里扔。

裴素岚伸直自己的薄刃,看见刃口那寸许长的裂纹,脸色更不好了。

“姓邢的尺,最好也折一截。”

“他带出去的时候,还整。”

二狗这句实话,惹得她瞪了一眼。

他笑着取出药,先洗净腕上的碎铜屑,再把肋下衣裳剪开。那道伤没有深到骨里,只是方才接连催力,血又浸透了一层。他敷好药,束住腰,才靠着船舷坐下。

裴素岚收起裂刃,重新接过舟心。

二狗从袋里取出乌亮长芯,放在两人之间。

她也将自己收下的三块带皮矿与那一捧碎粒,一并拿出来。

海风拂过,四块矿石上都浮起了很浅的青光。二狗看着最中间的长芯,伸手摸过外沿,心里那点因法力耗去大半而起的疲惫,忽然淡了许多。

“这一条,我先挑。”

裴素岚用薄刃的刀鞘压住旁边滚动的碎粒。

“你挑得好。但这一条,肯定不止四成。”

“到栖海渡问价。”

“矿价一起问,器物各修各的。”

二狗把破护腕举给她看。

“没想让你赔这个。”

裴素岚将矿石分装到两个空匣里,没有立即按人分。长芯暂由二狗封存,三块带皮矿与碎粒仍在她手上,到了渡口再结清。

她随后压稳舟心,催舟向西。

……

六日后,两人回到栖海渡。

矿铺的柜面上铺了厚毡,掌柜看过长芯,又把三块带皮矿逐一照过,至于碎粒,他筛了两遍,挑出混在里面的砂石,才肯一起估。

裴素岚站在旁边,看到他指间又压住一粒,伸手敲了敲柜面。

“那一粒有铁,放回来。”

掌柜抬头看她,将那粒矿重新拨回毡上。

二狗站在门旁笑。

问过这一家,两人又去了街后另一家。三块带皮矿的价相差不大,长芯却差了许多,第二家愿意多添,是因为自家正要炼一件重器,想省去另找整料的工夫。

裴素岚就着这个价,与二狗算了半条街。

最后两人认下的总价,是十二万上品灵石。

长芯占六万;另三块带皮矿连同筛净的碎粒,也共按六万。

二狗的四成,只抵四万八千。

他在客舍石桌上补出一万两千上品,推到裴素岚面前。

裴素岚清点后收进自己的袋子,又将放在桌中的长芯匣推给他。

“这回才算你的。”

“现在看它,比刚才沉。”

“嫌沉,我替你拿去卖。铺子还等着呢。”

二狗立刻收匣。

“不用。”

裴素岚笑了一声,收走自己那三块矿、碎粒和灵石。两只匣各自封好,从此谁卖谁炼,都各由其主。

矿没有卖给铺子。

二狗选的那条长芯,正适合日后炼一件随印势而转的器。他暂时还想不清器形,更不愿先把唯一的整料烧进炉里,于是将它原样收在身边。

裴素岚却已经问过修舟价,又把裂刃送去验了一遍。当天傍晚,她付了定金,将舟留给船坊;那柄薄刃修起来不划算,她取回旧刃,另买了一柄短些的。

二狗把右腕仍完好的铜护腕收起,左边残片也一并收存,没有再去配成一副。

他想先把那一印打明白。

第三日,裴素岚来敲客舍的门,见他案上压着一张渡外海图。

“不回广秀?”

“再住些日子。”

“我十日后往南走,你若还要去回风峡,下一回的潮,得另等。”

“不去峡里。我去这里。”

二狗指向海图外缘一片交错的石礁。

裴素岚看了看。

“那里没有矿。”

“有水,也有风。”

她明白过来,没有再劝,只问他手指伸直了没有。

二狗抬起左手,三根指头已经能展开,聚力时仍有一丝颤。

裴素岚哼了一声。

“你那一下绕得远,若再慢一点,尺就打在胸骨上了。”

“所以得再练。”

她点点头,问清下次有事往哪里送话,便拿着自己的海图离开。

他到传信铺留了客舍的住处,付清代收转送的钱,又给客舍续了房,隔日就独自去了渡外。

十日后,裴素岚从船坊取回修好的舟,结清余款,带着自己的矿与新刃,自南码头离岸。二狗那天还在渡外的礁上,回来后,才从客舍掌柜口中听说她已经走了。

……

最初留在海上的那些日子,二狗把印打得很窄。

在峡里,他靠三指攥住将散的余力,险险合上第二掌。那一下赢了半步,三根手指也疼了十多日。再照样强合,先废掉的只会是自己的手。

伤口长好以后,他换了做法。

第一道印仍留在身外,却不再用指力死扣;第二掌催起时,他把支撑旧印的那一缕神识顺着掌势移过去,让先后两段法力从原来的窄核相接。人可以离开最初的落点,旧印也能跟着他转过有限的一段。

开头两个月,这段距离短得叫他自己都不满意。

一次,他想从浪头另一边接印,走到中途,先落的金光已经散了。后掌落空,正面涌来的水把他连人带衣打回礁上,方才写好的半页纸也泡了个透。

他坐在水里看了半晌,把那半页纸捞起来,晾在礁边。

第二日照样来。

后来,窄印终于能越过肩侧,二狗又开始把它放宽。

宽了,受风的地方就多,收合也慢。他索性把最外一层印势当场散去,只留真正能接回来的部分,再从另一边打进去。

每少留一分旧力,新掌便要多添一分。那些落进海里的金光,都是真正耗掉的法力;有时一日没打多少掌,他便得坐到天黑,等经脉里的空涩慢慢退下去。

他逐渐不再盯着浪面。

脚下哪一股水将要抬头,身侧的风往哪边偏,先落的印还剩多少力,他必须同时看住。目光追得到,神识跟不上,也一样会打空。

到陈生入关第十一个月后半,二狗已在栖海渡住了数月。

那日午后,一场急雨从海上扫来。

他站在渡外的孤礁上,右掌推出,金印横过前方水面,挡住第一道浪。侧面又一股潮撞来,他没有将印全撤回,脚下已经越过浪脊,左手在身侧向下一按。

前印的外沿散进风里。

留下的窄核却从身后斜斜抬起,与左掌新生的金光相合,重重落在两股潮将接未接的地方。

水面凹下去一片。

二狗穿过飞起的白沫,落到另一块礁上。身后两道金光随即收尽,前后没有牵住他的肩臂,也没有把下一股回水一起拖到自己身上。

他站稳,向来时的礁石看了一眼。

方才那一步,已经超过峡中与邢越争矿时的距离。

法力仍是元婴圆满的法力,胸腹也因这一掌急促起伏。他抬起手,却忍不住笑了。

再打那根尺,他想试试这一次。

傍晚,二狗回到客舍,先换下湿衣,再把一直封存的长芯取出来,看过封口,重新收好。

矿还在,手上的伤也早已长好。

他拿出纸笔,想起江明问过的那句何时回来,没有再写几日。

信很短。

“生哥:

“我在栖海渡,还要留些时日。回风峡去过了,与一个使黑尺的争了一场,拿到一条玄罡铁长芯,按说好的份子补了灵石,如今自留,没寄,也没炼。

“当时肋下和左腕见了血,早长好了。印上新摸出一段:前印可暂留在身外,人先换位,后掌再合,不必每回都把两股力拖到肩上才打出去。眼下能多转一步,还费力,我想练熟再回。

“境界仍是元婴圆满。你那二十年,我记着,别等我回来却还拿旧一掌应付。

“有事仍寄栖海渡,东街临水客舍代收。

“二狗。”

他写下当天的日子,将信叠好,亲自送到渡口的传信铺。

铺里向他问清广秀仙宗祝霞山的收信人,收了传信灵石,把封好的信纳入长途信筒。二狗等到阵光亮起、信筒离开架子,才收起自己的回执。

回客舍的路上,他经过一家卖新法衣的铺子,在门口站了片刻,进去挑了一件窄袖的。

这一回,他先抬手落了个空印。

袖口没有牵住手腕。

二狗付清灵石,把新衣带走,明早还要去海上。

第395章 来取赌注

从陈生在祝霞山入关那夜算起,已到第十四个月上旬。

墨欢收到新信时,邵循正把一片护臂内扣翻来覆去地磨。他手边另有几片未合的甲叶,长案被占去大半,墨欢想腾出个地方看信,推过一只木匣,又被他拉了回去。

“里头有薄片,别叠。”

“我没叠。”

“你手已经伸过去了。”

墨欢只好站着拆信。

封上的日子,已经在两个多月之前。陈生写养体的第一程做成了,日轮石用尽,左肩如今不再牵涩,右前臂练功时添过的两处灼伤也已长好。他还留在广秀修行,问临潮之后有没有新的消息。

墨欢看到“灼伤”两字,停了一下。

上封信还只说一道浅口,这回又有两处烧伤。他把纸从头看过,确认写的是已好,才缓缓折起来。

“小陈?”邵循问。

墨欢点头。

“那一块石头,炼尽了。说身上也养得比从前好。”

“他舍得给自己用,算好事。”

邵循说完,又低头去磨那枚内扣。墨欢站了一阵,忽然把信收进衣里,回屋去取药匣。

他今日要炼的,已不是金汤水丹。

真剂丹,三阶,冲关小境时能助一臂之力。

这一份药,他前后凑了四个月。最后一味主药,早先只敢在柜前看看,前几日才真正付钱带回来。墨欢已经将方子推过许多回,纸上有几处被抹得起毛,最末一页却没有写买主。

邵循见他取出那几只匣,抬了抬眉。

“不卖?”

“先给自己留。”

“陶恪上回不是又问你要金汤?”

“他要他的,我也有要的。”

墨欢将匣子放到炉边。

青回炉仍立在黑石上,右侧浅弯里伏着那一点灰白余焰。墨欢探过封禁,没有引它出来,又把那颗最初的金汤水丹连同旧炉耳一起收好,才将今日的药材铺开。

邵循磨完手中的内扣,把案上东西往旁边让了些。

“我午后还要合甲叶。别在这边摆满。”

“只占炉前。”

墨欢回答时,指尖已经燃起了丹火。

……

三日后,一条客舟缓缓靠上焚城外的渡口。

墨沉在窗边坐到缆绳系稳,才收起膝上的水路图。

在临潮和附近城邑又住的半年多里,他看了几次大潮,也去邻近的两座小城住过。俞淮练成了新的回潮走法,非要叫他再看一次;墨沉看过,挑出两处不好,直到对方把那句“你下去试”说出来,才笑着走了。

再后来,想看的看了,想争的也争过,他便买了回焚城的船票。

归途断断续续,走了将近两个月。这一回没有再往卖竹器的城里拐,他从舱窗望见城外那片熟悉的红土,先将挂在窗钩上的葫芦摘下,收入袋中。

船家过来取回舱牌。

墨沉把牌还他,提着一卷刚看过的原拓,走下了跳板。

岸边比离开时多了一排棚子,有人卖热粥,有人卖刚从河里捞起的银鳞鱼。他看了一圈,买下两尾,让摊主用湿叶包好,又买了一小包当地晒的菜干。

从渡口到邵院,还有一段路。

墨沉没有沿着图上新添的那条短线走。他在河堤上抬眼看了一会儿,转身绕过水湾,到了城外旧路,才将图收回。

院门前那几级石阶还在。

他走上去,手已碰到门环,却又停了一停,等胸口那一口气平顺了,才推门进去。

门没有锁。

院里先传来一声炉鸣。

墨欢坐在青回炉前,袖口卷得很高,两手隔着尺许,掌中丹火正向中间收拢。炉口悬着一团淡金药光,内里却有一点白亮,像被薄壳裹着,几次向外冲起。

邵循站在另一头,刚把几片甲叶扣到一起,听见门响,先抬了眼。

墨沉朝他举了一下手中的鱼。

邵循张了张嘴,又朝炉前看去。

墨欢也回头了。

那一瞬,他两只手仍稳稳停在原处,眼睛却睁大了一点,嘴边的话已经到了,炉中的白光忽然一涨,又将他拉了回去。

“大爷——”

“收你的丹。”墨沉道。

墨欢咬住后半句话,重新看向炉口。

真剂丹的药性已经合了,内里的锐气却还未全数藏住。他不能像温养金汤那样,把这点劲磨得太平,也不能任它一股冲出,先破了外层丹衣。

他屈指扣下,将最先冲起的一线药气压回中心,外围丹火随即一收,托住已经缩小的药团。那点白亮再动时,金色的外皮终于随之收紧,没有被撑开。

炉鸣低了下去。

一颗真剂丹落入玉盘,丹心白光隐现,轻轻一转,便伏在内里。

墨欢仍不敢立即分神,等最后一缕散气收尽,才将丹收入新瓶,塞好盖子,起身转了过来。

“你怎么回来了?”

墨沉正将鱼搁到案边。

“我不能回来?”

“我不是这个意思!”

墨欢走得太快,膝头碰了一下石墩,自己也没顾上。他到跟前,先看大爷的脸,又看灰衣袖上的几处水痕,忽然不知道该先问路,还是问别的。

墨沉把那卷原拓递给他。

“先拿着。湿鱼贴上去,字又要少两笔。”

墨欢接过,指腹握住纸轴,直到那点真实的重量压在掌心,才慢慢笑了。

“你走了这么久。”

“路上有地方可看。”

“临潮不是就两面壁?”

“两面壁外头,还有城。”

邵循在旁边哼了一声。

“回来了才知道解释。你不是还要等下一回大潮?”

“潮一回接一回,等得完么?”

墨沉拉过旧椅,坐下,抬眼看他。

“我来拿赌注。”

邵循手里的甲叶停了。

墨欢已经转过脸来,嘴角越抬越高。

“我同你说什么了?”

“你少插嘴。”邵循道,随即指着墨欢抱着的纸轴,“我让你拓回来看看,什么时候说过押什么东西?”

墨沉从袋里取出葫芦,往案上轻轻一放。

“那就现在定。”

“现在定,字你已经看清了,我拿什么赢?”

“那就算接风。原拓我都带回来了,你还能说我是空手回来?”

邵循果然伸手去拿。

墨欢把原拓在空处展开,又进屋取来先前收到的复制件,一上一下摆着。新旧纸色不同,第四行的笔画却清楚地对在一处,连细小水蚀都没有少。

邵循盯了一会儿,手指先点上面,又移到下面,终于没有找出可争的地方。

“我又没说拓错了。”

墨沉笑了。

“你屋里那坛青梅酒,一顿饭。”

“酒给你,饭叫他做。”

墨欢立即抬头:“凭什么?”

“鱼是他买的,酒是我出的,你就拿个空盘来坐?”

“我刚炼完一炉。”

“那颗你又不给我。”

墨欢顿时把新瓶往袖里收深了些。

墨沉在旁边看着,笑出了声。

“不要他的丹。两尾鱼,邵循烧。墨欢把桌子腾出来,等着吃。”

邵循望着他。

墨沉指向那坛酒所在的屋子:“这就算你认了,往后不提。”

“说准了。”

“说准了。”

邵循把甲叶收进匣中,起身去提酒。墨欢跟进屋,见他真从柜底取出那坛一直舍不得开的,连忙抱了三只杯子出来。

酒封揭开,酸甜的气味先散出来。

墨沉没有把酒全倒进自己的葫芦,只在杯里斟了浅浅一层,尝过之后,转头看向灶边。

“没你说的那么好。”

邵循正在翻鱼,头也不回。

“放下。”

墨沉没放,反而把酒坛往自己身边挪了一点。

墨欢坐在另一侧,终于忍不住笑了起来。

两尾鱼烧好时,天色尚亮。院里的甲叶已经收走,青回炉也被墨欢擦过,炉沿边只留了他的丹札。

三人吃过饭,邵循端着半杯酒,将墨欢赶去收碗,自己重新拿起那枚护臂内扣,边看边嫌灶火将袖子熏出了味。

墨沉把原拓卷起,收进袋中放着旧砚的那一层,又将椅旁的葫芦挂回腰间。

“我先前那间,还能住?”他问。

墨欢点头,随即又摇了一下。

“摆了两匣药。我搬出来就空了。”

“不用搬光,床上别摆就是。”

墨欢站起身,走到门口,忽然回头道:“陈生来信了。他那一程养体做成,肩伤也好了。”

墨沉抬眼。

“还在广秀?”

“还在。问你临潮之后有没有新消息。”

墨沉想了想,笑道:“有。他下回能看见信,我都回这里了。”

墨欢望着他,过了片刻,应了一声。

他没有立即去写。

屋里的药匣被他搬到柜下,被褥取出来晒在廊上。他重新腾好桌角,放回大爷惯用的那只茶杯,随后把自己刚炼成的真剂丹拿出来,站在日光下看了一会儿。

这颗不交给陶恪。

他将它与那颗始终留着的第一粒金汤水丹分开放妥,又翻出自己修行的旧札。丹成了,什么时候用,怎样接着炼气行功,他还要重新想过。

邵循在外头叫他,把那片压在丹札下面的铜片还回来。

墨欢低头一看,果然拿错了。

他把铜片送回去,正要回屋,墨沉指了指案上还剩的小半碟菜干。

“明早煮粥,搁一点这个。”

墨欢应下,拈起一条尝了尝,随即皱眉。

“咸的?”

“所以叫你只搁一点。”

墨欢看着他,慢慢笑了,将那碟菜干放到灶边,回屋去找自己的蒲团。

第396章 药与对手

陈生那一回入关,到第十四个月中旬,祝霞山的树叶才真正转黄。

玄黄温婴丹的药力早已化尽。

它没有将他推入中期。元婴腹中积下的法力厚了一层,再由胸腹流向四肢时,也不再有一股沉气迟迟跟不上。陈生照旧修行,偶尔出来,便在院中走一趟掌势。炉已空了,他没有因为能开炉,就把袋里的灵药一份份全倒进去。

这日先到院中的,是常安。

他腰间的剑鞘换了条系带,衣襟上有一小片焦色。进门时,他把一只窄袋放在石案上,袋里传出细微的沙响。

“东侧的事办完了?”陈生问。

“我替一位道友领过一段路,换了些凝火砂。”

常安将袋口展开少许。三阶的细砂聚在里面,微红,却没有扑人的热意。

他仍是金丹。前些日子在东侧诸峰练剑,追一股贴石行走的火罡,顺便给外来的修士指明了绕开青木密处的路径。东西已经换到手,他想用在自己的剑器上。

“人还在山外。”常安道,“他想见您。”

陈生看了眼他衣襟。

“把你烧成这样的人?”

“不是。我自己追早了。”

常安把袋子收好,又道:“他说想找一个真肯出手的对手。我听他说火法,想留下看看。”

陈生没有先替客人作答,让常安将人领来。

来人身形瘦长,眉尾下垂,站着时仿佛总在低头看什么。他向陈生拱手,自报陆闻溪,元婴中期,平日往来于外海与内陆几条商路。

他的火意收在指掌之间。没有露出赤焰,衣袖边缘的空气却微微扭了一下。

江明正在侧屋擦剑,听见元婴客人来,干脆提着旧剑出来。

陆闻溪看见陈生,眼神先亮了些,随后又往他掌骨与肩背看了看。

“我在神都听过道友的名字。”

“听的是炼丹,还是打架?”

“前一个多。路上又听说,你在赤乌岭取了日轮石。”

陆闻溪说得直接。

“我练转炎诀,有几处火势要离身转回来。寻常元婴愿与我论,真打的时候,却往往先护法器。我想找个身上承得住、又不只站着承的人。”

陈生看向他的手。

指间那一点扭曲,正在往外移。陆闻溪没有抬掌,悬在案旁的一片落叶却从中央焦开,又被两股向外分走的热意扯成了两半。

不是单纯将火往外推。

“你这几处,想问我,还是想试我?”陈生道。

“先打一场。问得太明白,下手便想着答案,未必还打得出来。”

江明将旧剑靠在腿边,笑了一声。

陆闻溪转脸看他。

“你笑什么?”

“他等二狗回来,也等着打一场。”

陈生没接江明的话,反而问陆闻溪:“你走商路,带药么?”

陆闻溪停了一下,从袋中取出三只窄匣。

每只匣里有一段紫纹药髓,另封着一株四阶辅药。药髓的沉紫色藏在深处,陈生看过第一段,便将第二只也打开了。

他还缺几味普通辅药,可以从紫令堂的旧药路补。这三段主料却出自同一处,温厚,少杂,能省去以后许多调配的工夫;难得的是眼前这一味。

“这些是我从外头带来的。”陆闻溪道,“边地没有这样的三匣。我自己也能用。”

“开价。”

“三匣,九万上品。”

陈生合起一只匣,想了片刻。

“有了药,今日这场我更想打了。”

陆闻溪抬起头。

“你要拿它作彩头?”

陈生取出一个灵石袋,推到案边。

“九万。你赢,拿走这一袋;我赢,拿走三匣。只这一场,输了不添第二个赌注。”

陆闻溪没有立即点头,先将药匣又拉回半寸。

“道友可想清楚了。我是中期。”

“你方才也看过了。”

“我不压到初期。”

“我也没叫你压。”

陆闻溪的手终于从匣上松开。他探过灵石袋,又看陈生一眼,忽然笑起来。

“成。比干问掌骨有意思。”

陈生将三匣与灵石袋一并收拢,叫江明去请周显来作个见证。

江明提起旧剑,走到门边,又回头。

“你连丹雷都接过。今日若输了,我替你藏哪一段?”

“先藏你这句话。”

……

祝霞北侧有一片无遮拦的石坡。

两人沿石坡往上走,到了草木渐少的地方,才停下来。周显收好药匣与灵石,站得远些;常安却又往上走了几步,想看清陆闻溪如何使火。

江明拉了他一把。

“先看第一下,再往前凑。”

陆闻溪以指尖划过石面,火意绕出一圈浅白的痕。圈不小,给两人的法器与身法留足了地方。

越出这圈算退场,开口认输便停。不打生死战,却也不将招法先拆成几句客气话。周显听二人说定,自己退到了坡下。

陈生没有取乌玄炉。

他将铁剑悬在身侧,玲珑宝珠收在袖中,抬手时,肩背的温热已经随着法力展开。对面陆闻溪取出的,却是一件赤色环轮,轮心空着,沿缘有四道向内回扣的纹。

四阶法器。

陆闻溪托着环轮,问:“丹火也用?”

“我会的,为什么不用?”

“我以为你要单凭身子争个名声。”

“名声先放一边。药在周显手里。”

陆闻溪笑着将环轮放开。

第一道火没有冲向陈生的胸口,而是从他脚边绕了过去。

赤轮中又分出一道,压在那一道上。

石面一点点发红。

陈生往右移步,脚下热意随他移动,背后的风却忽然停了。他没有回头,先把铁剑向后斜送,剑锋刚碰到一层无形的薄火,陆闻溪手中的第三道炎流便已经抬了起来。

前面,后面,脚下。

三处来势并不一齐到。

陈生的左掌在此时扣住道一印,右手却没有立即推出。他望着那只仍留着第四道纹的赤轮,心里原先那点只想试试筋骨的念头,也收了起来。

对方还有一处没动。

他想赢的,是这一整场。

第397章 赢来的三匣

第三道炎流没有落下来。

它在陈生身前三尺处散开,化作一片横着走的红光。脚边先到的火却在此时向上翻,原本只有薄薄一层的热意,陡然叠成了厚浪。

陆闻溪要他先接这一下。

陈生左掌推出,道一印将火浪压住,右手已向身后回扣。铁剑从薄火里退出来,剑锋带着一点暗红,他没有让它再往回绕,直接将剑摄到自己前方。

身后的那股火便乘隙贴近。

玲珑宝珠从袖中升起,清光挡在背后,烧来的红光沿着光面一折,竟没有立即散去。

陆闻溪抬起两指。

前面的横光骤然往回缩,背后那股红光则越过宝珠边沿,顺着陈生护身气息的外侧往上走。两道火要在肩背上合。

陈生没有等它们合拢。

他向前跨了一步,左掌不再向下压,反而将已经截住的那股火向外送开。日熙之气同时走过肩背与腰腿,道一印的重势沿掌骨透出去,石坡上的赤色骤然断了半尺。

那半尺,足够他从两股火之间出来。

陆闻溪目光一凝,第四道纹亮了。

赤轮离手,迎着陈生的胸腹撞来。

这一下不再只取外层的护身气息。轮缘旋过,四股炎流往内一扣,空气像被一只极热的铁箍收紧,连陈生前行的脚步都被拖慢了。

中期的法力,压了过来。

陈生举起右掌。

掌心的金色很淡,筋骨中的温热却已经在那只赤轮到来前归齐。道一印迎上轮缘,轰然一声,石坡上碎石飞起,陈生的鞋底往后擦了两步。

他承住了。

一片热痛从陈生右掌边沿烧向腕骨。那只轮不肯就此退开,陆闻溪压着两道回扣的炎流,继续往前送。

陈生没拿血气一直与他顶。

宝珠的清光从背后转来,先托住赤轮的一侧。铁剑贴着轮外翻起的火,去削陆闻溪重新牵来的那条细线。

火线断了,赤轮仍转。

陆闻溪不惊,反而将那一条断火放出去,另外两股从石面抬起,绕向陈生的肋下。

坡下,江明看得眼睛都没有眨。

他在河口遇到的是刃和剑,眼前的火却能先伏在一旁,等人出手,再从另一处长回来。常安掌住剑鞘,盯着地面上两道并不相接的红线,方才想走近的脚也停住了。

陈生看见了那两条线。

陆闻溪每次换炎流的去处,轮缘都要亮一瞬。四处火势,看着各自走,却仍要经过那一瞬,才能从旧路转向新路。

若只追着火末去截,便一直慢在他后面。

陈生退了半步。

退得不多,离白痕还远。他将右掌中的重势收回,左侧的护身气息却故意留得稍厚。陆闻溪刚将赤轮斜压下,便看见那一侧已经先亮了起来。

他顺势牵火。

两条炎流一高一低,正要往那片亮处走,陈生掌中忽然多了一缕丹火。

火很细。

没有去扑陆闻溪的胸口,也没有想将赤轮炼坏。它沿着陈生自己留下的护身气息往外走,过了两道炎流之间,才骤然分开。

陆闻溪的眉尾抬起来了。

那两股炎流各被碰过一点。丹火的量不大,碰上后便被他的火性压散,可他正要合起来的那一下,还是迟了。

陈生在这时收起左侧的亮光。

两股失了同一落点的炎流,从他肩旁一前一后掠过。

他没有立即追击,法力裹住剑脊,将铁剑斜架在赤轮外侧。轮缘撞上剑背,剑身一颤,陈生的右肩却没有像当初与二狗切磋时那样,先被回势压住。

全身的承力已经到了。

他借着这一撞,向左踏开,掌势从赤轮旁边穿了过去。

陆闻溪立即后撤。

道一印落在他刚才站过的地方,石面裂成了三块。陆闻溪袖口被余势扫中,半边袖子向后扬起,几条伏在地上的火线也跟着抖了一下。

“好。”

他只说了一个字,赤轮已经回到掌前。

方才散开的几处火势没有全收,仍留在石坡四侧。他不再一处接一处去试,而是把环轮向前一推,四道纹齐齐亮起。

红光从四面合来。

陈生身前的风先热了,衣摆边缘卷起一点焦色。陆闻溪立在对面,掌中法力持续送出,不给他再各碰一线的空隙。

他要拿更厚的法力,把那点错开的时机压平。

陈生也不再分出那一缕细火。

玲珑宝珠抬高,清光承住自上方落下的重热。铁剑压着左侧一股,他自己往右侧那一道走。

那一道离陆闻溪最近,火也最厚。

陈生将掌落进去,神照体的光泽在臂骨里一闪。他的皮肤被灼得发红,肩背与双腿却同时承住了从掌前逼回来的力。

丹火这才重新吐出。

不往外追陆闻溪的火,而是附在自己掌边,随着这一掌一齐向前。道一印的外沿将厚火撑开,细火又从裂开的间隙穿过去,碰上正在轮缘回转的炎流。

两处火势的合拢迟了半瞬。道一印的重势继而推上,赤轮偏了一寸。

陆闻溪握轮的手向内一扣,要把这一寸重新拉回来,另外三道炎流却已经在同时回转。他没法只把这一处收紧而不顾其余三处,掌中的法力骤然分成了两截。

陈生就在那两截之间欺近。

陈生的左掌从腰腿带起一股整齐的重势,穿过收得稍迟的薄火,直逼陆闻溪胸前。

陆闻溪没有硬拿胸膛接。

他向后急退,赤轮横过来,在自己身前挡了一下。掌与轮没有真正相撞,陈生便已将重势收住。

陆闻溪的脚停在白痕外。

四处红光缓缓退下去。

赤轮还在转,他低头看了眼脚边,又望向陈生,过了片刻,笑了一声。

“你不接我最后一下?”

“我来拿药,不来替你把每一处都试完。”

陆闻溪哼了一声,手指按过轮缘,终于将火收净。

陈生的右掌微微发麻,腕侧留着一片红。他运了两回血气,才将浮在表面的灼意压下去。

衣摆烧了窄窄一边,铁剑没有缺,宝珠清光也已经收回。

周显捧着三匣药走上来。

他先给陈生看封禁,又把九万灵石原袋放在旁边。陆闻溪看过,没有伸手拦,自己的赤轮则已经收入袋中。

三匣药,归陈生。

那一袋灵石,也原样取回了。

江明蹲在白痕边,看了眼陆闻溪留下的脚印。

“最后半步,挺贵。”

陆闻溪望着他。

“下次你替他付?”

“我还缺药。你若也有三阶的,我另买,不上来。”

陈生正在合药匣,听得笑出声。

陆闻溪没有顺势约下一场,只向陈生道:“你碰我火的那一下,靠的不是火更烈。”

“你若要我把那一段全讲完,得另开价。”

“先欠着。不再欠药。”

常安却指着石面上渐渐暗下去的一条红线,问他为何前两回从低处走,最后才抬高。陆闻溪看了一眼,伸手在旁边划了一条短痕,让他先看两处来路的距离。

陈生将自己的伤手收进袖中,站在旁边等了片刻。

太阳往西移,两人的短话已经说完。他让江明与周显先回院,自己则捧着三只窄匣,沿石坡慢慢下去。

……

案上另压着一封东南来的信。

二狗在第十一个月后半寄出,陈生这几日才收到。信上只说与一个使黑尺的争了一场,拿到玄罡铁长芯,补了另一人的份价;矿自留,没寄,也没炼。

肋下和左腕见过血,早已长好了。

末尾几行,才是二狗真正想写给他的。

前印可暂留在身外,人先换位,后掌再合。如今能多转一步,还费力,想在栖海渡练熟再回。

二十年,他还记着。

陈生用那只尚有红痕的手,将信翻到末尾,又看了一遍“别等我回来却还拿旧一掌应付”。

他将三匣药放在信旁,没有立即拿出丹炉。

明日先补缺的几味辅药,再按自己的法体重排一回。

二狗带着新一掌回来时,他也要有自己的东西。

第398章 一粒黄芽

回山第三年,秋雨来得早。

周显上祝霞山时,衣角沾着湿泥,手里却没提伞。他一路护着的,是一只贴身收起的玉盒,进了院门,才把它取出来。

江明正在檐下理药。

药匣摊在膝前,里面分作两格,都垫了养药玉泥。矿中带出的火神草还剩原来的两株,养了这些年,根须比当初舒展,仍未被他炼成丹。

他看见周显的玉盒,先笑了一下。

“最后那一味?”

周显点头。

江明的笑便淡了些,伸手接过去,打开看。

一株白露芝,芝盖边缘呈淡银色,根下留着完整的三层细丝。药龄与灵性都到了,连取药时沾上的一粒黑土,也封在盒角。

“谁的价?”

“南路药行。比去年报的多了两成。”

“你也买?”

“去年那株,根是断的。”

周显取回玉盒,向静室走去。

江明看着他走过院中的石案,过了片刻,才将自己的药匣合起来。

“我也进去看看。”

周显回头。

“看可以,别说我买贵了。”

“已经说过了。”

……

陈生将那株白露芝放到一旁,再看其他的药。

周显带来的东西,占满了半张案。有些是很早以前存下的,封口上有经年留下的浅色;有些刚从外头换来,底下的药签还新。

添上这一株白露芝,黄芽服气丹的一份药料,终于齐了。

陈生一件件验过,问了几处存药的年份,又让周显伸出手。

金丹圆满的法力走过一周,沉稳而凝练。比起刚回山时,周显没有再把它往外压,连最细的一段回转,也能收在腕内,不溢出半点。

“什么时候想定的?”陈生问。

“您回山之前,就想。”

“我问的不是想结婴。”

周显把手收回,取出另几片玉简。

“去年冬末。这几处过去了,我就定了。”

玉简里记的不是新丹方,而是他自己的修行。神识收放时何处最容易涣散,法力从金丹中全数提起后,多久能平复,还有一些没有写全的念头,删过又添,笔迹很重。

陈生看了一会儿,停在最后一片。

周显没有等他问,自己道:“最难受的那一回,闭目便想到若是碎了丹,却什么都没结出来,往后连今日这点修为都保不住。”

江明坐在一旁,没有笑。

“后来呢?”陈生问。

“后来还想。只是能把该走的一周走完,不会一想到这里,法力便停了。”

周显低头看向那一案药。

“我再多等十年,也还会舍不得这颗金丹。”

这话说完,他把最后一只药匣推了过去。

“祖师,这炉请您炼。”

陈生没有立即去拿炉。

“你自己也是三阶丹师。”

“我能开这个炉,未必能把它收得这样好。只有一份,我不想在这里争。”

周显答得很坦然。

“等结了婴,我再炼给您看。”

陈生抬头,笑了。

“这句话先记着。”

他又问:“在哪儿冲关?”

“伏泉山。”

周显摊开一张舆图。地方在宗外南面商路旁,借两站短程传送,再走半日便能到,不必远去神都。

山中那几座聚灵洞府,已经经营多年,平日供过路的高阶修士暂住。洞底蓄灵池积下的灵气,连同从外阵引来的供给,比广秀寻常静室浓厚得多;真要拿来冲婴,仍得另添灵石。

“我上个月去过一次。”周显道,“挑了最上头那间,订下三个月。他们把同一条灵路上的小室空出来,租金也算到我头上。”

江明听到这里,坐直了些。

“租一间,付几间的钱?”

“不空出来,临到我需灵气时,再和别人争?”

周显取出订房的玉牌,又将一只扎紧的灵石袋放在旁边。

“这袋极品灵石,是我另备的。洞里的蓄灵若不够,便全添进去。”

陈生看了看玉牌上的日期。

药、地方、修行中那些迟迟不肯放下的念头,周显都已自己走过。

他把玉牌还回去。

“去的时候,我同你走一趟。”

周显的肩膀松了一点,随即拱手。

“那山外,就请祖师替我看着。”

“还有我。”江明道。

周显望向他。

江明将那张舆图拉近,在伏泉山旁边另一条商路上点了一下。

“我也要去那边看药,正好先看你。”

周显笑了一声。

“你若找着,别等我出关来抢。”

“放心。我拿得动就先拿。”

……

当日傍晚,乌玄炉在祝霞院中升起丹火。

陈生曾在神都炼过黄芽服气丹,入了四阶丹道以后,再回头处理其中的药理,许多当年须得凝神的交接处,如今已经能随手接过。

周显却看得很紧。

那株白露芝入炉时,根下的细丝一点点融成浅白药液,他原本搁在膝上的手指,也跟着收了一下。

江明看见了,没有叫他放松。

换作自己的药,他只怕还坐不住。

到了深夜,炉中一团黄气渐渐收圆。几味药原先各有明暗,此时却全压进那一点灵光里,连溢出炉外的丹香都淡了。

周显站起身。

天上的雷云,也在这时合了过来。

陈生把两人留在檐下,自己走到炉前。他先护住丹气,再以掌势击散落下的劫雷,乌玄炉在剩下的细碎电光里响了数声,随后便安静下来。

一颗黄澄澄的丹,浮出炉口。

陈生将它摄入玉瓶,验过药性,等浮热退去,才递给周显。

“这一颗,收好。”

周显双手接过。

玉瓶不重,他却握得很稳,隔着瓶壁看了好一阵。

案上的一份药材已全进了炉,眼下只剩这一颗黄芽服气丹。它到了周显手里,陈生便收起丹火,将空炉重新封住。

江明伸手碰了一下瓶底。

“我也想有一颗。”

周显把瓶收回自己袖中。

“这一颗,先让我拿走。”

江明看着他的动作,忽然笑了。

“怕我抢?”

“你方才说的,拿得动就先拿。”

檐外雷声已经远去,雨又落下来。三人都没有再去看天,周显将装丹的玉瓶贴身收妥,先向陈生行了一礼。

……

十九日后,伏泉山。

周显站在上层洞府门前,将剩余租金当面交清。管事收了灵石,撤去门外一面暂封的木牌,带三人看过蓄灵池与供灵槽。

上个月订下的几间小室都空着。

周显沿洞底走了一遍,亲自感受池内灵气,再看过外槽与内室相连的位置,才接下这三个月的控府玉牌。

管事退走后,江明把那一袋专备的极品灵石放在外槽旁。

周显道:“若供灵见缓,全投。不必给我留着回去花。”

江明将袋口的结松开,重新扎成容易抽取的活扣。

“知道了。”

陈生在山腰停下,没有进内室。他先看过远近几道山路,站在一处能望见洞门的石坪上,向周显点了点头。

周显转身入洞。

他在这十九日里重新养足了法力,神识也收得很静,此时却仍没有立即打开丹瓶。

洞内只有一张石榻,一面临水的窄窗。

他将自己惯用的佩剑放在右侧,又取出此行特意买来的三阶护身铜盘,放到左边。剑与铜盘的气机,他来之前都已养熟。

窗外云影流过,池里的灵气缓缓往上升。

周显坐在石榻前,将袖中几片玉简取出,最后看了一遍,随即全数收起。

半日过去,他的手落到玉瓶上。

瓶内那一点黄光,仍然明亮。

他旋开瓶口,将丹倒进掌心。

第399章 雷过之后

黄芽服气丹入口时,微凉。

到了腹中,那点凉意便散了,换作一股极其饱满的生机,顺着经脉奔涌而去。周显收起空瓶,闭目运法,将药力一点点引向丹田。

金丹在那里缓缓转动。

它圆满、坚实,曾支撑他度过了许多年的风雨。法力每从其中走过一回,便会变得更沉,连同气息、神识,都牢牢系在这一颗丹上。

周显第一次将法力从四面压回去时,它仍然稳固。

第二回,金丹上浮起一层细光。

他不再顺着旧周天散力,而将引来的药力和自身法力聚到同一处,凝成火意,向着那层圆满的光里烧去。

一声极轻的裂响,落在神魂深处。

周显的脸顿时白了。

仿佛有人隔着血肉,缓缓抽走了支撑身体的骨。他想抬手,又觉连抬手所需的那一缕气,都变得飘忽起来。

金丹上的裂缝还很小。

此时停下,兴许还能勉强封住。

他睁开眼,看见石榻旁的空瓶,瓶身映着自己毫无血色的脸。

周显伸手,将空瓶移开。

火意再起。

更多裂痕从金丹表面浮现,裂口相接,一直包裹在里面的精气顿时涌了出来。经脉里的法力随之奔走,连血气都往丹田深处落去,刚才还能算得清的周天,一下乱了。

周显闷哼一声,嘴角有血。

他将按在膝上的手收紧,仍然往内压。

那颗金丹,终于碎了。

……

洞外,蓄灵池的水面向下一沉。

江明抬起头。

从池里升起的灵气不再缓缓飘荡,而是被洞中一股力量直接卷走,石壁上的湿意都随之变淡。供灵槽内,几条光纹逐渐露出断续的空隙。

他没有往门上拍。

周显交代过的那一袋极品灵石,就在手边。

江明解开活扣,将里面的灵石全部倒进外槽,推下铜柄。槽口合上,浓厚灵气从石中升起,沿着内外相连的路,源源不断送入洞底。

最后一颗也落进去时,他把空袋翻了过来。

没有剩下。

山腰的陈生望向上头。

他能感觉到,那股原本金丹圆满的气息,正在往下落。落得极快,像一片厚云忽然被从中心掏空,周围的灵气全在朝着里面填。

陈生立在石坪上,目光始终没有移开洞门。

……

周显听见了外头供灵重起的低响。

他此时却分不出神去看。

丹田中,碎去的金丹再也撑不住旧日那一周法力,诸般精气散在其中,明明浓厚,却找不到能立住的地方。周显试着以熟悉的凝丹之法将它们收拢,才合了半分,神魂便一阵剧痛。

那条路,刚刚是他自己烧碎的。

他停了一息。

洞外的灵气仍在来,若再这样散着往身体里灌,先毁的便是经脉。

周显将心神沉到最深处。

从前他炼丹,最怕药性相冲,总想着先把彼此抹平,照定好的次序收圆。此时却只剩自己,一身修行、经年所见,还有明知可能回不来,仍然亲手碎掉金丹的那一点心意。

他没有去找一颗新的金丹。

那些溃散的精气被他一点点引回,在神魂深处的观照下,先定住最微弱的一点,再围着这一点,聚起新的形。

每凝住少许,便有一部分外来的灵气真正留了下来。

周显的呼吸重新清楚。

他看见过的人,走过的路,也从纷乱的痛楚里浮起来。筑基时的自己曾抱着丹炉下山,逼着各宗天骄来与他厮杀;那时要得金丹,眼前的这一颗却被自己毁了。

他仍然想往前。

身后的路已经足够长,足够让他知道,今日若能睁开眼,他要去的地方还有很多。

丹田内那点细小的光,忽然动了一下。

有了手足,有了轮廓。

周显压住胸口翻涌的血气,将最后一股药力送了进去。微小的身影逐渐清晰,眉目竟与他自身相似,端坐在重新归拢的法力中。

元婴睁眼。

洞中的气息骤然向外展开。

山腰上,陈生抬起头,脸上终于有了笑。

天色却在此时暗下来了。

……

周显从洞中走出来时,袖口已经湿透。

他抬眼看天。

低云压在山峰上,里面有细长的雷光一闪一闪,照得周围石壁忽明忽暗。云层还在积厚,雷声从远处滚到头顶,震得胸口发闷。

他踏上洞外的宽岩,将三阶铜盘祭起。

第一片雷落下来。

铜盘上的纹路全部亮起,在他头顶撑开一层青光。电芒打在上头,青光向下凹去,周显体内刚刚凝成的元婴法力也随之一震,沿臂而出,补进铜盘。

盘身猛地稳住。

周显却没有欣喜,只低头看了一眼自己的手。

这一股力量,比金丹时厚得多,真正送进眼前这件三阶法器里,却也比旧日难收。稍多一分,盘上的禁纹便开始发烫。

第二次雷落,他先把法力压细了些。

青光挡住正面,另一道细弧却绕过盘沿,打在右手剑鞘上。鞘口烧裂,周显拔剑一斩,将追来的雷线从身前截断。

剑身上浮起焦黑的斑。

云中的雷却更密了。

周显一手托盘,一手持剑,接连挡了几回。脚下岩石被震开两道长缝,左肩的衣料也被一道漏下的电光穿透,皮肉骤然焦起。

疼痛顺着肩骨往下走。

他退到宽岩的另一端,将掌中的铜盘举高了半尺。

盘上已经有三处暗了。

若继续往里面灌入厚重法力,剩下的禁纹还能再撑片刻,可这片刻一过,盘便会直接碎在头顶。

周显看着盘面的暗处,忽然松开左手。

铜盘向前飞去。

正要落下的那一团雷被它迎住,盘中蓄着的法力随即向外猛撑。周显主动催开了器中的数道禁纹,青光一下涨大,将最厚的雷芒拖向岩前。

铜盘发出尖锐的裂声。

他不再去收它。

右手的剑就在这时抬起,沿那一团雷尚未完全合拢的边缘,斜斜斩过。

剑光很窄,却比他过去任何一次出剑都凝练。新生的元婴坐在丹田之内,法力顺着他的心意奔行,一口气送到剑锋上。

轰!

铜盘碎了。

雷芒沿着破开的青光落下,又被剑锋截去一段。余下的光照在周显身上,护身法力一层层破开,左肩已经结焦的伤口再度崩裂,血飞进刺目的白光里。

周显双膝一沉,险些跪下。

他把剑尖抵住岩面,借了一口气,重新站了起来。

山腰也有散雷坠落。

陈生抬掌,将往石道上窜来的几缕雷火一并扫开。江明站在洞侧供灵槽外,握剑的手收得很紧,却一直看得见上头那道站起来的人影。

周显的剑,没有掉。

头顶最后一片雷光落下时,他收回盘碎后乱走的法力,将元婴护在丹田深处,持剑迎了上去。

这一剑,斩不开全部的雷。

剑前的白光向两侧裂开,余雷仍压在他的肩背上,打得皮肉发焦,发冠也碎了。周显没有再把法力全送去护皮肉,他守住刚刚成形的元婴,让尚能运行的一周法力完整地走过。

脚下碎石迸开。

雷声终于远了。

周显低着头,长发垂在脸侧,血顺着左臂滴到剑柄上。他等了好一会儿,才慢慢抬起眼睛。

云在散。

秋日的光从云缝里落下来,照在他手中已经焦黑的剑上。

丹田里的元婴,仍安稳地坐着。

他试着提起一缕法力,那种与旧日不同的气息,清清楚楚地从掌心透了出来。

周显笑了一声。

笑得不响,还牵动了肩上的伤,他却又笑了一声。

……

陈生走到宽岩上时,周显已经靠着断石坐下。

江明比陈生晚了一步。他方才开过外槽,里面的灵石已经全成了灰白碎渣。此时他先把收拢的几块铜盘残片放在周显脚边,又将那只空灵石袋递给他。

“全用了。”

周显接过空袋,看了一眼,笑道:“没白用。”

陈生蹲下,验过他的气脉,先替他封住左肩仍在流血的一处,再把伤药放到他手里。

周显自己服了药,慢慢压下胸中那股乱气。

元婴初期。

这一层修为,他终于真到了。

只是左肩的雷伤还深,肩背也有灼痕,握剑的手暂时抬不高。那件特意买来的三阶铜盘毁了,佩剑仍在,剑鞘却裂,刃上的焦斑还要另行洗养。

周显把剑平放在膝头,看了很久。

“先前总觉得,站到这里,便能把许多事看明白。”

陈生问:“现在呢?”

“先把这口气喘匀。”

江明在旁边低低笑了一声。

周显抬头看他,眼中那股刚渡过雷劫的亮意还没有退。

“你的药,去哪一家?”

“下山南路。”

“那就先去。今日别跟我抢灵气。”

江明看了他片刻,将空下来的手往袖里一收。

“等我真来抢时,你未必肯让。”

周显将铜盘残片收进袋里,扶着剑站起来。陈生陪他回到洞中,让他在石榻上坐稳;新一周法力运起时,周显又慢慢闭上了眼。

江明站在洞口,向陈生点了点头,提起自己的剑匣,沿着来时的山路下去。

……

南路药行离伏泉山不远。

江明到时,铺里正要收起一盘新药。他叫住掌柜,把自己那只小药匣放到柜上,取出其中火性更完整的一株火神草。

草根下仍连着一小块旧矿石。

他仔细分出护根所需的一小块养药玉泥,随草一并放下,另一株与余下玉泥则重新收回自己的匣中。

掌柜验了火意,又打开一只封水的小匣。

里面是一段三阶清魂藕,只有两节,藕心透着淡淡的银色。

这是江明所收丹方里养神的一味辅药,早些时候问过价,却一直没找到两节都完整的。掌柜要他的矿火神草,他要眼前这段藕,两边各验了一遍,最后说定以药换药,互不添灵石。

江明把火神草推过去。

掌柜收入自己柜中,他也拿走封水匣,揭掉铺上的旧签,换上自己的封记。

药匣里仍有空位。

江明低头数过,把清魂藕安放好,走出药行时,回望了一眼伏泉山。

山顶的云已经全散。

他还在金丹境,肩上的浅灰剑匣却压得很稳。青碧剑的剑气藏在其中,随步子轻轻一动,又被匣身收住。

江明收回目光,把袖中另一张写着药名的纸展开,朝下一家铺子走去。

第400章 纸边未裁

陈生回广秀留山的第六年,焚城入秋时,墨欢终于打开了那只装真剂丹的玉瓶。

丹是几年前自己炼的,瓶口封了又开,开了又封,始终没舍得用。

此时他的修为仍在金丹初期,法力却已在这些年的积累中逼近了关口。再让这颗丹躺下去,也不会替他多走一步。

金色丹丸入口,强横药力骤然散开,撑得腹中一阵发烫。墨欢早留出了运功的余地,依旧没能将第一股锐气全接住,额上很快见了汗。

最初炼成的金汤水丹仍封在另一只瓶中,丹皮的浅痕尚在。墨欢将两手按在膝上,循着自己推过的行功次序,一点点收束散开的药力。

第九日,金丹内积压许久的法力终于贯通,一股远胜先前的气息从静室中散出。

墨欢实实在在地跨入了金丹中期。

他又留了两个月,直到新涨的法力运转安稳,才推门出来。真剂丹的空瓶被他带在身上,见到墨沉,先往桌上一放。

“用了。”

老人看看瓶,又看看他,笑了。

“我听见了。”

“隔着门能听出多少?我如今再炼那炉,末尾那股药气就不必压得那么吃力。”

墨欢说着,掌中浮起一线丹火。火从指间缓缓折回,没有散开,落到掌心时,才被他握灭。

他自己也笑得有些得意。

邵循恰好从屋里出来,将一张写了炉租的纸压在空瓶下。

“那这笔也好说了?”

“晋一阶,你就涨一回价?”

“同上回一样。你先看数。”

最初那三年共用早已过了。后来墨欢仍留在院中,两人改作按年续租,邵循自用优先、已下药不能赶人的旧规矩则留着。

墨欢低头数过,果然没有多。他把灵石付给邵循,当面再续了一年,拿回自己的那张凭据,折起来收好。

青回炉仍在黑石上。

右侧浅弯里却早没了灰白火色。去岁冬天,墨欢曾守在那里,看着仅余的一线火光缩成白点,最后彻底熄灭。他探了数遍,槽里除了余温,再无能引出的火种。

如今那处也凉透了。空石盏与旧炉耳被他收在自己的木匣中。

墨沉正坐在廊下,膝上摊着一叠纸。

这些年,他仍出去过几趟,去近城看器,或在河边的小客舍住上几日,后来走得越来越近。今年入秋后,他到院门前也要歇一歇,常在一页纸还没读完时睡着。

今日倒醒得早。

“过来,帮我挪一下。”

墨欢本还想说那九日如何熬过,听见这句,目光落到了纸上。

临潮的两壁原拓,沿江的水道图,还有几处客舍写下的短记,都被老人翻了出来。入关前墨欢替他理过,按纸幅大小分作三摞,整整齐齐放在柜里。

现在全乱了。

“我都收好了。”

“收得我找不着。”

墨沉指了指其中两张。

“竹器城在前头,试钟是在后头。你把小纸放一堆,走一段就得换一卷。”

“你又不照着它再走一趟。”

话出口,墨欢自己先停了一下。

墨沉却已低头,慢慢抽出那张画着客舟的小纸,移到了水道图后。

“我要照着看。”

墨欢没再争。他将旧桌抬到廊前,叫老人不必一直低着头,又把三摞纸抱过来,一张张按墨沉说的顺序摆开。

邵循站在桌边看了片刻,取来两片薄木板。

“夹成一册。宽的折进来,省得往后又散。”

墨沉点头。

三人开始忙这本既不齐整、也不好看的册子。

有的纸薄,有的纸厚,长的足有桌面那么宽。墨欢想将两幅原拓另卷起来,墨沉偏要放在临潮那几页后面。邵循便照着原来的折处,做了两张能够向外展开的折页。

轮到右壁原拓时,邵循的刀比在纸边上。

“这条裁了,刚好。”

墨沉伸指按住。

“这张留下。”

“裁的是空边。”

“下面还有水印。”

墨欢凑过去看。纸角有一片极淡的灰黄,潮水湿过又干,留下了不大平整的皱纹,字与刻痕并不在那上面。

邵循看了一眼,收刀,将木板往外挪了一寸。

“那就让它宽着。”

墨沉满意了,取过黑青旧砚,慢慢磨出一点墨。

墨欢伸手要替他磨,被他拨开。墨欢有些不耐,转身去取针线,再回来时,砚中才积了浅浅一洼。

老人用笔蘸了蘸,在新添的纸上写下“临潮”二字。

第二个字末笔略斜。他盯了一会儿,没改,将笔搁下。

午后,册页穿好了。

墨欢捧给他,想叫他回屋躺着看,墨沉却把两幅原拓全展开,指尖顺着右壁那道先低后起的长痕走了一遍。

墨沉还记得,那天手上托着的水势,便在这地方分了开。

“大爷,进屋了。”

“先别收,我还看。”

墨欢只好将旁边散着的针线收走。

他低头拾起一截落地的麻线,起身时,忽然发觉桌面上的纸不动了。

老人先前一直分出极薄的法力托着纸角,以免压住下面那行小字。此刻纸角已经落下,覆在他的手背上。

墨欢伸手碰了碰。

“大爷?”

墨沉的目光仍朝着册页,眼中却已没有方才的神采。胸前起伏变得极缓,每一次都要隔很久,体内衰弱的真元也不再随着呼吸回转。

邵循放下了手里的刀。

墨欢扶住老人肩头,一股法力探入,又迅速收了几分。他摸到的脉息极细,像从指下滑过去,捉住一点,下一刻便又淡了。

“我扶你进去。”

这句话没有得到回答。

墨欢的手臂已经用力,老人却没有如往常那样借他的力站起来。邵循从另一侧扶住,两人将他靠稳,墨欢仍握着那只手,掌中不断送着温和的法力。

暮光从廊柱间移过去。

墨沉体内最后一点元婴气息渐渐散尽,脉息断了,胸口也不再起伏。残存的生机随之消退,墨欢送去的法力,只能留住手掌上的一点暖意。

老人寿尽了。

墨欢仍叫了两声。

邵循没有劝,俯下身,将覆在老人手背上的纸角托起,轻轻铺平。

过了很久,墨欢松开掌心,替老人合上了眼。

他刚刚养稳的金丹法力,在经脉里缓缓流转。那只终于不再发颤的手,却已经全落在了他的手中。

……

次日,墨沉被葬在城外的缓坡上。

墨欢与邵循替他敛衣、入棺,将土填实,立下刻了名字的石碑。

回院后,墨欢从旧房里取出那只酒葫芦,往装遗物的木箱中放。

邵循伸手拦了一下。

“这个给我。”

墨欢立刻抱紧了。

“我还没收完。”

“我没叫你都给我。我就要这个。”

“我想留着。”

两人隔着箱子站了一阵。

邵循先松了手,转身往外走。走到门口,又回头道:“那册子别锁进去。他昨日才叫你摊开。”

墨欢的眼睛一下红了。

“他昨日说什么,你倒全记得。”

邵循看着他,半晌才道:“我也认识他。”

屋里没了声音。

墨欢把葫芦取出来,握着颈部,指尖在那圈磨旧的系绳上来回摸了几下,终于走到门边,递了过去。

“别换绳。”

邵循接住,没有再说什么。

旅行册与旧砚被墨欢抱回自己屋中。他把那套早年收到的复制拓另卷一处,原拓则仍留在册内,保着那一条未裁的纸边。老人的衣物折入木箱,空药匣与旧被褥另放,旧屋一时没有再给别人住。

又过两日,墨欢提笔写信。

他用了老人的砚,磨墨时太急,水添多了,第一行落下去,字便洇了一点。他另换一张纸,等了等,才继续写。

“陈生:

“大爷三日前在焚城寿尽,已葬城外南面缓坡。我和邵循送的。他最后坐在旧桌前看临潮原拓,那日还亲手写了两个字。

“我还住邵院。真剂丹已经服了,如今到了金丹中期。原先想等见面再拿这事同你说,先写在这里。

“旅行册和砚在我这里,葫芦给了邵循。你来信,仍寄旧处。

“墨欢。”

墨欢在末尾写了深秋当日的日期,封好信,走到焚城的传信铺,付了送往广秀祝霞山的信资。

铺中人将信装入转送筒,问他是否还有夹带之物。

“没有。”

他看着筒口封上,转身回了院。

邵循正在廊下拆一件旧护腕,酒葫芦挂在他的椅背上。墨欢经过空着的那把椅子,脚步停了一停,随后进屋,将新册搬到桌面,压住了翘起的纸角。

第401章 二十年这一掌

二十年满的那日,二狗真的来了。

陈生正在乌玄炉边收火,院门先被人推开,一只酒葫芦已经搁到了他身后的石案上。

“还要炼多久?”

那声音和二十年前一样不客气。

陈生回头,看见二狗站在门里,衣袖很窄,靴沿带着没擦干净的泥。人还是那个人,掌边的气息却比当初收得更沉,没抬手,便让陈生想起栖海渡来信中那一道留在身外的印。

“今日没开丹炉。”陈生道,“收一段火。”

“那就快些。我来叫你了。”

二狗自己拉开凳子坐下,揭酒封,闻了闻,又把葫芦合上。

陈生将炉口最后一线丹火收回掌中。

这二十年,他没有全用在炉前。

第十二年,他真正试过冲中期。积下的法力已经能推到那层关口,聚起之后,却总差一线留得住的劲。他连守三个月,将散回经脉的法力重新收稳,才结束那一次闭关。

仍是元婴初期。

陆闻溪留下的三匣药,早在之后补齐辅料的几年里用尽,三炉成丹,逐次服入体内。紫令堂后来送来的药,也按需用了。元婴不再像神都归来时那样,催上几段重势便先散去一截;日熙神照的血气与法力相接,也越来越齐。

门槛还没过,站在门前的人却已不同。

院中那棵树,比旧屋的檐高出了许多。

当年周显在伏泉山调息一个多月,伤稳后,陈生才陪他回广秀。左肩的雷痕后来渐淡,他仍记着那只铜盘先裂开的一声;如今元婴初期已经养稳,丹道还在三阶里往上走。

二狗向侧屋望了一眼。

“江明不在?”

“前日又出去了。现在是圆满,倒比从前更坐不住。”

这也是江明这些年自己一点点走出来的。南路药行换到的那段清魂藕没有凑出一炉黄芽,他后来仍沿自己的药路来回,积法力、补药、练剑,到去年才真正养至金丹圆满。还未冲婴,也没把那一口青碧剑里所有深处的残势都洗尽。

他这回去补最后几味,不曾拿陈生这二十年约满,当作自己也该停下的日子。

二狗笑了一声。

“你们这里,比我想的热闹。”

陈生将空炉推回案角,给他倒了茶。

“你在外头,倒肯寄信。读第一封时,我刚赢回来三匣药。”

“信上那句话,看见了?”

“不拿旧一掌应付。”

二狗放下茶杯。

“走。”

……

两人来到祝霞北侧的石坡。

陆闻溪留下的浅白火痕早已磨没,裂过的石面也长出几簇野草。二狗低头看了看,没有问陈生当年从哪一边出手。

“还是上回的法力量?”陈生问。

二狗点头。

他将外放的法力压到陈生初期的量,自己的手段却没有一并收回去。那尊元婴仍是圆满,谁也没有把这场切磋当作换了一副境界。

“你要等我先打?”二狗问。

陈生笑了。

“这回不等。”

他迈步,右掌压了出去。

没有惊人的金光先把石坡映亮。道一印落在二狗身前时,血气已经随法力经过了全身,掌中重势来得极齐,二狗横过左手,接上那一下,眉头才微微一动。

随即,他向旁边跨去。

刚接住陈生右掌的金光没有全撤,外沿随风散开,最中间的一段却留在原处。他自己的身形离开,右掌又从腰侧翻起,前后两道印在陈生身外一合。

陈生肩前顿时一重。

二狗要将他按回原来的落点。

那正是陈生看信时,想过许多回的一下。可文字里没有这般快,眼前也没有一个任他慢慢辨清的窄核。

他右脚沉下,神照体的光泽在腰背一闪。

两道印合拢的力量沿筋骨压回来,没有先把右肩牵住。陈生将掌中旧势向下送,整个人却向前半步,从二狗留下的前印旁侧穿过。

左掌紧跟着推起。

二狗没有接满。

他撤去前印的外沿,旧核从侧后方转来,身体已走到了另一边。新旧两段力再次相接,陈生这一掌的前半,被他借着身外的金光带偏。

陈生脚下碎石一响。

二狗的掌却已经到了胸前。

陈生双臂向内一合,血气承住重势,向后退了两步。掌缘震得发麻,胸腹中的法力也被这一撞催得翻涌。

二狗停都没停,又往前走。

“还以为练成了就能站着接?”

“没站。”

陈生回答时,自己的左脚已经从碎石里抬出。

丹火自指间一吐,先点向二狗刚刚留下的金印。二狗抬手,将那道印向旁边一移,火落在空处,陈生的右掌却也没有跟过去。

他留在自己身前。

二狗眼中亮了一瞬。

“又要我追一段残势?”

陈生忽然将掌中重势尽数推出。

“这段是真的。”

金光往前压,刚好逼在二狗新掌将起、旧印尚未接回的那一处。二狗侧身,左掌压住正面,右手便要把留在身后的力引来。

陈生没有继续与他争正面。

一掌打尽,自己却向左踏开,肩背与腰腿同时收住回来的震力。细丹火从掌边一分,两线分别落在先后两道金光相接的外沿。

火没有烧掉印。

两点向外的牵动,却让二狗后掌的落点偏开了少许。他立即重定窄核,陈生却已将左掌从腰下翻起。

这一下,他原来总要等右肩回稳。

如今不用等。

前掌余力尚在外面,左掌新势已经从全身齐齐送出。陈生不把它们先拖回肩背再压向一处,而是沿自己让出的半步空隙,先截住二狗那只要合印的右手。

两掌碰在一起。

二狗的手腕向旁边一偏,身外金光随之迟了一瞬。陈生借着这一瞬踏进,右手从前方收回,掌根落在他的腕骨外侧,左臂则架住正面的回势。

二狗肩头一沉。

他还要起掌,陈生已将自己的脚落在他下一步要换的位置上。前后两道金光受这一挡,各偏了一边。

重势没有打入胸口。

二狗的手腕却被压向低处,擦过了石面。

陈生自己也弯着腰,左臂承得发热,牙关紧着。二狗看见他的手还在微颤,掌中法力却已经收了。

“这一回,你赢了。”

陈生没有立即松手。

“说清楚。”

二狗瞪他。

“我要真挣,你就问我是不是多放了法力。还得再打?”

陈生这才松开,两人各退半步。

石坡上风声重新走过。

二狗甩了甩手腕,低头望着自己擦灰的袖口,忽然笑起来。

“养了二十年,倒是真肯拿来抢这一下。”

陈生也在笑。

“这一下,我想抢很久了。”

“真打呢?”

“等我再上一层,接着问。”

二狗哼了一声,先往坡下走。

陈生跟上,掌心的麻意还没有全退,心里那股高兴却已经压不住。上一次是自己先说认输,这一次,他听得很清楚。

……

回院后,两人把酒斟出来。

二狗坐到树下,看到案角一封焚城来信,手停了一下。

“焚城后来也写来?”

陈生点头。

第六年深秋,那封报丧信他当年就收到了。墨欢写大爷寿尽,已葬城外缓坡,自己和邵循送的;旅行册与砚留下,葫芦给了邵循。信里还说真剂已经服了,人到了金丹中期。

陈生读了很久,随后写信回去,问葬处与那两字,也问墨欢这次冲关怎样留住药力。回复亲自交到山下的传信铺,没有把该说的话留到以后见面。

那不是今日才到的消息。

后来的十四年,焚城仍有信来。最近一封在半月前,墨欢还住邵院,说前年在另一家器铺买下了一口旧三阶铜炉,已不用按年租青回炉;邵循嫌他的新火太响,不让在院中间摆。

信末只有一句:自己的炉,我偏要在自己最顺手的地方用。

陈生当时看得笑了,今日说给二狗,二狗也笑。

笑声落下,两人的酒杯却都放得慢了些。

风从树枝间穿过,地上的影子落在酒杯边。

陈生忽然问:“内门第九层,这些年可换过地方?”

二狗抬起眼。

“没有。你要看什么?”

陈生看着自己的手指。

方才那一掌的金光已经收净,掌上只剩浅浅的红。更早以前,手指上也曾破过一个极细的口子,血落到一只棺上,照亮了凤凰的纹。

“一本旧书。”他说。

第402章 旧书还在

陈生再上内门第九层时,走到最后一级石阶,先停了片刻。

楼里比山道上静。

他那时为绿珠借过二狗的陈字玉牌,如今再来,守层的修士已换了人。对方认得祖师,起身要领他进去,陈生却先向右边的书架看了看。

“幽河的古记,仍在那边?”

守层修士点头,将灯往高处挪了些。

陈生自己走过去。

他记得当年有一册很旧的纸本。架上的东西多了,木格之间也添了护纸的薄匣,他找过两格,才看见那个名字。

《幽河书》。

古纸本仍在。

他取下书,放在窗边长案上,先解外头新添的护绳,又用指腹压住页角,慢慢翻开。

第一次读它的时候,绿珠尚在。

他已经不肯只听一句寿终。他寻能延寿的东西,也寻那只凤凰棺究竟有没有另一条路。那时自己也年轻些,以为找到一个字,便离真正的答案近了一步。

后来,他护住她最后一点真灵,将棺推入幽河。

凤凰与黑水交缠,漩涡在河面展开。棺没有回到岸边,水里也没有留下任他辨认的痕迹。

这件事,他一直记得。

陈生翻到原来那一页。

“幽河为阴,凤凰棺为阳,仙人血实为渡世之人历劫归来的锚点。”

他读完,将指尖留在那一行下方。

当年他的血,确实使凤凰棺的纹亮起,棺盖也因此开启。可眼前这一行,始终没有说怎样从河里再找到那只棺。

下一页已经是别的古记。

陈生往后翻了两页,又翻回来。书中零散的说法很多,落笔者的推测、前人见过的异象、古旧年月里传下来的话,隔着不同的纸色与笔迹,挤在同一册里。

他把那句话又读了一遍。

楼外有风,窗边的光动了一下。

陈生忽然想起绿珠曾嫌他一句话想得太久。

她不是事事等他拿主意的人。他炼丹久坐,她也有自己的去处;有时他以为她只是随口说了一句,过了几日,才发现她已经把想做的事做了。

那时他听见她回来的脚步,往往仍坐在炉前。

她会先看一眼炉,再看他。

这些细小的事,比那片开棺时涌来的奇景,在记忆里更清楚。

陈生将手从页下收回来。

他想见的,不是一句得到应验的古语。

可是棺里的人如今怎样,真灵是否仍在,河中的路又通向哪里,他都没有亲眼看见。长生让他不必怕等到自己先死,并没有替他把那些未知的水面掀开。

当年开棺时,看见十万年、百万年的影像,他曾心惊,也曾把其中走出的人记得格外牢。那些影像是真是假,他那时不知,如今也没有多一份凭据。

他却已在那一份希望里,过了许多年。

书案另一端传来脚步声。

周显走进来,肩头的衣料比从前宽些,站稳后,将袖中一只小瓶放在灯旁。

“您忘了收这个。”

陈生看了一眼,是方才出门前搁在院里的普通丹药。周显今日到院中问旧方,正遇准备回太平峰的二狗从门里出来,听他说陈生去了第九层,便把忘收的瓶一道带上来了。

“不是急用。”陈生道。

“我也正想上来看看。有一册三阶丹谱,前回看了一半。”

周显说完,目光落在打开的纸本上。

他认出了书名,却没有立即开口。陈生将瓶收起,问他还想看哪一册,周显指过另一边,便准备自己过去。

走了两步,他又停下来。

“祖师,你那二十年,真满了?”

“满了。”

“二狗前辈来叫你?”

“还打了一场。”

周显看见他掌背的红,眉头抬了一下。

陈生笑着伸了伸手。

“我赢了。”

“……真赢了?”

陈生转过头。

周显立即把后半句话咽下,却没能藏住眼里的笑。他向陈生拱手,去了自己要看的书架,过一阵,那里便传来取匣放匣的轻响。

第九层重新静下来。

陈生继续读书。

这一次,他没有先把所有古记都当成能够接续那一行话的办法。有的前后不合,他停下来重看;有的只记了见闻,他便读到见闻止处,没有替写书的人补上后半段。

窗外日影慢慢转过案角。

到了傍晚,周显已抱着自己选的丹谱坐在另一张案边。陈生将古纸轻轻翻回最初那一页,望着“锚点”二字,过了很久,才合上书。

他没有取走原本,只在随身的纸上抄下那一行,又写明书名与页处,折好收入衣中。

周显见他起身,也跟着将丹谱放好。

“回院?”

“回。”

陈生向楼下走去,脚步比上来时缓了些。

山道尽头,祝霞的屋脊露在树后。二十年里,他把药用了,把掌练了,也把那道尚未过去的关口真去推过。如今那些日子已经过完,旧书里的那条河却仍停在自己最后看见它的位置。

陈生把手放在衣中那张新抄的纸上。

继续等,也许会等到什么。

可若下一回不只是坐在岸外想着,他该先问清的,又是什么?

他走到院门前,石案上还留着二狗没喝尽的酒。他坐下,展开抄纸,将“仙人血”与“锚点”之间那几个字,重新读了一遍。

太阳已经落到山后。

陈生仍坐在那里,想起当年棺消失时,自己曾站过的河岸。

第403章 这一次我去

第二日清晨,陈生将那张抄纸折好,放进了自己的袋中。

他没有再上第九层。

原书已经看过,那一行话也没有因此添出后半句。若要再知道些什么,他得回到当年真正放下凤凰棺的地方。

乌玄炉被他收起,案上几只常用药瓶各留一份。他挑过伤药,又将铁剑握在手里看了片刻,最后从存旧物的匣底,取出一方小玉玺。

玺小,掌心一合,便能握全。

这是洗龙河秘境的中枢,不是那颗常随他迎敌的玲珑宝珠。陈生神识落上去,旧日相连的气息仍在,除此之外,他没有隔着广秀的山川看见任何一只棺。

他将它另收入袖中,走出了静室。

周显今日来得早,正蹲在院里,把前日落在树下的一只药匣捡起来。听见脚步,他抬头,先看见陈生已经束好的衣袖。

“要出门?”

“去四九城。”

周显站起来。

陈生没有叫他猜,将想再看看洗龙河的事说了。周显听完,握药匣的手稍紧,随后问:“江明若回来,叫他在这里等?”

“不用。他缺的药,还是让他自己去找。”

“那我留哪一句话?”

“说我出去一趟,不赶他的炉。”

周显笑了。他将那只药匣放回屋里,跟陈生走到院门口。

“您那道新掌,昨日还说赢了。”

陈生伸了伸手,掌缘的浅红已经退了些。

“所以今日敢多走几步。”

“早知道,我昨天就该问是哪一招。”

“等我回来,你真拿自己的东西换。”

周显应了一声,没再跟。他自己也有丹要炼,不是祖师一出门,便得把所有炉火都停下来。

陈生到山下寄了一封信往焚城。

信里报二十年已满、二狗回来与他打过一场,又说自己要去四九城看看旧地方,暂时不往焚城。他还问墨欢那口买下的旧铜炉,用起来究竟哪里顺手,别下回来信只写邵循又说了什么。

写到这里,他在后面添了一句:这一回,我赢了。

信交入铺中,付了信资,陈生看着铺里的人封筒,才转去太平峰。

……

二狗已经回到了旧洞。

陈生走进去时,他正坐在晶碑旁,将一只封好的长匣推回柜里。匣口的禁纹很旧,二狗察觉来人,手停了一下,却没再打开。

“刚赢,就来要第二场?”

“不打。”

陈生把小玉玺拿给他看。

二狗坐直了些。

他当年肯借陈字牌,让陈生进第九层,却没有因此知道那本书每一行都写了什么。陈生这次坐下来,将凤凰棺血启、绿珠入棺、棺被幽河卷走的经过,说了一遍。

说到当年的漩涡,他停了片刻。

二狗没有替那一行古书作解释。他望着小玉玺,问:“你能找到棺?”

“那时候找不到。现在还不知道。”

“那你去做什么?”

“去看。”

陈生抬起眼。

“河还在不在原处,旧权柄还能管到哪里,当年我的血使纹亮了,再到河边,有没有别的变化。这些都得真站过去才知道。”

二狗将柜门关上,起身去拿自己的葫芦。

陈生看着他:“我没说要你去。”

“我知道。”

“你海上的那段还没练完。”

“练掌又不是脚不能挪。”

二狗取了法衣,束紧袖口,回头道:“你若真把什么东西拖上来,总得有个人帮你接一下。还是你想试试,自己先被拖下去?”

陈生笑了。

二狗却没有笑。他走到洞口,才道:“到了河边,你自己说要怎么做。我要觉得不对,也会说。”

“成。”

陈生将玉玺收好,与他一同出了太平峰。

……

七日后,两人抵达四九城。

城墙换过一段,河堤比从前宽了,靠水的街上新添了几座晒药的木架。陈生沿堤走,望见原来红线楼所在的巷口,那里仍有客舍,门上挂的却已是另一块招牌。

门前的年轻伙计正在扫地,见二人停住,扬声问住不住店。

“不住。”陈生道。

伙计又问他们找谁。

陈生向巷里看了一眼,笑着摇头,没说一个如今无人认得的名字。他从旁边买了两包干粮,二狗嫌那饼薄,自己又挑了一包。

卖饼的人朝葫芦看了看。

“两位仙长,要热酒么?”

二狗把葫芦按住。

“这里头还有。”

陈生看见他的动作,忽然想起许多年前,也是这样寻常的街边,他与绿珠一同挑过东西。那时他总觉得以后还有许多机会,街很长,日子也很长。

现在街还在,人群正朝着新的铺子走。

陈生把买来的干粮收好,转向河堤另一端。

“从这里下去。”

洗龙河在城外流过,天光照在水上,一片明亮。陈生走到旧时入秘境的位置,取出小玉玺,掌中法力缓缓送入。

水声忽然深了一层。

河面上浮出一方虚淡的轮廓,轮廓外是今日四九城的舟,轮廓内却是另一片静得没有人声的天地。

二狗将葫芦收入袋中。

陈生先迈了进去。

这一回,他的背上没有棺。

但他不再只是站在远处,想着当年那条河。

第404章 旧河岸

幽河仍在。

陈生进入秘境,先听见了它的水声。神殿外有一片新裂的石地,几处旧阶被尘土埋住,河却从黑沉沉的空处流来,又从另一端流入空处。

小玉玺与这片天地相连。

陈生能牵动岸上的灵气,能感觉到神域中一些空殿的位置。他试着将心神沿水路送远,到了河流穿出秘境的那一线,熟悉的感知便止住了。

那之外,没有一幅等着他展开的舆图。

二狗走到岸边,弯腰拾起一块碎石,弹进水里。

石头落下,水面开了一个小口,随即合上。

“你那时候站哪儿?”

陈生沿河走了几十步,停在一块低矮的石台前。

旧台缺了角,原来可供两人并肩立足的地方,如今只剩较窄的一段。他看过台边那条斜裂,确定是当年所在,才踏了上去。

二狗在后面停下,没有催。

陈生看着黑水,想起棺当时如何浮起、凤凰如何在水中挣动。眼前的水面却十分平静,没有因为他回来就重新生出漩涡。

他站了半晌,将铁剑收入袋中,指甲在另一手的指腹划过。

一滴血落下来。

血碰到黑水,红色只亮了一瞬,便不见了。

陈生的手仍伸着。

二狗看了一眼水面,又看他。

“也许要棺就在面前。”

“当年是碰到了纹。”

陈生收回手,止住指腹的血,沿着低台往前走。河边这一段水势,看着与别处无异,脚下却有一股往内回卷的暗流;他当年推棺入水,也是在这里。

他将宝珠祭起,清光贴着自己与石台落下,再以玉玺牵来岸上灵气,托住近处不断被水冲薄的一层光。

不是把幽河截断。

他只想在旧台之外,多站住半步。

二狗没有阻拦,右掌已经在身侧收起金光。

陈生的脚越出石台,踏在宝珠撑开的光上。

水面离他近了。

那股寒意却先从脚下透上来,护身气息没有破,肩背仍然一紧。陈生运起日熙神照,血气沿着筋骨走过,才将那一点僵意逼开。

他低下身,把带着薄薄血色的指腹伸向前方。

指尖没有碰到棺纹。

前面的黑水却忽然凹下去。

二狗掌中金光立即推出,落在清光后沿,将陈生往外滑去的脚托了一下。陈生没有回头,一线很淡的赤金色,已经从凹下去的水面深处亮了起来。

那不是他落下的血色。

它很细,转过一个弯,又往另一边延伸,像一片羽翼的外缘。

陈生的呼吸停了半息。

“凤凰。”

二狗听见这两个字,左掌也翻了起来。

前后两道印在身外相接,河边的清光终于不再向前滑。陈生顺着那道金线俯身,伸手去够,一块深黑的石角在水下浮起,真正碰到了他的掌心。

硬,冷。

棺角上有一道向上收拢的旧纹。

陈生认得。

他的指腹按了上去。

血色沿纹渗入,赤金之光一下亮过半尺。陈生来不及看更多,掌下的石质棺角忽然向外沉去,带得整条手臂往下。

他用另一手扣住边沿,身子也随之一低。

二狗的右掌重重推过来。

“先站稳!”

道一印承住陈生肩背,宝珠清光却在水上连着裂了两处。陈生催起法力,将右臂筋骨一并收紧,棺角终于在他的掌下又抬起了少许。

只这一角。

水底没有露出完整的棺身,反而有更深的黑色顺着它往外卷。陈生往内探去的神识一触便散,他只觉得手中越来越重,胸腹中能调动的法力却被这几息抽走了大片。

二狗又换了一步。

留在清光后沿的前印撑着旧处,新掌向棺角压来。他能托住陈生,真正要碰棺时,掌前的金光却被石上亮起的赤金纹滑开了。

“我接不上!”

陈生听得很清楚。

他没有因此松手,反而将掌贴得更紧。指腹那一点血已散进旧纹,河中的羽翼随之亮起,另一道还藏在黑水里的纹,却迟迟不肯浮出来。

棺角在他的掌心颤了一下。

陈生仿佛抓住了很远的一个人,手臂中的筋骨绷到发疼,仍想把这一下往岸边拉。

他的膝盖却先弯了。

血气能承住寒重,法力已续不上前面那一段。身下清光又裂开一道细缝,黑水沿着缝隙往上涌,碰到鞋边,顷刻结了一层灰霜。

二狗将左掌收窄,重势顶在陈生的腰后。

“你若再下去,我得把你拽回来!”

陈生的牙关咬得很紧。

他望着自己握住的那一角,终于将再催血气的劲停下。右手先松了两指,左手仍沿棺边留着,直到二狗把他的肩背往后托稳,他才将最后那一只手收回来。

石角没进了黑水。

赤金之光尚亮了一瞬。

随后,水面重新合拢。

二狗一把提住他的肩,将人带回石台。两人落地,清光从河上撤回来,陈生却仍朝着方才棺角出现的位置望着。

右腕肿起一线,鞋边灰霜未散。

他的手里没有棺,也没有绿珠留下的任何东西。

可是那道纹,他真正摸到了。

……

当夜,两人坐在远离河边的一座空殿里。

二狗把酒倒了一小杯,推给陈生。

“你到最后,想不想松?”

陈生没立即喝。

“不想。”

“我看出来了。”

二狗靠着旧柱,抬起右手看了看。掌缘也有一道被河寒压出来的白痕,等法力退稳,才渐渐有了血色。

“下一回别叫我只在后面喊。”

陈生转头看他。

二狗皱着眉。

“喊了你也没马上听。我得早一点把印放好,知道你哪一下接不上。”

陈生终于端起杯,笑了笑,酒入口时,胸口那一片冷意也动了一下。

“这次接不上的地方,我记住了。”

“记住,和能接上,还差一段。”

“我知道。”

那一夜,两人没有再去河边。

第二日陈生恢复了法力,才重新走到旧台,看了一眼已经不见金色的水面。他没有继续划开指头,只把台上新崩的一角和棺纹出现的地方,认真记了下来。

棺角被他碰到,不等于棺内的真灵还在。

但这一次,他不再只有一本书和当年的影像。

出了秘境,城外洗龙河上正有渡舟经过。船家吆喝客人快些上船,另一头的人嫌绳系得太慢,吵了两句,又笑起来。

陈生在堤边停了片刻,便与二狗向广秀走去。

又过七日,两人回到祝霞。

他将鞋边那层早已化去的灰霜擦净,把肿过的右腕放在日光里,慢慢转了一周法力。

到河边,需要的是他自己。

下一回能不能握得更久,也得由他自己往前走。

第405章 六个月的路

秦林的信是在两人回山后的第五日送到的。

来人走过广秀的传信铺,将封筒交到祝霞。陈生接过时,右腕还缠着一圈薄布。他用左手拆封,先看日期,再看那两行亲笔。

“参洲北境查获一队运奴客,所用血禁与旧军甲残纹相接。其主已逃,遗下一张芈氏铜盘拓样。

“请国师来辨。祖师若同来,朕另有事相商。”

下面附着一张新拓。

陈生没有隔着纸认出那口盘。他让送信人歇下,自己去太平峰,把信铺在二狗面前。

二狗看了很久,只点出盘沿那个字。

“这个刻法像。别处,当年隔得远。”

“去不去?”

“去。”

二狗将拓纸卷起来,又看陈生的手。

“你先把腕子养好。”

“路上养。”

“你急什么?”

陈生动了动手指。

“我想再去河边,得往前修。这些年能买的药,我没少买。秦林若真有新东西,我要亲眼看。”

二狗望着他,忽然笑了一下。

“这才像你。”

两日后,两人下山。

周显送到祝霞外,收了陈生留给江明的口信。江明还在外补药,陈生叫他回来照自己的时候开炉,不必等。他又交传信铺寄往神都的回书,只写收到拓样、二人动身,沿途还需时日。

这封回书先走,他们随后上路。

陈生在第一座过夜的城里换掉腕上的布。十余日后,腕骨转动时那一点牵痛也退了。他握剑练了一路,停船时练,等传送阵时也练;二狗嫌船舱里窄,出去站在船头,将掌中印放向迎面的风。

遇到不通传送的地方,两人便自己飞过去。

第五个月,他们从一处大泽绕路,落在山中歇脚。陈生挑干枝生火,二狗捉来两尾鱼,嫌他将鱼刺剔得太仔细。

“又不是端去卖钱。”

“你若不怕刺,连头一并嚼。”

二狗真咬了一口,随后将鱼头吐到火里。

陈生笑了很久。

等到了神都,前后已近半年。

城外的秋田刚收过,路旁还有车队等着进城。陈生没有在城门前看见迎候的仪仗,倒在紫令堂门口看见赵管家挽着袖子,正同一个送药的争两匣药的价。

赵管家回头,话音忽然断了。

他的鬓边已有几根白发,站姿比旧日沉得多。老赵的孙儿,当年接过祖父差事时还是青年,如今连还价都学会先把对方晾一阵了。

“东家!”

送药人一听,立刻把匣子往前举。陈生伸手压住,先问赵管家差价多少。

“他拿上次的价,买这次的药。”送药人抢着道。

赵管家将匣口掀开,拈起两片叶子给陈生看。陈生看完,将那一匣推回去。

“这一匣按原价,另一匣叶脉伤了三成,你自己选,降价还是带回去。”

送药人看看他,终于松了口。

赵管家付清药款,再将两人迎入后院,脸上的笑这才全露出来。

弗陵从炉房赶来,袖上沾着药灰,见陈生就先伸出三根手指。

“又废了三炉。”

陈生被他堵在门前,连椅子还没摸到。

“废了找我赔?”

“找你看。二阶的生意养得住铺子,养不住我这张脸。”

弗陵已是金丹后期,陈生在他近身时感受得清楚。可他取出的那三枚丹胚,外层看着浑圆,内里的药性却散乱得很,三阶药性始终没真正凝住。

陈生收下一枚,答应这趟走前看他一炉。

二狗在旁边道:“先吃饭。”

饭还没摆齐,宫里已经来人。

秦林不是从陈生的回书里得知他们今日抵达。城门司递了报,药监长便亲自过来,请两人入宫。

陈生看着桌上刚端来的肉,将筷子拿起来。

“等我吃完。”

……

秦林在偏殿等他们。

这一次,他的右手握着笔,落下最后一字,又亲手将一方小印盖了上去。陈生看着那只手,旧日的僵滞已经全消;待秦林抬头,身上圆满法力微微一展,依旧停在元婴的尽头。

“祖师路上顺利?”

“绕了一段大泽。”

陈生坐下,二狗却没有坐。

“盘呢?”

秦林将案上的一个木框翻过来。

框里嵌的是拓纸,不是铜盘。

“寄信那一回,没留下人。追到今日,才知道他换了三次船。”

他指向旁边的舆图。图上点着三处渡口,最北处已经涂去。

“最初救下二十一人,押船的死了七个,逃掉的那位动过元婴法力。此后两队密探盯着这条线,丢过一次,又从运去的锁具找回。”

“现在?”二狗问。

“石鸦渡。昨日回报,有人运新锁进去。”

陈生低头看图。

石鸦渡离神都尚有两日行程,沿河往下,三个支流都能藏船。

秦林又取出一张薄纸,放在他手边。

上面列着药名和数目,有的已画朱圈,有的仍空着。陈生看过,手指停在其中一味四阶药上,随后移开。

“这是要买我多少年?”

“三十年。”秦林道,“留在神都。药材优先供给祖师,成丹之外,朕也可助你冲中期。”

二狗转过头。

陈生将那张纸推回去。

“留三个月,可以。三十年,不成。”

“广秀能给的,朕未必少。”

“可我今日想去四九,明日想往焚城。留在这里,你三十年的药,我就欠三十年的事。”

秦林没有再劝,将药单收了起来。

“只办眼前这件,九万上品。事毕付清。”

陈生看向二狗。

二狗冷冷道:“我自有旧账,不替你讲价。”

陈生笑着向秦林点头。

“我接。人救出来,路截断,我拿灵石走。”

“路未必要立刻截。”

秦林这一句,使二狗终于拉开了椅子,却仍没坐。

“什么意思?”

“他们手里有冥血,身后还可能有人。朕要顺着再找一层。若只杀搬运的,过几年,照旧换一拨人。”

“你拿谁等这几年?”

二狗的手按在椅背上,木头喀地响了一下。

秦林看着他。

“朕没说不救。”

“真到了坛上,你要留线,我要拆坛,听谁的?”

偏殿里静了片刻。

陈生将舆图拉到两人中间,指住石鸦渡。

“先抓持盘的,得活口,你问。若能跟着器物走,就跟。若只能把人送上去等血落下来,我不等。”

秦林的目光仍很硬。

“祖师当年去王元枫处,见过祭阵?”

“见过。尸血进去,冥血自己出来。你留着那座坛,也未必等得到捧碗的人。”

二狗慢慢松开了椅背。

秦林起身,将殿门拉开,叫近侍传令备舟。

“今日夜里出发。”

他回过头。

“朕亲自去。活口朕要,活着的人也要。到了渡口,先别让他们看见国师那张脸。”

二狗抬手一抹,面容随法力换成了一个须发灰白的老客。他转身走出殿门,肩头却仍绷着。

陈生经过那张椅子时看了一眼。

椅背已经裂了。

秦林也看见了,没叫人换。

第406章 持盘的人

御舟没有挂龙旗。

秦林换了常服,与陈生、二狗同在后舱。舟过两座城,他亲自换乘一叶小舟,又将大舟留在上游。第二日入夜,三人从河湾步行到了石鸦渡。

接应的密探只有一人。

他左眼肿着,说话时不敢抬头。秦林问过才知道,前一日那船上的管事临时起意,叫人比斗解闷,他装的脚夫输了,被踢进水里。

“伤了谁的事?”秦林问。

“没露。”

“那就把头抬起来。”

密探抬头,将一只小木匣递过。

匣内有半截新锁链,另一头还粘着血。他说白日有个女人咬断舌头,差点死在船底,那边换锁时将这截丢入了水中。

陈生拿起锁链。

内侧的齿很细,入肉之后会自行收紧。链中法力流动的方向,却与普通困人的禁器相反,一直往外抽。

“人呢?”他问。

“还活着。有人给她灌了药。”

陈生将锁链放回匣里,望向河上。

三艘乌篷船依次泊着,船边的人正将一筐筐湿草搬下去。乍看是满船药草,直到船舱里传出一声闷响,一个管事俯身踢了脚下两记,声音才止住。

二狗向前迈了一步。

秦林按住他的腕子。

“盘还没出来。”

二狗看了他一眼,没有再迈,手却从他掌下抽了出去。

又过了一盏茶。

中间那艘船上,灰袍修士从篷下走出,将手里的铜盘举到胸前。盘沿向下放出三股暗红色光,三艘船腹一同下沉,水面立时开出一条窄道。

元婴中期。

陈生感觉到那股气息,右手已握住了铁剑。

铜盘翻转,盘底一个古篆短暂露出。

二狗的脚终于落了下去。

“就是这个刻法。”

他没有喊人名,第一道掌印直落中船。船篷当空炸开,灰袍修士反手推盘,红光朝两侧散去,竟先扯动了船底的锁链。

陈生已从另一边跃上左船。

他没有去抢盘。宝珠清光在船舷上一滚,压住两名拔刀的管事,铁剑带着一线法力落下,将船板斜斜剖开。

板下全是人。

十几双眼睛仰起来,最边上的女人嘴边都是血,双手仍死死扣着自己颈上的锁。

陈生探手摸到主链,炽白丹火顺链分成数缕。链内正在向外抽取的红丝被逐一截断,他另一手运起道一印,抵住船底那块被禁力拽得上翻的木梁。

整艘船猛地一震。

他的双脚在湿板上滑了半尺,肩背血气随即合拢,将那根梁压了回去。

中船上响起一声怒喝。

灰袍修士被二狗一掌拍穿护身红雾,铜盘脱手,又被他以神识拽回。他转身要遁,一条金龙已经从岸上落下,咬住盘沿。

秦林出手了。

盘上的三道红光一齐绷断。灰袍修士当即舍盘,肉身化作一团血影,斜掠出去十余丈;二狗留在前方的掌印忽然翻面,与后掌相接,将他硬生生截回河滩。

秦林落在他身前,五指按下。

血影被压散,灰袍修士的额头撞进泥里。他丹田一亮,元婴刚露出头顶,便被金色法力裹住,重新按了回去。

“芈承晦。”秦林道。

地上的人浑身一颤。

“你竟肯亲自出城。”

秦林扯下他的储物袋,封住他的气海,才转头看向船上。

二狗已经去了第三艘船。两位元婴圆满不再掩藏气息,余下的押运修士纷纷逃散,被岸上赶来的密探与禁军截住。

陈生抱出那个女人,将伤药抹在她舌下,替她止住血。

“能咽就咽,不能便含着。”

女人喉中呜了一声,紧抓着他衣袖的手终于松了。

三艘船里抬出五十三个活人,另有四人已经断气。二狗抱出最后一个孩子,看见船底那四具身体,站了好一会儿,才将孩子交给岸上的军士。

秦林没有叫人把血洗掉。

他令密探留下护送活人,再将芈承晦带进废弃的渡亭。

……

铜盘被放在石桌上。

芈承晦左颊嵌着碎石,嘴却闭得很紧。二狗解去易容,在他面前站定,他的眼睛终于变了。

“你当年站在西南台上?”二狗问。

芈承晦看着他,没有答。

秦林将盘翻过来,拇指用力,底层一片后加的铜盖被揭开。里头藏着六道细槽,两道早已熔断,另外四道仍泛着淡红。

他让近侍取来一卷旧图。

旧图来自当年留在军器署的西南阵台底稿,二十年间几经查验,如今与盘底一合,六处接位严丝合缝。此盘原是接军阵的器,被人重炼成了收血之用。

“朕知道盘的来处。现在问的是,谁拿过。”

芈承晦吐出一颗碎牙。

“盘是我家的,谁拿都一样。”

二狗伸手,将铜盘抬到与他双眼平齐的地方。

“你既认得我,便该知道我会问。当年你站哪一台?”

秦林拦下二狗继续向前的手。

“你现在说,朕给你一个明白死法。不说,就把你押去神都,让最先救下来的二十一人与你见面。”

芈承晦瞥过亭外。

岸上有人在哭。那声音很低,断断续续地从黑暗里传进来。

“齐孟舟拿幡。”

他终于开口。

“那夜盘在我手里。他站在右台,我在左台。严承岳翻令以后,两座台先截中军,再往前压。齐轩正从后面送来十二道青光,我们各接了一道。”

秦林眼中寒色一动,却没打断。

“后来?”

“秦证把主纹斩断,剩下的人开始脱甲。齐孟舟使幡去截,被他——”

芈承晦望向二狗。

“被他打到西边山壁。石塌下来,把我的盘脚也砸断了一根。齐孟舟将断脚带走,我后来找他才拿回来。”

二狗盯住了他。

秦证最后斩主纹、士兵脱甲、幡撞西壁,这些事不是军器署底稿上画的。芈承晦说的位置,也与二狗亲见的两座阵台合得上。

秦林将铜盘侧起。六只短脚里果然有一只接过,焊口埋在底沿内侧,旧锈已深入缝中。

“齐轩正在哪里?”

芈承晦咬住了牙。

秦林忽然以指扣住那只铜盘,压向他封住的丹田。盘内残存的红丝感到血气,细细地伸了出来。

芈承晦脸色大变。

“你也要用它?”

“朕在问话。”

陈生按住盘边,没让红丝碰到人。

“现在说,省得他真动手。”

芈承晦喘息着,道出了石鸦渡往南七十里的一处废矿。

旧日神都一败,齐轩正逃入群山,随后潜回参洲养了多年伤。近十年,他招拢两家散逃的人,借矿路和渡口收取血祭所需,又从活人身上榨取精血,才将这支路重新接了起来。

“下河还有两处仓。我只收这一头,另外两头由齐孟舟的人带。总盘在矿里,今晚就要用。”

秦林将图推过去,要他点出位置。

芈承晦点了三次,又交代矿中通道。旁边的近侍一一记下,将纸封好,立刻分出两拨快舟,去接秦林留在上游的人马。

二狗始终没离开亭子。

等笔停下,他才问:“方厚呢?”

“谁?”

“守渡营,举半旗的人。”

芈承晦茫然片刻,摇头。

“那夜逃的人太多。我没追过西渡,盘断了以后,我只顾自己走。”

二狗眼里的怒意没有消,最终却没逼他编一个死法。

秦林叫近侍将芈承晦押上御舟,派人持自己的禁印看守。铜盘也由专人封起,留下与旧图、供述一并送回神都。

他自己走向南面的山路。

陈生在渡亭外洗净手上的血,跟了过去。

二狗最后看了一眼离岸的小舟。船上的女人抱着孩子,仍说不出话,握住船舷的手却已经有了力气。

前方,秦林低声道:“若还有活人,祖师先抢。国师跟我进去。”

二狗走到他身边。

“别在里头改主意。”

秦林没有回答,掌中已经亮起一线金光,斩开了挡路的荆棘。

第407章 把人带出去

矿里的灯还亮着。

天未明,三人从背坡翻下去,先闻到一股很重的铁锈味。陈生将神识贴着石壁送入,越过几道废轨,探到成排的笼子,也探到笼子下方正在发热的沟槽。

里头的人还活着。

最深处忽然响起一声铜鸣,神识被齐齐截断。

一道青光从矿口照出来。

“陛下远来,齐某失迎。”

秦林抬手,皇道法力在掌中化作长剑,一剑斩开了前面的石门。

齐轩正站在门后。

他比陈生上次见时瘦了些,右肩露出一段深色旧疤,抬臂时却很稳。元婴后期的气息从他身上散开,十二道青光随身转过,几道照着矿内,另外几道一直通向山腹。

“上回让你走了。”秦林道。

“这回未必留得住。”

齐轩正身后又走出一人,狭长面孔,手里一杆短幡。幡面旧,幡杆中段却换过一截,新旧材色相差极大。

二狗望着那张幡,掌中金光亮了起来。

那人没有先看秦林,反倒盯住二狗。

“西壁那一下,我记到今天。”

二狗向前走了一步。

“齐孟舟?”

那人听见自己的名字,眼神稍动,随即望向后方矿道。

“承晦被你们抓了?”

秦林没有接这句话。

二狗已将第一印推出,金光擦过地面,直轰短幡。齐孟舟摇幡一卷,后期法力撑起一道血色弧面,竟将掌印拖歪了半尺。

幡背的齐族旧刻在光中亮起。

“那夜也是这个转法。”二狗道。

“那夜你只顾脱甲的兵,哪敢追我!”

齐孟舟厉喝,短幡陡然翻面,血弧变成三条长鞭,从不同方向抽向二狗。

二狗身外前印一停,后掌撞了上去。两道印合拢,第一条血鞭当场断成数截。

“今天追到了。”

两人迎面撞在一起。

秦林也动了。

长剑压向齐轩正,剑上金龙盘首,甫一相触,最前方三道青光便弯了。齐轩正脚下却升起大片红纹,那些纹绕过他的靴底,往十二道青光中一灌,竟将长剑往上托回了一分。

矿里的铜鸣更急。

陈生转身冲进左侧窄道。

齐轩正左手一引,一道青光越过秦林,向他后背射来。玲珑宝珠腾起,清光接住尖锐的青锋,陈生整个人被击得向前扑去,肩背擦过石壁,火星连着血一并飞出。

他落地时右脚一撑,法体把余力引入脚下,碎石炸开,人终于没跪下去。

秦林斩断了第二道追来的青光。

“进去!”

陈生提起铁剑,钻过半塌的门洞。

笼子就在门后。

数十张面孔一起转向他,有人喊救命,有人只张着嘴,颈上的锁已经抽得皮肤发青。

陈生一剑切入最近一条铁链,剑上法力一转,将红丝逼向断口,再以丹火烧去。解开的青年猛扑过来,险些把自己扑倒。

“从我来的门出去。”陈生道。

青年望了一眼外面翻涌的血光,腿立时软了。

“门外那两位是来救你们的。”

陈生抓住他的肩,将人拎起来。

“出去一个,再拉下一个。你留这里,后面的人也走不动。”

青年咬住牙,抱起笼角一个昏迷的孩子,弯着腰冲向门洞。

第二个人跟着爬了出去。

陈生没有一根根慢拆。五道丹火顺着笼底沟槽铺开,他沿火看清红流往哪边去,铁剑便往哪边切。断开的血禁在身侧乱跳,宝珠清光兜住飞溅的细丝,将它们压在空笼中。

前方突然传来一声闷响。

八只铜齿从石座里升起,缠在一起的红光在齿间旋动,方才断开的几条沟槽,竟从另一边又亮了起来。

总盘在这里。

陈生刚走近,一股吸力猛然攥住他的丹田。

元婴在体内一震。他闷哼一声,连退两步,左肋抵住石柱,才将那股抽取之力顶住。

这座坛积着的血力,比渡口那口小盘厚了太多。

陈生将道一印按向地面,以印力拦住最靠近囚笼的红槽,丹火只留三缕。他没有再伸神识去探盘心,改将血气沉入腰背,双手扣住了承着最后两排囚笼的底梁。

起!

石台往上抬了一寸。

铜齿中的红光猛地收紧,底梁几乎勒进他的掌中。陈生十万零八窍间血气齐动,膝骨发出细响,硬将两排铁笼连着底座抬离了地面。

下方的血纹断了三条。

笼里的人终于能喘出气。

陈生额角迸出青筋,将整片底梁向旁边一掀。囚笼重重撞在空地上,他跟着扑过去,用剑斩锁。

铁剑切过最后一根锁时,剑刃上崩开了一道小口。

他没停。

……

洞口,齐孟舟已被打退到第三根石柱旁。

二狗左臂被血鞭割开一道长口,出掌却越来越近。齐孟舟背后的短幡接着矿内血流,幡面每碎一处,就有红光补上,强行撑住后期与圆满的差距。

“你耗得起?”齐孟舟喘着气笑,“里面那位再拆下去,先把自己拆死。”

二狗没有回头。

他前掌忽然偏了,击在齐孟舟右侧空处。

齐孟舟立刻转幡,后掌已经从低处追上,撞在留于空中的残印边缘。两印错位,并未正合,金光却沿幡杆拐了过去。

旧幡中段那一截新材,喀地裂了。

“当年撞断的地方,接得不牢。”二狗道。

齐孟舟面色陡变,拼命往幡里灌血。正在此时,矿内一片铜鸣骤然停了半声。

幡上红光空了一瞬。

二狗欺身而入,一掌拍碎幡面,另一掌印在齐孟舟胸口。

骨肉在重印下塌陷。

齐孟舟的元婴从头顶窜出,才跃过半根石柱,先前留在空处的金印已经转了过来。前后印合拢,小小的婴体在中间发出尖叫,随即被碾成一片散光。

二狗收掌,神识从散去的残光中扫过。

尸身胸腹尽毁,元婴也没有遁走。

他一把扯出断幡,转向矿内。

另一边,齐轩正已把十二道青光收成一束。

秦林胸前裂开一道口子,衣袍里露出血色。那柄皇道长剑仍顶着青光,一寸寸往前推。

“你父亲当年也这么逼过来。”齐轩正低笑,“他以为西南两台还在护着军阵,等严承岳翻令,我亲自把退路合上。你今日倒聪明,先叫人进去抢——”

秦林的剑猛地往前压了一寸。

齐轩正嘴角溢血,笑容却没消。

“可你杀了我,谁告诉你冥血从何处来?”

秦林的眼睛抬了起来。

就在他目光一动时,齐轩正脚下两道红纹忽然转亮,绕开剑锋,直奔矿中的囚室。

他要将剩下的人一口抽尽。

秦林左手猛然探出,五指攥住两道红光。掌中血肉立时翻开,他却没有撒手,右手长剑顺势斩了下去。

“下去问我父亲!”

青光被劈成两半。

矿内,陈生听见最后几人跑过门洞,立即将道一印向总盘底座推去。盘上八齿仍在转,左边三齿已因底梁移开而悬空,他分出的三缕丹火正烧在那三处根部。

二狗从门外掠进,看清盘座,掌印立时落向另一侧。

两边力量相交,铜齿一只接着一只折断。

总盘终于翻了。

积血翻腾着扑向陈生,他以宝珠清光护住头脸,借道一印余势向后退。左肋仍被余劲扫中,一口血喷在衣襟,身子撞上洞壁。

二狗扯住他的肩,把人拉了出去。

洞口的齐轩正同时惨叫。

血力倒断,十二道青光失了根,皇道长剑从他胸口贯穿,将肉身钉在石壁上。元婴裹着一缕细青光从后脑逃出,朝山腹飞去。

秦林以流血的左手一握。

金龙回身,将那缕青光连着元婴一口衔住。齐轩正喊了半句,秦林右手的剑已经落下。

金光中,婴体碎尽。

秦林又一剑斩断尸身丹田,收回神识,确认生机与婴息同时消散,才向后退了半步。

陈生靠在门边,低头吐掉口中的血。

秦林向他看过来。

“人呢?”

“最后一个,也出去了。”

陈生伸手指向背坡。先前那个青年正扶着两个老者往上爬,抱走的孩子已经交给接应的军士,回头又来拉人。

天边终于透出一线亮色。

秦林低头,看了看自己的左掌,再看向已停转的总盘。

“把铜齿全部砸断。”

二狗将陈生扶到洞外,回身再入矿中。

片刻之后,里面传来接连的断裂声。最后一块盘心被他拎出,摔在石阶上,裂成数片。

陈生坐着没动。

一名刚获救的老人被人搀着经过,停下来,颤巍巍地把自己的水囊递给他。陈生接过,喝了一口,又递了回去。

老人也喝了一口。

山风吹过,两人身上的血味都还没有散。

第408章 出城各走一路

第六日,两处支仓的回报先后送到神都。

先回来的那队人押着七名活口,船上还躺着十七个获救的修士。后一队多走了一日水路,赶到时,仓主正在烧船,禁军抢下底舱,又从火中抬出二十六人。

两处仓里的取血器都被拆下。差官带回残件,与铜盘的转接锁一件件扣上,又让押船的当面对认。石鸦渡那三艘船、两处支仓,以及矿里的总盘,终于在庭中拼出了这条路的全貌。

矿里清点出的八十二个活人,已分送沿途医馆。有人回了家,有人家中早已空了,暂时仍住在官舍。

秦林从官舍回来时,脸色很沉。

朝上有人等着,想将封下的船仓先划到自家所领的司中。秦林听了半盏茶,将三份空着救治药钱的呈文扔下去,叫他们回去填。

陈生坐在下首,左肋绑着药布,等那几人退尽,才将封好的证词递上。

芈承晦亲口供出的左右台,齐孟舟战中自认的旧击,齐轩正所说的闭路与翻令,都写在上头。二狗另将自己当夜真正看见的事落了名,不把没看清的面孔补回那一夜。

秦林看完,将证词压在旧军图下。

“芈承晦又求见朕,说还有捧碗人的事。”

二狗抬眼。

“他说什么?”

“说先给一条活路,才肯说。”

秦林停了停。

“押来的七人里,有两个跟过他很多年。一个供他从齐轩正处领血,一个供近十年都在等旧使再来。他自己昨夜也已承认,未曾见过新使。”

陈生没有催杀,只问:“查过他指的地方?”

“查了。空山洞,只有烧剩的坛沿。两队人走了不同的路,回来都一样。”

秦林将最后一封回报合上,起身往外走。

芈承晦被押在殿外石阶下。

他看到秦林,立刻抬头,还想开口。秦林将当年阵台底稿、旧盘、断幡和三份供证放在他面前,叫他逐项认过,又问此次运活人的事。

芈承晦声音越来越低。

到最后,他盯着铜盘,忽然道:“若那夜赢的是你们,今日还会问这么多?”

“今日赢的就是朕。”

秦林拔出佩剑。

一剑落下,芈承晦的首级与肉身分开。封在丹田里的元婴被皇道金光提出,还没喊完,便被剑光绞碎。

秦林亲自扫过残躯与碎光,确定再无婴息,将剑收入鞘中。

阶下那口盘随后被砸碎。

断幡、支仓取血器与盘心残件,由陈生和二狗在旁,看着禁军一件件熔去;留供的拓形与记录收入宫中,活器再没有留下。

齐轩正重接的这一支冥血祭路,到这里彻底断了。

秦林却没有叫礼官写“血道尽灭”。那个捧黑碗的人究竟还在何处,他仍不知道。朝中还有人会觊觎别人血肉里能换来的修为,今日的灰也替他烧不尽。

他将那道报捷文书改了三行,盖上印,令沿河各城公布已查实的案情与获救者去向。

……

陈生在神都又住了三个多月。

左肋的余劲由他自己慢慢化去,肩背擦伤早先便已结好。最后一次运转周天,左侧再没有滞处,他才将药布尽数解下。

二狗的臂伤也好了,只留一道新浅痕。

秦林约定的九万上品灵石,在回京第十日已经送到紫令堂。赵管家当面点过,陈生将足数的灵石收进自己袋中,没换成秦林那份三十年的供药文书。

二狗所得的九万军赏也真取了。他盘算着海上寻器师的价,嫌秦林给得少,秦林听过,只说再没有。

两人直到陈生伤好,才最后入了一次宫。

秦林左掌的新肉已经长齐,胸前伤也收了口。他把一份拟好的诏纸推给二狗,前半写旧功,后半却要他每十年在京住满一年。

二狗从头读到尾,将纸搁回去。

“你还没死心。”

“国师该有国师的事。”

“我已做了。”

秦林望着他。

二狗没有再伸手。

“你若叫我找当年的仇人,我来。若元梁真有一天撑不住,我知道了,也会想来不来。可每十年一年,我给不了。”

“为了海上那一掌?”

“为了我自己。”

二狗坐得很直。

“我困住那些年,没往上走。如今走出来了,还要拿余下的时候换一个坐处?我想试化神,成不成,总得让我试。”

秦林的手压在纸上,久久没有动。

最后,他抽出另一张纸,写明国师不再领朝务,停俸,旧功与尊号仍存。写到末尾,笔尖顿了顿,又添上自择居处四字。

“往后你在外头,可不许叫地方官替你征人取物。”

二狗笑了一声。

“你以为我图这个?”

“朕得写。”

“那就写。”

秦林盖了玺,亲手将诏交给他。

陈生起身告辞。秦林叫住他,问下一处去哪儿。

“焚城。看一个开炉的朋友。”

“祖师下次来,若只为看朋友,也可入宫。”

陈生看着他,点了点头。

“到时候别又先拿药单堵门。”

秦林没笑,只将那张早已收过一次的药单从案旁取出,折好,放进抽屉。

二狗在门边等陈生,回头时,秦林已经坐下,翻开了下一封奏疏。

这回,两人都没有再说留步。

……

临走前,陈生去了紫令堂炉房。

弗陵等这一炉,已经等了数月。陈生养伤时看过他的火,又将那枚废胚剖开,让他自己试着收回总想护住全炉的力。这一日,弗陵把最后一份平澜丹药材摆上案,先向门边的人道:“成了也不许说你替我炼的。”

“败了别算到我头上。”陈生道。

炉火升起。

前半炉很稳,直到青色药液合入白霜,炉内忽然生出三重涌势。弗陵额头出汗,手已伸向护丹诀,伸到一半,却猛地收了回来。

他把火分出一道细缝,让最外层蒸气先吐出去。

药液一缩,炉心真正合住了。

陈生没有出手。

两个时辰后,弗陵揭盖,三枚青白相间的丹药缓缓浮起。他逐枚试过药力,直到最后一枚入瓶,手仍在微微发抖。

三阶。

他盯着瓶子,忽然转头望向陈生。

“我这个准字,可以摘了?”

陈生伸手,将他袖上快要落入药瓶的灰拍去。

“先把瓶口封好。”

弗陵大笑,抬手封瓶,亲自把丹送到前堂。

三日后,药客登门验货。

平澜丹能平抑服药后过于汹涌的药力,对方是金丹后期,取一缕丹气验过,又照约看了三枚丹的色泽,点出四万二千上品灵石,换走三个封瓶。

赵管家将灵石搬到柜后,当着弗陵的面核过本炉药材、火石与所摊铺务,一共三万。

这一单净余一万二。

按先前说好的分法,弗陵拿三分之一,四千当场入了自己的袋。余下八千留在铺中,赵管家另封一袋,准备补进下一批药。

陈生看着那两个袋子,问弗陵高不高兴。

“高兴。”弗陵答得很快,“可前面废的三炉还没全赚回来。下一炉我要五枚。”

“先再成三枚。”

“你走了,我想成几枚便几枚。”

陈生笑着站起来。

他真要走了。

铁剑刃上的缺口已被他细细磨平,剑锋因此窄了一线;宝珠收在袋中,还是原来那一颗。赵管家将路上用的药与吃食送到门边,弗陵捧着刚到手的四千灵石,又跑回炉房挑下一炉的材料。

城门外,二狗与陈生并肩走了一段。

“真不跟我出海?”二狗问。

“先去焚城。”

“你那位朋友也像刚才那个,一见面就要你看炉?”

“多半先骂我来得晚。”

二狗笑了,将葫芦递过来。陈生喝了一口,还给他。

前方到了岔路。

二狗要先回广秀取自己封存的玄罡铁长芯,再走海路寻合意的器师。陈生则顺南道直去焚城,已经在城中买好下一段灵舟的位子。

“等你回来,再打一场。”二狗道。

“还是上回的规矩?”

“等我炼成器,再讲规矩。”

陈生笑着骂他一句,转身走向南道。

暮色里,去焚城方向的灵舟升起了帆。陈生登舟,将行囊放在身侧,透过渐远的城墙,看见另一道黑衣身影已经飞向北方。

他收回目光,取出纸,在膝上写给墨欢的信。

这一次只写了几行:我已从神都动身,乘舟往焚城。旧事办了,伤也好了。你的铜炉,给我留个能坐下看的地方。

写完,他将信封好,准备到下一处舟站交寄。

船身越过河湾,神都终于隐在了后面。

第409章 沉船里的药

陈生与二狗在神都奔走的那段日子,江明在广秀南面的商路上,已经过了一个冬天。

这是回山后的第二十一年。

去年离开祝霞时,他还缺三味药。两味在入冬前买到,最后一味返真藤的活髓,却随一艘翻覆的药舟,沉在了落篷湾。

江明找到船主姚丰时,对方正拆一副湿透的护臂。

“人在就好。”江明道。

姚丰抬眼,看了看他摊在桌上的药单。

“道友不是来问我的。”

“你若不在,我连船沉在哪儿都没处问。”

姚丰苦笑,将水道图拉过来。

药舟是在夜里撞上暗石的。护船禁制先裂,水灌进去,姚丰只来得及带两名伙计离船。大半货被冲走,底舱那只扣在船骨上的药柜,多半还留着。

江明要的藤髓,便在里头。

“封柜能撑多久?”

“半月。今日第十一日。”

姚丰想把这批沉货一并作价卖掉。他只是筑基,进不去湾底的急流,另请金丹修士打捞,价钱又比他愿意出的高。

江明没有当场掏钱。

他先去了一趟湾口,沿两岸看过水势,又在浅处放下几块石子。石子坠了数丈,忽然一齐向西偏去。他换了三个位置,等到日头落在对岸的塔后,才回客舍。

“船向西翻了。你图上的地方,已经不准。”

姚丰的脸色一下难看。

江明指着图下的一道弯。

“我买剩下的货。药柜若碎了,我认;真捞上来,你也别再按一柜好货的价找我。”

姚丰坐了许久,终于将自己的印按在转货书上。

江明当面付清灵石,收了开柜的铜钥,又去买了一枚护水梭。

他剩下的钱,已经不多。

当年矿中的两株火神草,一株换了清魂藕,另一株也在数年前卖了,所得换成今日药匣里的几味辅药。两节清魂藕一直另匣封养,这回出门前重新验过,藕心仍银亮,药性未散,足够入他所收的这份黄芽方。

只差返真藤。

次日午后,江明下了水。

护水梭撑出窄窄一层清光,将迎面的浊流剖开。水底远比岸上所见暗,碎木、砂石混在急流里,一次次敲过光罩。

江明没有直往姚丰画的地方落。

他贴着东侧高起的河床前行,避过一股向下卷的暗涡,在水流转西的地方停住。神识往淤泥下一探,果然触到一条斜躺的船骨。

船已翻过来了。

江明绕着断开的舟尾游近,旧剑割断缠在舱口的几道粗索,才看见里面那只嵌着青铜角的药柜。

柜上的微光还在。

他刚伸手,左侧水中忽然有一片银芒翻起。

江明侧身,旧剑先横在胸前。

一根短鐧撞上剑脊,震得四周水浪猛地散开。护水梭偏出半尺,他的肩背撞到船骨,碎木顺着衣袖滑了过去。

来人金丹圆满,穿着贴身的黑水衣,腰间挂有三只已捞到的货囊。

江明在岸上见过他。姚丰说,此人名叫梁慎,先前出的价连原货值一成都不到,被他拒了。

“晚了一步。”梁慎隔水传音。

江明压住翻涌的气血,把铜钥在掌心一亮。

“我付过钱了。”

“那就找收钱的人要去。”

短鐧再次打来。

江明向舱外让,剑尖斜挑,逼得梁慎不能伸手抓柜。两人在翻覆的船底交了数次手,短鐧每一下都借水压砸落,江明脚下没有实地,旧剑被压得越来越近。

浅灰剑匣在背后一震。

江明催开匣口,青碧剑沿着自己已经用熟的浅路横出。清光切开迎面的水,截住短鐧,梁慎的身形第一次退了半丈。

江明却没有跟着追。

水涡从后方挤来,正好压在剑身尚未收回的那一段力上。剑器深处,一股陈旧的凶势受了牵动,顺着他放出的神识往外顶。

只要再往里送一点,眼前这一片水也许都能被劈开。

江明的指尖停住。

那股势先要把他的法力拖进一条陌生的深路。他感觉得到出口的强,却摸不清中间有多少地方,是由早已死去的人留下的意志在推动。

他将青碧剑压回了匣中。

梁慎看见清光消失,短鐧立刻追来,打中他左肋外的护身灵光。

江明闷哼,半边胸腹都麻了。

一线血从唇间溢出,转瞬被水冲淡。

他借着这一撞,反向船尾滑去。梁慎没有立即追,伸手抓住药柜的铜角,便要将它从船骨上拔下。

柜底的四道扣环绷紧了。

江明等的正是这一刻。

他忽然撤去护水梭外的法力,让那枚梭顺着暗流落进船骨间,自己以旧剑挑断尾部仅存的两根支木。

翻覆的药舟猛地向西一沉。

梁慎正拔着柜,身子顿时被带斜。急流从新裂开的船底灌入,将他与药柜压向同一边,短鐧不得不先向上撑,挡住砸下来的横梁。船骨扭折,柜底三道扣环接连崩开。

江明在船外转身。

他昨日查过,西边那道暗流贴底往下卷,出了旧河槽,才会向上翻。舟身往下沉时,他已经退到了槽外。

旧剑沿新开的裂口刺入。

这一剑只取梁慎握柜的腕,去得极快。梁慎缩手,剑锋仍划开水衣,鲜血从腕背涌出来。江明随即以法力牵住药柜,将它往自己这边猛拖。

药柜没有脱开。

还剩一道扣环。

梁慎的短鐧已经转了回来,江明没再硬挡,旧剑向下一压,切断最后一环,抱着柜子翻出河槽。

身后传来沉闷的一声碎响。

护水梭被船骨压断了。

舟身整个翻下去,横在两人之间。梁慎从另一侧冲出时,江明已顺着上翻的水流升出数丈,旧剑悬在身前,锋尖直对着他。

他一手抱柜、一手御剑,左肋每呼吸一下都在疼。

梁慎看得清楚。

可他自己腕上也正在流血,手中的鐧握得不如方才稳。柜外还有一口剑,那只刚刚收回去的青碧剑到底还能不能再出来,他也说不准。

两人隔水停了一瞬。

江明先往上升,剑锋没有偏。

梁慎终究没跟。他转身追向另几只被冲走的货囊,黑色身影消失在浊流中。

……

江明上岸后,先把药柜拖到背风处,坐了下来。

左肋肿起一片,幸而骨未断,呼吸却短。新买的护水梭丢在了水底,连碎片都没能带回来。

他吞过伤药,封住衣内渗血的一处,取铜钥开柜。

上层两只玉盒已经裂了,水沿盒缝浸进去,里面一把细根失了灵光。他将坏药挑到一旁,开最下面那只窄盒时,手上慢了些。

三阶返真藤仍在封蜡里。

江明拆开一角,以神识轻触藤心。浅白的活髓缩在外皮之下,灵性完整,正是他这份丹方尚缺的那一段。

他长长吐了一口气,牵痛了肋下,又笑了一声。

一柜药没有全保住。

他要的这一味,却真到了手里。

回客舍后,江明重新包好藤髓,将柜中还能卖的几样药另封。姚丰来看,先看药,又看他身上的伤,站了片刻,问水下有没有见着那根主桅。

“断了,别再去捞。”江明道。

姚丰点头。

两人都没提补钱。

江明在湾口养了五日,能稳稳提起一周法力后,才带着药启程回广秀。那口青碧剑仍封在匣中,浅灰匣身上的禁纹已经重新稳住,收着寻常漏气。

到山下时,他先去找周显。

周显见他把一只只药匣往案上摆,起初还有笑,看到最后那截藤髓,便坐直了。

“齐了?”

江明点头,将写了多年的药单铺在旁边。

纸上最后一个空圈,被他亲手涂满。

“这回,请你替我炼。”

第410章 剑外有路

周显没有在江明回山当日开炉。

他先让江明解开衣袍,看过左肋的伤,才收下那一份药。

“药齐了,人还没齐。”

江明把衣带系回去。

“我又没说今日吃。”

“你眼睛里就是今日。”

那一日,陈生还在远方。周显也没有往外寄信,请人替自己做这炉丹。

他已在元婴初期稳了许多年,丹道却仍是三阶。黄芽服气丹,他从陈生炉前学过,也在自己的炉里试过药性。如今江明将几乎全部积蓄换来的药交到他手中,他把最先写下的配次改了两处,又重新验了保存多年的清魂藕。

一月后,才开炉。

这一炉的浮热退得慢。周显没有强行收丹,多守了两日,等返真藤与几味辅药的气真正交合,才引着黄光离炉。

天边聚来的薄雷云打过几阵,被他护着丹一一接下。江明立在丹室外,看见最后一缕丹气收圆,自己扶门的手才松开。

一份药,成了一颗。

周显将玉瓶递给他时,脸上也有掩不住的疲色。

“与祖师那颗比,药力散得慢些。你收法力的时候,别抢在它前头。”

“这话,你已经说过四回。”

“那就再记一回。”

江明接过瓶,难得没有接着还嘴,郑重向他道了谢。

……

从落篷湾归来四个月后,江明去了宗外的伏泉山。

仍是那处经营聚灵洞的山场,管事却已换人。他付下四个月租金,另买一批供灵石,亲自看它们装进洞后的储灵槽。周显这次站在门外,等他走完洞里的一圈。

江明的肋伤已经养好。

他伸展手臂,将法力沿胸腹运过,没有一处牵滞,才将浅灰暗金剑匣递给周显。

“暂替我收着。”

周显没有马上接。

“你要用哪一口?”

江明拍了拍腰间的旧剑。

剑上那处小缺口,早在这些年的磨养中磨平了,刃口也因此窄了些。它没有青碧剑深处那股汹涌的剑势,握在掌中,法力却从哪里进、从哪里出,都清楚。

“水下那一回,我差点把自己也押进去。”江明道。

“压住了?”

“压住一次,不想往后回回赌自己压得住。”

他把匣子交到周显手里,没有再打开。

洞门随后合上。

江明盘坐石台,旧剑横在膝前。等心神静下来,他才倒出那颗黄芽服气丹,实实在在地吞了下去。

药力先沉,后涨。

他依着周显的提醒,没有一开始就将涌来的灵气全拉进丹田,而是让药力走过周身,接稳了自己的法力,才一点点回聚。

金丹悬在内里,光洁、圆满。

这颗丹陪了他许多年。离开侯府、远走边地,与人斗剑,挨了伤又养回来,俱靠它支持。如今气脉全部向内合拢,丹面出现第一道裂纹时,江明的十指也不由得收紧。

痛意从身中最深处涌出。

他想起昔日两位长兄争位,自己站在旁边,总先算退到哪里,才能不被下一股浪打中。那样做,让他活到了今日。

可眼下并没有另一个人,能替他拿走这道关口。

江明继续催动法力。

丹裂开了。

破裂后的精气冲散旧有周天,他眼前一暗,仿佛连自己坐在哪儿都不知道。惯常出剑时,心神总先落在手中锋上,此刻他也下意识向旧剑探去。

膝前传来一声轻鸣。

江明忽然停住了这一缕神识。

他把它收回体内。

没有剑,他这一身血肉、这些年养成的法力,仍然在这里。那些散开的精气之中,也仍有属于自己的明暗与起伏。

他从最熟悉的一点开始,重新辨认,牵引。

一片、一线,又一线。

外头的灵气被新生的气机卷进来,丹田深处渐渐出现了细小的轮廓。江明没有把它刻作一柄剑,也不再追着哪一道现成的剑势去塑形。

手足、眉目,在他的心神照见下逐渐清楚。

元婴成形的那一瞬,江明忽然听见了自己的呼吸。

很近,很长。

他睁眼,掌中涌起与旧日不同的法力。洞外的天色已经变了,头顶有雷滚过,石台上的灰尘一齐跳起来。

……

周显站在山道拐角,看见江明自己推开洞门。

他没有将剑匣送过去。

江明只提着那口旧剑,走到洞外临崖的石坪。新生元婴的气息尚有些浮动,每一步落下,都在重新适应这一身骤然厚重的法力。

雷光照到眼前,他抬剑。

第一道迎面落来的白光,被窄窄的剑势分向左右。余雷打在护身灵光上,江明向后滑了数尺,右掌贴着剑柄的皮肤随之烫起。

他换了一口气,站回去。

第二片雷并不肯照着第一片的落点来。

细雷先向两侧散,粗的一股却压在中间。江明如果再沿原来的剑路劈,只会先斩开虚处,正面的重雷仍要砸到头顶。

他将脚下一转,法力先护住腰腹,剑锋只取其中交接的一处。

一小片白光被斩散。

其余的雷擦过腿侧,烧开衣摆,石坪上留下两道焦痕。江明没有追着全部雷线挥剑,挪出落点,等下一击真正到了,再把能用的力送到锋上。

雷声越来越近。

他听见剑器内部轻轻响了一下。

旧剑毕竟只承过金丹时的法力,新催的元婴力与天雷一齐压来,剑锋已有细纹。江明立刻收细送进去的法力,仍迟了半息。

锋端断去一截。

那三寸剑尖飞进白光,顷刻碎成了几粒暗红的铁屑。

江明手上一空,迎面的雷已落到身前。

他没有朝山道喊剑。

剩下的剑身被他拉到侧面,护身法力全部向内一收,让正面的雷从头顶压落。耳中先是巨响,随即忽然静了。

天地还在亮,江明却听不见雷了。

右掌的皮肤裂开,血顺着残剑的剑柄往下流。他低头,看见自己的左膝已陷进碎石,又看见胸腹上的护体灵光尚未全散。

丹田内,那尊新生的元婴仍在。

他把断剑换到左手,慢慢站直。

失去声音以后,雷反而像只剩下一条条看得见的路。他知道不是天劫变轻了,右手的疼与胸中翻涌的血,都没有少。

只是不能再凭声音,猜哪一片先落。

他盯住云底的明暗,用尚能运转的法力护紧元婴,断剑从侧面挑开最先落下的一线。余雷压下来时,他已将脚步移向自己刚刚斩出的薄处。

电光沿肩背与腿侧游过。

他的身子晃了,却没有倒下。

再抬眼,云底终于透出了别的颜色。

……

周显走上石坪,先看了一眼江明手里的剑,才伸手扶住他的左臂。

江明偏过脸,看见他在说话。

听不清。

周显立即停了,掌心贴过他的后背,验明元婴与气脉还稳,才以神识传话。

“过去了。”

江明笑了一下。

他试着答,自己也不知声音有多响。周显却点了头,将伤药交到他的左手,把人扶回洞内。

供灵槽里的石已经耗去大半,剩下的继续留着养境。青碧剑与浅灰暗金匣被周显原样交回,放在江明自己够得着的地方。

江明用左手摸过匣扣,没有开。

右掌与指节的雷灼伤重,须慢慢养,双耳受震,眼下仍只能听见一点沉闷的回声。断剑剩下的剑身还在,剑穗焦了一端,他也一并留着。

元婴初期,却已经是真实的了。

两个多月后,他把洞中余下的供灵石收回,向管事交还玉牌,与周显离开伏泉山。

右掌已经长起新皮,握细物时仍会疼;听力恢复大半,左耳听远处的声音还发闷。周显一路说话,他时不时便叫对方再说一遍。

到广秀山门前,周显终于提高了声音。

“我是说,你今日想住哪儿!”

江明看着他,忽然笑了。

“听见了。你这么喊,全山都替我听见了。”

他将自己的剑匣往肩上提稳,指向祝霞山。

“先回去。我屋里还有东西,没收。”

第411章 自己的炉

从神都启程的第三个月,陈生到了焚城。

他在下一处舟站,将船上写好的那封报行程短笺交进传信铺,付过信资,比自己先送了过来。

城外的旧路修宽了,两边多出不少铺面。邵院门前那几级石阶倒还在,门换了一扇,铜环被擦得发亮。

陈生刚抬手,里面先传来一声喊。

“别压那里!”

紧跟着,是邵循的声音。

“你把炉往旁边移,火就不必绕这么大一圈。”

“现在下着药,怎么移?”

陈生站在门外,笑了一下,才叩了门。

门是邵循开的。

他看见陈生,手上拿着的铜钳停了片刻,随后往里一让。

“正好,你看看他。”

墨欢坐在院侧的石台边,面前立着一口旧铜炉,炉腹颜色深浅不一,左耳还留着从前修补过的接缝。一缕赤火贴着炉底走,经过侧边的窄口时,响得像细风穿竹。

他见到陈生,先是一怔,随即笑开了。

“前几日才收着你从神都动身的信。我还当得到下月。”

“船没停太久。”

“倒是你先前从祝霞寄的那封,没事还得写一遍,打赢二狗一掌。”

“怕你不知道。”

墨欢点点头,想站起来,炉内的药气却在此时一冲。他只好重新坐稳,把要说的话连同笑意一齐咽回去。

“先帮我压一下外火。”

陈生放下行囊,走到炉边。

这就是墨欢在留山第十八年买下的那口三阶旧铜炉。另一家器铺的旧货,品相不好,炉内禁纹却全,墨欢攒够钱,自己验过三回,才将它抬回了邵院。

青回炉在另一间器房,仍归邵循。

眼前赤火也不是早年那点灰白余焰。墨欢从南边火井付钱取了火种,回来分炼,试着与自己的丹火相接,折腾得院中响了好些时日。

陈生伸手,外面那股摇动的赤焰缓缓伏低。

墨欢随即起诀,将炉中两团药液分开半寸,腾出中间相济的一条路。两手一翻一合,法力厚实,已经不止旧信里那一层。

陈生看着他收稳药气,才道:“后期了?”

“前些年过的。你不来,信里还得一层层写给你看?”

嘴上不肯,墨欢眼睛里的得意却藏不住。

陈生没有替他接内火,只按着他说的位置,将外围赤焰压薄。墨欢自己牵引药性,原本互不相让的两团药液渐渐接上,最后落进炉底一只小盏。

他收好这一份试配的药液,才真正站起来。

“不是成丹。”他把小盏递到陈生眼前,“这两味我想这么接,你看。”

陈生看过,指着外围的一线浅色。

“这边再晚一点收。”

“晚过了,会抢里面的木气。前一回我就是这么毁的。”

墨欢立即翻出丹札,给他看试过的两次。陈生读到后头,又将小盏拿近,探过内里药性,点了一下头。

“你先抽了根边散气。照你这一手,不能按我方才说的收。”

墨欢的嘴角一下扬起来。

他伸手,把丹札拿了回去。

“留了一页空白,就怕你来了,没地方写。”

邵循在旁边道:“他才进门,你就惦记他的字。”

“我又不白给他看这一盏。”

陈生笑着坐下来。

三人将冷下来的铜炉移到石台另一侧。墨欢原先不肯挪,真正试过风口,发现火声小了些,便只说这一边摆药更顺手。

邵循没有拆穿他,提起铜钳,回去做自己的器。

他仍有元婴法力,一块难锻的铜片在掌下展平,却没有因为陈生来了,就把手头那份活停到明日。

……

午后,陈生与墨欢去了城南。

缓坡上草色微黄,几株矮树已经长高。墨沉的碑立在朝河的一侧,碑脚有新拂过的痕迹,没有长草盖住名字。

墨欢将带来的小壶放在地上,倒了一杯。

陈生在碑前站了很久,弯身将酒倾下。

“当年在守蔵室,承你照拂了。”

他只说了这一句。

墨欢蹲在旁边,把一截落在碑脚的枯枝拣开。等陈生直起身,他又斟了一杯,自己喝了。

“你问那两字,是临潮。”

“后来信里看见了。”

“册子还在。回去给你看。”

墨欢望向河面。

“大爷从那边回来后,又去过几座近城。有一回说看器,回来却买了三把竹扇,邵循嫌他不务正业,他还分了一把给邵循。”

陈生听着,嘴角慢慢动了一下。

两人没有在坡上等到天黑。墨欢把壶收好,与陈生沿旧路往回走,经过一家卖汤的小店时,忽然停住。

“吃点再回去。邵循今日忙,我也没做。”

陈生点头,和他坐到檐下。

墨欢要了两碗,另加一碟细笋,付钱时又嫌店家涨得多,问是不是连这回修路的钱也要收在汤里。

店家指着他。

“墨丹师,你上回来,还没添这笋。”

陈生低头喝汤,没有替他争。

墨欢把那碟笋往自己这边挪了一些。

“那我得多吃点。”

……

旅行册仍在墨欢屋里。

纸比从前旧了,外头换过一层护套,两张原拓作为折页夹在临潮后面,展开时,那片灰黄水印也跟着露出来。

陈生按住纸边,没有裁齐的那一条,比木板宽出一点。

“这里是他不许动的。”墨欢道。

陈生点头,看完,依原来的折痕收回去。

旧砚放在常用的桌角,有才洗去的墨迹;当年寄到焚城的复制拓另卷一处,没有同原拓混在一起。窗外,邵循把自己的椅子拖进廊下,墨沉留下的葫芦随椅背轻轻晃了两下。

当天夜里,他住进后来添盖的客房。墨沉从前那间屋,几年前已经由墨欢整理成药房,开门能闻见草木的气味。

陈生借着灯,把墨欢那两页药理看了一遍,在空处写下自己的看法。墨欢守在旁边,看到一处不同,便伸手指住,硬要他把“可再试”写成究竟怎样试。

到最后,陈生索性把笔递给他。

“你先写。写完,我再看。”

墨欢接了,真写到半夜。

此后几日,两人看药、论方,也去火井外的铺子走了一趟。墨欢早就买过一份四阶温元清露丹的旧方,此时拿出来,终于能把几个卡住自己的地方当面问清。

墨欢将问清的次序记下,又趁两人都在,把自己推不明白的另一段圈了出来。

第七日,陈生辞行。

墨欢送到院门外,手里还拿着最后那页修改的方子。看见陈生收好行囊,他忽然将纸卷起,伸手抱了他一下。

“下回我拿成丹给你看。”

陈生笑道:“我等着。”

“先说是我自己炼的。”

“知道。”

陈生走下石阶,向回广秀的商路去了。

墨欢看他走远,转身回院,先掀开自己的铜炉,看了看昨日留下的药气,随即将那页方子压到丹札下面。

邵循从器房问,他的新火还响不响。

“响。”墨欢说,“没有你问得响。”

他坐回炉前,自己笑了一阵,才抬手重新引火。

第412章 门内门外

邵循到丹院时,墨欢没有开门。

门外挂着停炉的木牌,牌角被风吹得轻轻敲墙。邵循站了一阵,把手里两块刚修好的炉垫放到阶上,自己推开侧门,进了院。

这是陈生当年回广秀留山后的第二百八十七年,初春。

焚城南边的街市已经扩过两回。墨欢不再住邵院,几十年前买下这处小院,添了丹室、药房,又给自己留了一间向阳的屋子。

邵循来得熟,仍嫌他门槛高。

“做这么高,防谁?”

屋里没人答。

他把茶煮上,端着杯子坐到廊边。自己的手比从前慢了些,提起壶盖时,要多用一根指头;元婴法力仍在,过去肯连守半月的器单,如今却常被他划掉一半。

“赶得急,另请人。”这是他近年的原话。

墨欢总说他懒,遇上费心的炉病,又还是把东西送过去。

那口留山第十八年买的旧铜炉,早已不在了。用到第九十一年,炉腹一条旧伤裂开,修过两次,仍漏火。墨欢终于将它按废铜卖掉,添上积蓄,另买了一口三阶深灰炉。

此后又修过火口、换过炉足,品阶始终是三阶。

今日送来的两块垫脚,就是给它用的。

邵循看了一眼丹室里封着的深灰炉,将杯放下。

墨欢这一回关的不是丹室。

……

多年以前,墨欢便养到了金丹圆满,却没有立即碎丹。

他试过几回聚起全身法力,旧周天一松,神识便先散;后来又有一回,尚未真正冲关,胸中气血已经走岔,养了整整一年。

最初那颗带浅痕的金汤水丹,就是在那一年服下的。

墨欢自己揭开旧瓶,盯着那点浅痕看了很久,终究把丹吞了。药力在往后的调息中渐渐化尽,空瓶洗净留作分药,再没有一颗旧丹替他存着从前。

他仍接单,仍炼丹,也仍给自己留修行的时日。

有一年,他赊下最后一味药,凑齐的一炉黄芽药料却在炉中合坏了,最贵的主药烧成黑渣。他心疼得三日没肯出门,第四日亲自去药铺结清料钱,又回来接了两份自己熟手的丹单。

日子并不总在吃老本。

那些丹换成药,那些药有的炼成,有的废掉,有的服进自己体内,逼着原先迟迟不能收稳的法力一点点安定。

到这一年,墨欢重新炼成一颗黄芽服气丹,留给了自己。

他将丹养过浮热,去城中租下半年聚灵阵盘,付清租钱,另买供灵石,亲自布在修行静室内。阵师来校过灵路,他在每一处该补的地方都付了材料钱,才把停炉牌挂出去。

邵循来之前,他已经服丹入定十一日。

……

午后,院内的灵气忽然一空。

邵循的茶水还在杯边晃,头已经抬了起来。

静室里,墨欢的金丹正在崩裂。

他没有再在最初那一阵痛里将散开的法力收回旧路。药力托着周身精气向内汇聚,碎丹后失去依凭的神识,在一阵晕眩之后重新定住。

这些年,他惯于在炉中找成形的一瞬。

轮到自己,才知道再熟的手也伸不进去,一切全靠心神留住。精气在丹田里聚散了几次,他额上青筋一根根绷起,终于在散开之前,定住了最中间的那一点。

细小的形体渐渐凝出。

不是炉中的药丸,而是眉目、手足俱全的一尊元婴,随他心意坐稳,接过原先金丹所承的一身法力。

墨欢张开口,吐出一口带血的浊气。

门外,邵循已经起身,将两块炉垫踢到廊下。

天上的雷云往丹院上方压来。

墨欢入关前,将看院子的事托给了他。邵循便守着外围,不让隔壁看热闹的人沿墙根靠近。静室的门开了,墨欢身周气息骤然一涨,自己跃到院后预留的空坪上。

邵循看着那个人影迎上第一片雷。

他原先也说过,墨欢若顶不住,宁可毁掉护身器,别再像平时炼丹那样斤斤计较。墨欢当时嫌他不说吉利话,如今新买的护身玉带真的裂开,手却没有去接碎玉。

雷光一次次照亮院墙。

邵循守在外头,看见墨欢低下去,又重新站直,看见他将刚凝成的元婴法力收紧,慢慢稳住原先因剧痛而散乱的手势。

最后的雷落时,墨欢坐了下去。

他双手按地,护体法力向内一合,身前铺开的光渐渐变薄。雷火从背上滚过,烧焦了一大片衣袍,他却一直留住了丹田中的那一点明亮。

云散后,邵循走到空坪。

墨欢还坐着,背上的伤渗血,双腿也软得站不稳。见他过来,却先抬起了右手。

掌中一缕元婴法力,清楚而稳定。

邵循看过,将伤药递了过去。

“够我看了。先治。”

墨欢笑了一声,自己接药服下。

元婴初期。

几百年里想过、怕过、付过那么多回代价的一步,如今真正到了。

……

五个月后,墨欢将租来的阵盘完好还回。

供灵石已耗尽,背上雷伤结过痂,脱去后留下一片淡红的痕。新境也在这几个月里养稳,三阶深灰炉重新开过两回,都是他接下的旧丹单。

这两笔工钱,加上先前余下的积蓄,终于让他付清了第四阶丹炉的租金。

炉在焚城东边一家丹坊的后院,墨欢看过禁纹、验过承火,交了押金,租用一个月。炉主收了钱,将外阵交给他,自己退出丹房,没有留在旁边替他主炉。

邵循这回只来看看。

案上摆的,是温元清露丹所需的一份药。

那张旧方,墨欢留了两百多年,纸早换抄过几次,旁边原有陈生的几处字,后来又添满了他自己的记号。

真正的四阶主药,却不是那时就有的。

一枚白纹果,他拿这些年攒下的三阶成丹换得,附药则分次买齐。前几日试炼一小段凝露髓,火收得急了,药性散去,他又从余钱里拿出一笔,买回足量的一段,才凑成今日这一份。

墨欢把药匣逐个打开。

邵循伸手掂了掂那张已经付清的租据。

“一个月,倒舍得。”

“炉子不是我家的,打坏还得赔。”

“知道就好。”

墨欢坐下,点起自己的赤火。井中火种这些年换过几回,都由他自己付钱取来分炼,如今与自身丹火相接,收放已远胜从前。

四阶炉中的火路比深灰炉宽阔得多,药液铺开后,他原先嫌太细的一段回火,才终于有地方完整走过。新生的元婴坐在丹田之中,法力不断送出,依旧有几处比他推演时更吃力。

第三日夜里,药气忽然向一侧倾。

墨欢的左手已经抬起,到了炉边,又停住。他将那一处火稍稍让开,宁可耗去一点外围药气,也没把尚未合拢的丹心硬压回原来的位置。

一线清露在丹心中出现。

此前看过、推过许多次的关节,终于由他自己接了过去。药性层层相合,炉中渐渐升起一股属于四阶丹药的气息。

天边有云来。

这回墨欢没有走向院后。他立在租来的炉前,护住那一团尚未脱胎的药光,迎着落下的丹雷抬起双手。

雷不是半年前自己过的那一场。

他要保的也多了一件东西。护体法力被细雷擦开时,墨欢右前臂烫得发麻,手却仍托在炉口,没有让雷直接穿入丹心。

邵循站在外阵边,挡住朝邻院游去的散火,没替他收丹。

等云气散去,墨欢将最后一线药光收拢。

一颗清润的丹丸落在掌上,内里仿佛含着一点不肯散的晨露。药性安定,灵气自成回转,再不是他从前只能在方纸上比画的形状。

温元清露丹,四阶。

墨欢看了很久,才装入瓶中。

这颗以养元婴为用的丹,由他亲手炼成,四阶丹师的门槛也由他自己跨了过去。

邵循走近,先看他的前臂。

“又烧着了。”

墨欢将袖口翻上去,涂过伤药,随后把丹瓶递给他。

“你先看这个。”

邵循看了,果然很久没说话。

两日后,墨欢验过炉内剩火,将租炉擦净、归还外阵,取回押金。唯一那颗新丹仍在自己瓶中,他准备再稳几日,留给自己的修行用。

回到丹院,他先把邵循送来的两块炉垫放到深灰炉足下。

旅行册、原拓与旧砚仍收在向阳屋里,复制拓另放。墨欢过去关好窗,将刚成的丹瓶放在自己的药架上,转身去取两只酒杯。

邵循已经坐在廊下,解下了那只仍在用的旧葫芦。

“今日的酒,你出。”他说。

墨欢把杯子往他面前一搁。

“行。先说好,不许只夸酒。”

第413章 这一层我到了

回广秀后的第三百一十一年,陈生的静室又封了一次。

这一回,他在里面待了九个月。

元婴腹中积存的法力,已经厚得将旧日几处回路压出沉响。养生经每走一周,筋骨中的温热便随之涨起,行到肩背,再回到胸腹时,元婴总要微微一动。

那一动,曾使他第十二年的冲关功亏一篑。

后来又试过两次,他一次撑得更久,一次提前收住,却仍没能越过去。数十年的药力与苦功,没因一次失败都消失,可门前多走几步,也不是门已经开了。

陈生如今不再像头一次那样,将能催出的法力一齐压上。

他把最外层收回,元婴仍端坐着,只将真正已经养稳的一股送进旧周天。血气从腰腿升起,法力沿筋骨去,回来的时候,他也没有急着把它全部归拢到胸前。

那股沉重,终于能在完整的一周里留住。

他又走了一回。

第三回,静室石门外的灰尘轻轻震了一下。

……

九个月前,陈生在院中收起了一颗新炼的玄黄温婴丹。

药不是神都带回来的旧匣。

这一份主料,他用了几年,拿自己承接的数炉四阶委托去换,仍补出了灵石。紫令堂来往的药铺已经换过掌事,价也不再是当年那个价;有一味到了手,灵性比约定的弱,他当面退了,没有为了赶一个自定的开关日期照单收下。

最后补齐,才有这一炉。

周显来过一趟,见他把丹放进自己的瓶里,问这次可要留人。

“你今日不是也有炉?”

“那份药可以晚一天。”

“晚了,我这边也未必刚好开门。”

周显听完,取回自己放在案边的丹札,走前又回头。

“祖师,我现在能等。但我也想再往前走一层。”

陈生笑了一下。

“那就别只看我这扇门。”

周显真的去开了自己的炉。

陈生将药稳好,择了次日服下。

药劲到腹中,依旧厚重。那时他右腕已经看不见第一次访幽河留下的肿线,掌骨却记得棺角压下来时,自己究竟在哪里先撑不住。

这些年他也出过山,承过别人的术法,争过自己要用的药。二狗去外海回来,有新东西便拿给他试;他有几次胜,也有输到不得不先开口的一次。

他不是只为了某一日把棺拖出来,才修这一个境界。

他自己想上去。

……

静室里,第四回法力经过了腰背。

陈生肩前微微一痛。

旧日这一痛出现,他往往先将前面那股力撤回来,护住最容易乱的地方。如今他知道那里的筋骨承得住,也知道后面还有没有未尽的回势。

他没有撤。

法力继续向前,血气沿着另一段先抵肩后,两者在原本总要迟一瞬的地方,相接了。

元婴腹中那一点动荡,忽然静了下去。

不是所有法力都停。

恰好相反,静室里的灵气向内涌来,他积下多年的那股重力也终于能持续往上走,不再到关口便散回四肢。

陈生额上有汗。

他撑着这一周,又撑下一周。丹药最后藏在经脉里的温厚之力随之融开,给了他早已不肯放松的那一段回转,最后一分余地。

一声低响,落在丹田深处。

端坐的元婴抬起了头。

它的气息向外伸出,经过筋骨,到了掌中,再被陈生缓缓收回来。这一次,收回的法力没有散薄,也没有需要他先压住的空涩。

元婴中期。

陈生仍闭着眼,嘴角却已经抬了起来。

他又坐了半个月,将新一层法力养稳,才推开石门。

二狗在树下等着。

桌上有两杯茶,一杯新添,另一杯已经凉了。他看见陈生,先向掌前那股未全敛净的气息望了一眼,便站了起来。

“到了?”

陈生点头。

二狗又看了一遍,笑着抬起手:“那试一掌。”

“就在院里?”

“你别砸树。”

陈生真的向前推了一掌。

金光很窄,落到二狗掌前,却比当年约满时厚了许多。二狗接上,鞋底往后擦了半寸,手腕随后一转,又将来势引到树外。

陈生肩背承住回来的力,没再退。

二狗收掌时,眼睛很亮。

“这一回,才好问真打。”

“我刚出来,你就要赢回来?”

“我等了不少日子。”

陈生笑出了声。

三百多年过去,二狗仍是元婴圆满。他去过更远的海域,找过化神的路,带回来的却不是一句从此必成的口信,而是自己越来越不同的一掌。

“我还得再走。”二狗道。

“我知道。”

“等你养稳,河边那一趟先去。然后,我去北海。”

陈生转脸看他。

二狗在杯边点了点。

“这回不为矿。我找到地方了,要亲去待一程。你别说等我回去替你看炉。”

“那要是炉先成了呢?”

“留丹,不留炉灰。”

两人都笑了。

次年春,陈生将中期法力真正养稳,才再次取出小玉玺。

他临行前把一封近况送入铺中,又给周显留下院里暂不用的两份普通药。新一炉的药钱仍要自己挣,北海的路也仍是二狗自己的路。

眼前这一步,两人却一起走。

陈生走出祝霞时,山门外的春水刚涨。

第414章 把棺接回来

洗龙河秘境的旧台,又缺了一段。

陈生望见时,先把脚下松动的碎石移开。二狗从旁边断阶取了两块厚石,落到台后,压住最容易被震开的那道缝。

河上没有棺。

陈生把小玉玺放到台心,以秘境灵息养住近岸,玲珑宝珠随之升起,清光沿着台前铺开。

二狗却没再站到他的正后方。

他先在石地上落下一道窄印,人往右侧移开,另一掌蓄在腰前。前印留下的距离不远,恰在陈生退步时,能先接住肩背的位置。

“这回,我先放好。”

陈生向他点头,踏出旧台。

清光在水上微沉。

寒意仍然来到脚边,他的日熙神照却已经顺着中期法力走过全身。那股回来的重力没有先压弯右肩,陈生站定,俯身把划开一线的指腹送到旧日的落点。

水声忽然低了。

赤金色沿着黑水深处的一道弯,缓缓亮起来。

陈生伸手。

那块深黑石角真正浮起时,他的掌根已经扣在旧纹外面,另一只手随即贴上去。血色与凤凰的金纹相接,棺角向下一沉,他的两条手臂却仍然稳稳留着。

二狗的新掌也到了。

前印托住陈生身后,后掌没有去碰棺,压在他的腰腿之外,随着他用力,向岸上合。

石角抬高了。

然后是一段棺沿。

陈生真正看见了那只凤凰的头。

黑水附在石纹里,像一条条往外绷紧的细线。金色的鸟翼每亮一处,那里便有水向下卷回,棺身却没有因为半截浮起来,就轻了半分。

陈生的脚往外滑了寸许。

二狗立即换位。

旧印的外沿散入风里,只剩窄核仍接着陈生后侧。新一掌从另一边合来,将方才滑开的那一点,再推回岸边。

河面响起一声沉鸣。

不是雷。

它从棺底穿过来,像极远处有水压住了一面大鼓,声音抵到手臂时,骨头也跟着震。

陈生右掌有血。

他手指先前的小口被棺沿撑开,血顺着掌骨流入金纹,金色随之亮过更多的翼羽。棺身却仍朝河里沉,两股反向的力将他的腕骨绷得发热。

“还能续?”二狗问。

“能。”

陈生没有再低头去看自己的血。

他将袖中的铁剑祭起,法力护着剑脊,斩过贴在自己掌前的一股黑水。水断开半瞬,又从旁边卷来,剑锋也被寒重崩去极细的一片。

这半瞬,棺又靠岸了一点。

陈生收剑,丹火从掌缘吐出,没有去炼石质棺身,而是护住自己血气刚被寒意压慢的那一段。日熙的暖意顺着骨中往前走,右腕终于不再麻木。

他把掌贴稳,肩背与腰腿同时发力。

棺侧离开了水面。

底下却有更深的一片黑色,随它翻起来,直朝岸边旧台压落。

二狗眼神一沉。

“脚别松!”

他撤掉前印最外一层,把自己人也让到台后。两道金光在陈生身外错时相合,不再只往前推,而是托着腰侧,沿厚石前面的空处向上。

旧台最前一段碎了。

陈生已经没有可退的那只脚。

宝珠清光承住了他,秘境的岸上灵气却在这一撞里散去大片,小玉玺所在的石面连着裂开两纹。陈生不能拿权柄去叫河停,只能握着棺,往二狗已经让出来的那一侧转。

他的右掌忽然松了一指。

棺身立即向下。

陈生左掌扣紧,身子随棺低了一下,随后将右手换到另一段已经离水的棺沿。血没有够到的那一处金纹,先暗了一瞬,又在他掌心贴上去时重新亮起。

这一换,终于不再只抓着一个角。

他把两只手之间的棺沿抬起来。

二狗也在同时推出了最后一掌。

金光从陈生身后合上,重重顶住下坠的回势。二狗自己脚下的厚石被挤得一响,左膝弯了,喉间那一口血却被他压住,没有先撤手。

陈生跨过断台。

第一步,棺底仍在河中。

第二步,黑水沿着凤凰尾羽向下滑。

到了第三步,两人掌中的重势终于向前一空。

深黑的石棺,轰然落在岸上。

二狗先收了半道印。

陈生跪在棺边,两手仍按着,掌中不断送出的法力却终于能停下来。他回头看河,棺底残留的黑水正从石地缝里退下去,没有把棺重新拖回。

他们把它接回来了。

……

过了很久,二狗才走到另一侧坐下。

他左膝的衣料裂了一道,右臂经脉也被最后那一掌的回势震得隐痛。陈生把药瓶推给他,自己先束住掌上的伤,再将棺周围的碎石移开。

棺还整。

原来朝水中展开的凤凰纹,此刻一片片收向盖沿,颜色比方才淡了。陈生探入的神识仍看不见里面,他却已能分辨出棺盖旁那一条很细的旧缝。

当年,也是血触了纹,它的翼才让开一点。

陈生把沾过血的手轻轻放上去。

金色向内一收。

盖沿真的松开了。

他的手停了一下。

从推棺入幽河那天起,他想过无数次,若它回来,自己先要说什么。如今它就在面前,那些想过的话却都不在了。

二狗坐在旁边,没有替他推。

陈生缓缓移开棺盖。

里面没有当年的衣裳,也没有一具仍睡着的旧身。

黑暗中,只有一点极弱的赤金色。

那一点亮意先缩了缩,仿佛太久没有见过外面的光。随后,它在棺底轻轻一动,向着陈生放在盖边的手,慢慢靠近。

陈生的呼吸忽然乱了。

“绿珠。”

他很轻地叫了一声。

那一点光停住,又向前动了一下。

他的手也随之发了颤。

河还在身后流。

这一回,陈生的眼睛,却终于不再只看着河水。

第415章 她要走的路

那一点光碰到陈生的手,停了下来。

他将神识收得极轻,像当年护住她最后一点真灵时那样,只在外面托着,不敢急着往内探。

光里有一点熟悉的气息。

很淡,薄得几乎留不住,却是他曾经握在手中、亲自送进这只棺的那一点。陈生的眼泪忽然落下来,滴在棺沿上,他自己却没有发觉。

赤金之光向上浮起少许。

他听见了一个极轻的声音。

“陈生?”

声音像隔着很远的门,不完整,后面便散了。

陈生把手伸稳。

“是我。”

她又向他靠近。

没有完整的身形,只有光里一瞬浮出的眉目。他记得那双眼睛看人时的样子,如今看见,却连最寻常的一句寒暖都说不出来。

“很久了么?”

“很久了。”

他缓了缓,才道:“我回过山,又出去过。后来到了元婴,去了神都,也找到了二狗。如今是中期。”

她静了一会儿。

光里那一点眉目,似乎在笑。

“你还是……慢。”

陈生笑了,眼泪却又落了一滴。

“是。你以前便嫌我看一页丹方,能坐到你回来。”

“我没有嫌。”

声音依旧很轻,这一句却让他心口猛地热了一下。

二狗坐在另一侧,听见最初那一声,便已经站起。他向陈生肩头按了一下,随后收起自己的药瓶,走到稍远的断阶旁。

河还在流,他守住那边松开的石地,没有把夫妻当年的话也听完。

……

陈生将光托在掌前,慢慢说起了凤凰棺。

星盒如何打开,手指如何被纹划破,古书又如何只留下那一行字。他当年瞒着她找出路,回院却说想着把她埋在哪里;到最后,他将她放入棺,护住这一点真灵,再把棺推入幽河。

这些事,她生前不知道。

如今,他都说了。

赤金色在他的掌边轻轻一动。

“所以……没有埋。”

“没有。”

她静了很久,陈生也没有催她。那一点光中的记忆并不连续,有时似乎认得他,有时又向棺中沉去,直到石上的凤凰纹亮起,才重新浮回来。

陈生试着将她托出棺沿。

光到了外面,立刻淡了。

他赶紧收手,把她放回金纹能照到的地方。没有身躯,也没有一周能自行运转的法力,他自己的血气可以守住手掌,却没有因此变成她的新生命。

“跟我回去,好不好?”

这句话,他仍然问了。

光里传来一点很轻的暖意,像她要回答,话却还未出来。

棺侧的金纹忽然动了。

那只凤凰的翅膀不再向幽河展开,尾羽却沿着棺底另一道细痕,缓缓亮向外面。细痕很窄,陈生看过去,里面没有河水,也没有能让他辨认的城池。

只有一点柔和的天光。

真灵在这光前,第一次没有向内缩。

陈生将手移近,神识却被挡在外面。那一点赤金色已经自己往前浮去,碰到尾羽时,忽然有极细的生机从光中长出来,又迅速隐了下去。

他不肯松开那只手。

“这边……是什么?”

她的声音似乎比方才清楚一点。

“我不知道。”陈生道。

他能看见变化,却仍不知道它通向何处,也不知道她若走过去,还会记得多少。古书没有替他答这些事,棺中那一点真灵更没有给他一套完整的办法。

“也许是你再活一次的路。”

陈生说完,喉头有些发紧。

“我想你回来。可是回来以后在哪里,过多少年,下回还能不能如此,我都还不知道。”

她向天光靠近一点,又停下来。

陈生的手仍在旁边。

过了很久,她轻轻问:“你很怕?”

“怕再松一次手。”

这一回,他没有说一个人也成。

真灵向他贴近,光中一只极淡的手,似在他的指间停了片刻。她的记忆还不完整,却已能将这个人认得清楚。

“我也怕。”

陈生听见了。

“这里太静……我想出去。”

他望着那一点光,手指渐渐松开。

她没有立即走,仍在他面前停着。陈生忽然想到,当年她说若有通天法力,要把那段时光截断封存;如今旧身已经不在,他真的找回了她,却仍有一条他不曾亲走的路。

他把一直托在外面的神识,也收回了些。

“绿珠,你自己走。我会去找你。”

“别只……找。”

她的声音又轻了,后面还有半句话。

“你的路……也走。”

陈生点头。

那一点赤金色终于越过他的指尖,进入凤凰尾羽照着的天光。

光没有散成烟。

它先缩成一点,又仿佛被一阵看不见的风托住,向着石棺之外明亮的空处,缓缓远去。

陈生走了两步。

他看见最后一线赤金色没入光中,随后,棺上那只凤凰的羽翼一片片收尽。裂纹从石底响起来,沿黑沉沉的棺面延伸,直到盖边。

一声轻响,旧棺塌了。

二狗立即过来,停在陈生旁边。

棺里没有第二点光,石上的金纹也已经熄尽。陈生将一片尚能辨出羽痕的黑石捡起来,旧血贴上去,石片没有再亮。

他握着它,站了很久。

……

两人又在空殿调息了两日。

陈生掌上的伤收住了,二狗膝下也能自如走动,臂中那点回震仍需慢慢养。临出秘境时,他们经过旧台,棺留下的一层石灰已经被风吹薄。

陈生没有将灰扫进河里。

他只把那一片黑石包好,收回袋中,带着小玉玺走出了秘境。

城外洗龙河上,春水明亮。

二狗看了一眼河面,问:“回山?”

陈生点头。

“先回去。你的北海,也别误了。”

“误不了。”

二狗向前走,步子还有一点不显眼的迟涩。陈生跟上,在河堤尽头回过一次头,随后便没有再停。

他不知道那一点真灵此刻去了哪里。

可是她要走的那一条路,已经真正走了出去。

第416章 叶知春

从陈生接回凤凰棺、再度离开洗龙河算起,又过去了三十四年。

叶知春三十二岁了。

她在下游小镇出生,小时候替娘守过晒药的簸箕,十七岁跟着一位游方丹师学手艺,后来搬到四九城,租下河街尽头一间窄铺。

她出生在陈生离河两年后的春天。

那时陈生不知道世上有这个孩子,她也不认识那个仍在山中修行的人。

如今她修到炼气九层,铺中常卖的几样一阶丹,已经能自己炼得很稳。离二阶还远些,药钱却得今日就付。

掌柜送来的新药摊在案上。

叶知春拿起一株,掐掉底下那一点松软的根,放到旁边。

“这株不算。”

送药的人皱眉。

“叶姑娘,你别每次都挑得这样细。”

“你下次不送坏根,我下次就少挑一次。”

人叹了口气,换过一株,从匣角又拨出两片药叶,说是添的。叶知春收下,把灵石当面数清,等人走了,才将门前的木牌翻成今日有丹。

她爹在里面做柜。

老人手艺不错,偏爱厚木,新柜刚钉到一半,已经把她屋里仅剩的空地占了大半。

“爹,不能再宽了。”

“薄了不经放。”

“宽了我没地方坐。”

他往屋里看了看,果然是这样,便把另一块板取过来,锯掉一条。

叶知春替他扶住。

木屑落了一地,她娘从灶边出来,先骂他们把饭吃了再弄,又把两只碗摆到院里。叶知春去端汤,袖口在门边挂了一下,她用力一扯,缝线顿时开了小口。

她低头看着,忽然站住。

有一个很旧的念头,从心里浮起来。

她仿佛也曾走过另一座院子,门里坐着一个人。那人看的不是她的袖子,而是一页怎么也看不完的丹方;她在外头忙了许多事,再回来,他仍坐在那里。

她已经不是头一次想起这种零碎的画面。

有时是一盏灯,有时是一段剑光,还有一个温和的声音,说把她埋在哪里也由他想。那些片段没有来处,她不能拿它们给客人看病,也不能用它们替自己结出金丹。

但画面中的人,她总觉得认识。

“知春,汤。”

娘在院里叫。

叶知春回神,端着汤走出去。

这两个人,从她小时候守药簸时便在身边。她记得爹冬天替她补鞋,记得娘为第一笔学丹的钱与舅家吵过,也记得自己炼坏第一炉药后,躲进被子里不敢起来。

这些事,比那些没有来处的画面,长得多,也真得多。

她坐下吃了饭,把坏掉的袖子缝好,又去开自己的炉。

……

陈生这一年回四九城,是替一位相识的丹师送药。

送药有酬,他原本也打算顺这条水路走一趟,便接了。出了广秀后,他在几座城里停过,见过适合收药的铺,也见过自己的药被人喊出贵了一倍的价。

这回他没有只沿河寻找。

有时候看一段水,他仍会想起那一点飞入天光的赤金色。但那片黑石如今只是一片黑石,不会指给他哪一家有一个已经长大的姑娘。

他也仍开炉、练掌、修行。

中期以后,又有新的难处。他多次走到更深的关口,仍会退回;一次炼药损失不小,二狗从北海来的信还笑他,总算不光说自己哪一局赢了。

信从近海驿铺转来,写得很短,却仍是二狗亲笔。

陈生把委托药送到城西,领清酬资,傍晚向河街走来。

新修的桥已经不是当年那座。桥头几间药铺关了,他却听见尽头窄门里还有炉声,门前木牌写着一味寻常的养气丹。

炉火收得很轻。

他在门口停了一下。

叶知春恰好托着玉盘出来,见一个白衣人立在木牌旁,先问买什么。

陈生往盘里看了一眼。

一阶丹,药性温和,尾段火略偏,却没有伤到丹心。

“你自己炼的?”

“铺子是我的,炉也是我的。”

她说完,察觉这回答有点硬,又笑了笑。

“您若想看药,进来就是。别站门外看木牌。”

陈生听见那笑,忽然抬起了眼。

她的眉目不完全像从前,衣料也不是记忆里的颜色。掌中玉盘很稳,眼睛正看着他,并没有等一个久别的人来认。

他却一时没能说话。

叶知春望着他,自己也静下来。

院中最后一缕丹香飘过门槛,一个先前只有声音、总看不清面目的旧人,忽然在那些零碎的画面里抬起了头。

她认出了这张脸。

不是因为城里贴过画像,也不是因为哪个师长拿他作故事讲过。她心里那盏灯旁,他本来就在;许多并不属于这间窄铺的日子,也忽然有了前后。

她手中的玉盘向下一沉。

陈生已经伸手托住了。

“烫不烫?”他问。

叶知春摇头。

她看着他,又看他托住盘的手。掌骨已经没有伤,指腹上那一片当年的血,也没有留下如今能辨认的痕。

她却慢慢叫出了一个名字。

“陈生。”

门后传来她爹的声音。

“知春,外头有人?”

叶知春一惊,将玉盘收稳。

“有客人。”

陈生没有往屋里走。他从袋中取出灵石,照木牌写的价,买了一瓶她刚炼的养气丹。

叶知春接钱时,手仍有些不稳。

“你……明日还来么?”

“来。”

“早些。我午后要回下游,给人看几株新药。”

陈生点头,收起自己的药瓶,往桥边走去。

叶知春望了很久,直到爹又叫,她才重新关住院门。

她回到炉前坐下,将双手放在膝上。

那座遥远的院子,那个看丹方的人,还有一段本来已经断了的生命,在她心里一点点清楚起来。

她想哭,又想立刻追出去。

但她自己的炉还温着,屋里两个人也正在等她。

叶知春擦了一下眼睛,将药盘收好,走进了屋。

“怎么了?”娘问。

她摇头,又抬起来。

“我明早要见一个人。”

第417章 先讲这一世

陈生来的时候,叶知春已经把炉擦净了。

她坐在院中,桌上有两杯茶。昨天那只玉盘留在旁边,药瓶已经收进柜里,她爹新做的柜,仍少一扇门。

爹娘去了下游。

这事她先说了,又道:“我本来今日也该去。推了半天,午后还得走。”

陈生在另一张凳上坐下。

“我知道。”

她望着他,开口时却不是叫昨天那个名字。

“你是不是很久以前,在一夜星光下,听我说过,若有通天大法力,就把那段时光截断、封存起来?”

陈生端茶的手停了。

她又道:“你在舞剑。星夜,很亮,地上有篝火。我离开的时候,你又拿起了一个盒子。”

他说不出话,先点了一下头。

叶知春眼里也有了泪。

“还有一次,你回来,说想着给我埋在哪里去了。我那时真以为,你只是在说埋。”

她记得那一夜自己先离开了。盒子里的秘密,则是在河岸重新有了一点意识时,才听陈生说起的。

如今,他那时说的话也慢慢回来了。

凤凰棺、幽河、很久的等待,还有他不肯松开的那只手。

她低头看自己的手。

手指上有学丹时烫出的浅痕,掌侧还有小时候从药架上跌下,留下的一点细疤。这些都不是旧日那具身躯的东西。

但两百多年的相伴,不是别人的记忆。

她记得自己怎样看过那个人,怎样与他走过四九城,怎样在越来越困的那一日,仍为留他独自一人而难过。

她也记得,这一世,爹把她背过冬天的河,娘守着那一炉第一次没有炼坏的药,笑得声音很大。

叶知春把茶杯放下。

“我想起你了。”

陈生的眼泪落下来。

昨日站在门外时,他不敢只凭一个笑、一声名字,就将所有希望压到她身上。如今她自己说起那一夜,眼前那些从出生开始长出的新日子,也没有因此消失。

“绿珠。”

他很轻地叫。

她应了一声。

过了片刻,又道:“但你在这条街问人,要问叶知春。不然大家不知道。”

陈生先点头,随后竟笑了。

“那我明日就问叶掌柜。”

“掌柜听着太老。”

她也笑,眼泪却顺着脸侧落了下来。

……

两人坐了很久。

陈生把她走后发生的事,挑着说给她听。二狗从受伤到失踪,又如何在黑崖重逢,秦林如何见过父亲留下的半面铜令,墨沉终于看见想看的石壁,却已在许多年前寿尽。

他也说自己怎样结了婴,怎样在初期门前待了那么久,最近这一次,又为什么去河边。

叶知春听得认真,有时追问,有时并不肯让他用一句“后来好了”便跳过去。

“你手上那一次呢?”

“哪一次?”

“从河里拉棺。你方才只说拉出来了。”

陈生低头看掌心。

“伤了。好了。”

她看着他。

他终于把那三步怎样走的,也说了。

叶知春伸手,触了一下他的掌骨,随后又慢慢收回来。

“我还记不全。河里很多日子,像没有日子;出来那一阵,你说的话,倒还记着一些。”

“不必急着都记起来。”

“我没有急。”

她抬眼,声音稍高了一点。

“是你看着我,像怕我下一句就不认识你。”

陈生被她说中,手里的茶放得有些慢。

“我怕。”

叶知春望着他,原本到了嘴边的一句便没再说。她把那杯已经冷了的茶换掉,自己也重新坐下。

院外有人喊价,一辆运药的小车从门前过去,车轮压过石缝,响了几声。

她忽然道:“那先说我的。”

陈生向她看过来。

“我不是从那一夜星光,直接走到这间屋里来的。”

“嗯。”

“爹做木活,娘种药。小时候我想学剑,嫌丹炉坐久了没意思。后来跟师父走,看见他给一个伤了经脉的人配药,才觉得也不是只坐着。”

她说到这里,自己有些不好意思。

“现在我也能坐得很久。”

陈生笑着点头。

她说了第一炉赔掉的钱,说了来四九城租铺时,爹还嫌这里离下游太远;又说这座城的客人总爱在丹出炉后才来压价,她忍过两次,第三次便将人请了出去。

陈生听着,有时问一句,却不替她把后来应该怎样也安排完。

这三十二年,他没有在场。

她正在把自己的日子,一点点讲给他听。

到了午前,叶知春起身,把墙边的一只药匣扣好。

“我该走了。”

陈生也站起来。

她忽然又停下。

“你住在哪里?”

“桥外客舍。”

“还要走?”

“手里这一趟事办完了,可以多住些日子。”

叶知春想了想,指向屋里缺门的柜。

“那明日来吃饭。爹总说这个柜好,你来了也许能劝他少钉一块板。”

陈生应下。

她提起药匣走到门前,回头又道:“还有一件事。”

“什么?”

“过去你炼丹,我等过不少回。这回你先等我。”

陈生站在院中,笑着点头。

“好。”

她真的走了。

陈生把两只茶杯洗好,将凳子推回檐下,没有替她关掉还要通风的炉室小窗。他走出窄门,在桥头看着那条向下游去的路,直到她的衣角隐入人群。

这一次,他知道她去哪里,也知道她为什么去。

傍晚,叶知春赶在暮光里回了城。

她先看见桥边站着的人,脚步不由快了两步,随后却又放缓,走到他跟前。

“等很久?”

“这一回,不久。”

陈生伸手接过她提着的药匣。

她没有马上松手。

过了片刻,她的手却从匣柄上移开,轻轻落在了他的手背上。

桥下的水向前流,两人一起朝那间仍亮着灯的小铺走去。

第418章 海上长啸

回山后的第三百五十六年,北海秋潮涨到洞门前。

二狗在这里已经修了四十余年。

来时他走过几座海岛,先给人护过商舟,又换到一段供元婴修士用的灵地。后来找到眼前这处海底灵势与岸上阵力相接的道场,他亲自试过供灵,付清租资,才把洞门关上。

其间出去过,也败过。

第一次尝试,他的神识撑不开最后那一点沉滞;第二次,他急着将新旧两股力量合圆,先伤了自己。那一年他在洞外看了三个月的水,手才不再发颤。

陈生曾从四九城写信来。

纸上说自己遇见了回来的那个人,她如今叫叶知春,三十二年已经有了自己的生活。后来的信,又说两人一起开铺,炼药、争价,也仍各有自己的修行。

二狗收到信时,在潮边坐了很久。

他把那封纸收好,回去再炼自己的印。

今日,洞口前没有人等他。

那只四阶承印环却已经在腕上亮起来。

环是他往海上寻器师后,用自己那条玄罡铁长芯作主料,另付辅材与工钱炼成的。成器后,他用了多年,改过一处不顺的转势;它能替身外的窄印多留一段回力,不能替他生出一尊元神。

二狗将它抬到胸前。

供灵槽中的光一层层升起,洞里蓄下的灵气被他引进周天。元婴端坐在丹田,法力已经养到了圆满的尽头,他却不再只往那尊小小的身影里添。

神识先落下去。

落到自己最难收住的那一处,落到长年独自修行时,仍然不肯被抹平的一点心意上。

他记得太平峰,也记得黑崖。

当年人在阵台上,肩背撑着一股不能放的力,稍松一点,就有东西朝身后的活人扑去。那种绷到不能换位的感觉,他后来已经用新印慢慢走开,却仍在心里留了很久。

此刻又到了眼前。

二狗没有将它斩掉。

他让自己看见那个人,再从那一掌上移开半步。

神识不再死扣一处,散出去的几段也没有全失。它们随着他自己的心意,重新朝着最清楚的一点聚来,与元婴深处的气息相接。

他胸口猛地一痛。

这一回,他没有急着将新生的力量压回旧形。

洞外的水先停了一瞬。

随后,天色暗下来了。

……

二狗走出洞门时,掌中已不是元婴圆满时那股气息。

一尊元神在他的神魂深处逐渐凝稳,刚刚成形,还薄,身外的天地灵息却已经随之有了回应。

雷从海上落下来。

他抬掌,道一印迎了上去。

第一片来势被截开,余雷沿着金光流下,钻进腕上承印环的转纹。环里留着的旧力向外震动,他将最外一层放开,没有连着全部回势一起拖进身上。

海面裂出一道白痕。

更厚的雷光接着压下,环侧忽然响了一声,裂纹贯过半圈。

四阶器,到了这里也有它的尽头。

二狗没再灌进去,收环,双掌在身前一合。

金印不是一齐碰到雷。

前印先截住正面,他自己往侧边走,后掌从下方推起,与留在半空的窄核相接。两股力在他身外合拢,最重的雷被带偏半尺,余下的光仍打在肩背,衣料顷刻焦了。

血从嘴角出来。

二狗抹掉,抬起眼。

新生元神在震荡中微微一缩,随后又被他收住。海底与岸上送来的灵气已经耗去大片,供灵槽里的光也开始断续,他知道自己还剩多少力。

最后一片雷下压时,他没有再把力铺满海面。

他只留住该留的那一点。

掌起,人前行。

雷光将身影吞没,礁石从脚下炸开,二狗的双膝沉了一沉,却没有跪下。元神在神魂深处立住,那一周新生法力也真正走过了全身。

天上的沉云向两边退。

他喘了很久,才将掌中金光收回来。

化神初期。

二狗站在碎礁上,望着仍在涨落的海水,忽然笑起来。

随即,一声长啸越过海面。

没有国师的仪仗,没有谁在台下称颂。

这一声,只属于他自己。

……

他又在道场里养了大半年。

肩背雷伤渐好,新元神从薄到稳,能自如收回身内,他才重新开门。承印环的裂纹仍在,他将它封入匣中,准备找原来的器师看。

第三百五十七年初夏,二狗到了最近的海洲信铺。

掌柜验过他的传送路引,问信要送哪儿。

“广秀仙宗,祝霞山。”

他写了几行,想一想,添上自己北海新落脚的转送处。

“生哥:

“这一层,我也上去了。化神初期,伤已好,元神也养稳。

“你若在四九,叫周显转给你。若回山,就自己来看。我不会只写信,把那一掌藏着。

“下一回真打,等你肯来问。

“二狗。”

他当面付清跨海转送的钱,看见信筒封好,才走出铺子。

街旁有酒卖,他买了一坛,不等留到给谁接风,当日便开了。

海风从窗外进来,二狗尝过一口,嫌淡,又倒一杯。

远处还有更深的海。

他如今可以去,今日却不急着去。他把封着裂环的匣放到手边,先将这坛真正给自己买的酒,慢慢喝了下去。

第419章 人间有春(终章)

第三百六十年春,四九城的新药铺开了门。

铺子不大,前面摆丹,后面有炉,院里栽着一棵刚高过墙头的树。匾是叶知春的爹做的,木很厚,写字的地方却被她亲自磨过,原先那个太占地方的旧柜,也换成了两个。

她爹的头发已经全白。

匾挂好,他站在门外看了很久,仍嫌边上少一条横木。叶知春没再劝,叫他先去吃饭,陈生则站在旁边,伸手托住了他想再钉的一块板。

“先不用。”

“你也嫌宽?”

“是她的铺子。”

老人看向门里。

叶知春正在收丹火,闻言笑了一声,连头也没抬。

她如今四十六岁。

九年前那一回,她用这些年存下的药与灵石,去推筑基的关口。事先请自己旧师验过所缺,临关时她自己守住心神,将法力引过新一周,最后从静室出来,气息已经不同。

陈生那时在门外,等她先将自己的这一程说完。

后来,她又开二阶的炉,败过两份,才真正收出第一颗能卖的丹。今日炉中的三粒丹丸浮起,她逐颗验了药性,收瓶、封口,才走到院里端自己的饭。

“你俩再站下去,娘要说饭凉了。”

陈生立刻把木板放回去。

……

他们重新决定共同生活,是在相遇后的第二年。

那时叶知春的铺子还在租,她要攒下一份新药,也要给爹娘在下游的屋修漏雨的顶。陈生往来过几次,见过她的师父,也听过爹娘说她小时候究竟有多不肯坐住。

有一天傍晚,他将那片黑石放在桌上。

叶知春摸过残留的一点羽痕,问他还有什么没讲。

陈生坐得很久,才开口。

“有一件,我以前没有告诉你。”

他告诉她,自己不会因寿数走到头。

不是靠一颗延寿丹,不是修到某一境才突然有了这个年月。他从很早以前,就已经不再被时间催着老去,却仍会受伤,会被人困住,也会失去握得很紧的东西。

“那时,你就知道我会先走?”

“知道。”

叶知春的手停在石片上。

“所以你去找那个盒子的办法,又总不说。”

陈生点头。

她低头看了很久,再抬头时,眼里的委屈没有全消。

“我以前还想,若是你晚些走,能少难过一点。”

陈生没有拿自己已经找回她来抵这句话。

“是我没说。”

“以后不能只说这个。”

她将石片推回他面前。

“你闭关过了多少年,你自己遇到什么,想做什么,都真说。我也要结丹,要炼药。我怕有一天又到头,可我不想这三十二年,往后这一百年,只变成你等过多少年。”

陈生伸手,慢慢握住她的手。

“好。”

他没有许诺凤凰棺还会再来。

那只棺已经塌了,旧石不再亮,前世最后一点真灵怎样进入新生,只有他们真正经历过的这一回。今后的路,他们都要自己走,也都可以同对方说。

第二日,她留陈生吃饭。

再后来,两人在河街继续开炉,存够了买院的钱,一人出一份,买下今日这间铺子。乌玄炉放在陈生用的炉室,她惯用的二阶新炉在另一边,不争一个炉口。

原来那口从旧货铺买来的三足炉,她已经卖给另一个刚学炼丹的人。

两人的日子真正添了新的东西。

……

陈生回广秀时,周显拿出一张存着的转送凭据。

那是二狗第三百五十七年从北海寄来的信。信跨海到了祝霞,周显照陈生先前寄回的新住处,又转去了四九城。原信一直带在陈生身边,纸上折痕已经很深。

“您还去找他打?”周显问。

“去。”

“他已经化神。”

“我知道。总得去看看,他那一掌到底与信上差多少。”

周显笑了一阵,把另一张新药单推过来。

他如今仍为药庐做事,却已养到了元婴中期。去年那次闭关,他用自己积下的修行,服下一份筹来的药,过了久久不肯松开的关口。丹道还在三阶里,案上的新方写满了他反复删改的字。

陈生验过药单,选了两味自己要用的,又按周显这次买入的价付钱。

周显收得很快。

“原来您也真嫌贵。”

“我以前是假嫌?”

院外,江明正同人试剑,听见这话,笑得剑光歪了一点。

他的元婴初期已经养得很稳。旧佩剑断去的锋端没有长回来,后来他自己配料重接,仍不与青碧剑混作一口。那口青碧剑的深残势,则在这些年反复洗养之后,只剩最后一缕。

这一日试完,江明将剑横在膝上,以自己已经熟悉的法力沿内里走过,将那一缕与己剑路始终不合的旧势逼了出来。

剑光淡了一瞬,又重新收稳。

他把残势封入旧匣另格,握住终于只剩自己御剑之意的剑柄,抬头看陈生。

“以后这口剑,不能拿里面还剩谁,替我打输了说话。”

陈生走过去,亲看过那道已经平顺的剑气。

“那就自己说。”

江明笑着将剑举起。

一道他这些年自己改出来的剑光,越过祝霞院外的树梢,清清楚楚,不带那个旧人的回声。

……

去年冬,墨欢与邵循也到过四九城。

墨欢如今元婴初期、四阶丹师,来时带着自己新成的一瓶丹。初成四阶时那颗温元清露丹,早已在修为养稳后服下,化尽药力。这些年他又开过许多炉,今日带来的瓶底,还沾着新药的香。

他在河街先看了叶知春的炉,又看陈生的乌玄炉。

“两口。”

“一人一口。”叶知春答。

“早该这样。他看方的时候,谁跟他抢都费劲。”

陈生从里面出来,正好听见,向他手中的药瓶点了一下。

“那你这回是让我看,还是让我买?”

“先看。价看完再说。”

邵循在旁边笑出了声。

他还是元婴,手比从前慢得多,急器单已很少接,自己想做的却仍没全放下。他将一只刚替墨欢修过的炉扣摆到桌上,嫌河街的桌子低,自己又搬了个高凳。

酒葫芦挂在他的袋边,旧系绳后来断过一回,是墨欢亲手照原来结法续的。

这只葫芦没有随墨沉一道埋去。

旧砚与旅行册也仍在墨欢自己的院中。临潮原拓保在册里,复制拓另存;册子的护套换过一回,那道大爷执意留着的水痕还在。

那一晚,墨欢同叶知春争了一处用火,两个人谁也没立刻服谁,陈生坐在旁边听,邵循却先去揭酒封。

笑声从炉室传出来,过了半夜,才慢慢散。

……

这一年春天,二狗也终于来了。

他从北海返陆,沿陈生回信所写的住处,到了四九城。店门前没有国师的车,只有他自己带来的一个酒坛,腕上的承印环已由原器师修稳,裂过的地方留着一道暗纹。

陈生出来,第一眼便看见他收在身内的元神气息。

化神初期。

那封折痕很深的信,还在陈生袋中。

二狗也看向他,随后朝门内那个正在收瓶的人望去。

叶知春抬起头,先静了一会儿,才向他点头。

“好久。”

二狗把酒坛放下,喉头动了动,却先问陈生:“她比你会挑酒?”

“你带的这坛,自己先尝。”

二狗大笑,袖子一卷便要开封。

陈生按住。

“等我收完这一炉。”

“当年二十年满,也是这句。”

“那日没开丹炉,今日真开了。”

二狗松手,自己到树下坐着,没把化神的名头摆到门外,让排队买丹的人先来认。

秦林的新信,是隔一日到的。

他仍在神都,元婴圆满,此次信中没有三十年的供药单。信只说春日沿河巡过一程,当年获救者家里的后人,如今已有自己收的学徒;又问陈生若回宫,能否把河街新卖的两种丹各带一瓶。

末尾另写给二狗:酒可带,朝务不还你。

二狗看完,笑着把信推回去。

“我也不要。”

陈生收好信,准备回一句:药照价,酒你请。

……

到了午后,新丹终于出炉。

陈生验过自己的那一颗四阶丹,先收回瓶中。他仍在元婴中期,下一层有下一层的难处,自己的丹也仍要用。他仍走得慢,却没有因为等到了一个人,就把往上的心思停了。

门外有人喊新药价太贵。

叶知春过去,把两瓶不同的丹并排放好,让对方自己选。那人低头看了很久,终于付钱拿了较便宜的一瓶,临走还要她添两片药叶。

她真添了,随后把钱放入柜中。

陈生坐到树下,二狗已经斟了酒。

叶知春忙完,端着今日新煮的汤出来,将一碗放在陈生手边。

“别只喝他的。”

陈生尝了一口。

是新的味道。

她看着他,问好不好。

陈生点头,给她拉过一张凳子。

院外的河水向前流,铺中炉火还在轻响。树上的新叶落下一点影,照着三只杯,也照着尚未收走的药瓶。

叶知春伸手,将他袖边一点药灰掸去。

陈生望向她,又转头看门外正在走过的人群,眼里慢慢有了笑。

他还会走很远的路。

这一日,他坐在人间,喝完了她新煮的那一碗汤。

全书完。

软考 架构师

计算机系统:用于数据管理计算机硬件、软件及网络

逆向工程

项目规划

无线烧录

无线烧录器:Linux 本地烧录网关方案

1. 核心定义

本方案将无线烧录器做成一台专用的嵌入式 Linux 设备,而不是远程 USB Hub:

浏览器 / 手机 / 产线系统
          │ HTTPS + WebSocket(Wi-Fi 或以太网)
          ▼
  自研 Linux 烧录器
  ├─ Web UI / API / 用户与任务管理
  ├─ 固件仓库、签名与日志
  ├─ Rockchip / Allwinner / NXP / MCU 烧录适配器
  ├─ USB Host、UART、SWD/JTAG、RESET/BOOT/PWR 控制
  └─ 本地执行实际烧录与校验
          │
          ▼
      被烧录的 MCU / SoC / 开发板

用户浏览器只上传固件、选择芯片配置并查看日志;烧录器下载/缓存文件后,在自己的 Linux 系统中调用封装好的烧录逻辑。其本质与一台连接了专用接口的 Linux 工控机相同,但可做成体积更小、接口固定、便于自动化和批量部署的设备。

这个架构可以覆盖多类型 MCU 与 SoC,且不需要 PC 安装 Rockchip、NXP、Allwinner 等工具。USB over IP 不是主链路,不做也不影响烧录能力。

2. 为什么这比“无线 USB Hub”更适合烧录

对比项Linux 本地烧录网关无线 USB 透传
实际烧录工具运行位置设备内,版本可控用户电脑,因系统而异
网络中断上传完成后烧录可继续可能中断 USB 会话
手机支持浏览器/App 可直接操作无法通用映射远端 USB
多厂商适配在设备端增加一个适配器即可还要兼容各桌面 OS 驱动
安全与审计固件、权限、日志集中管理文件与工具散落在客户端
量产可重复性高受客户电脑和驱动影响大

前提是:目标芯片有可用的本地烧录协议和可在 ARM Linux 上运行的工具,或可自行实现对应协议。RK Maskrom/Loader、Allwinner FEL、NXP i.MX SDP/UUU、串口 BootROM、DFU 与 SWD/JTAG 均符合该模式。

3. 产品边界

3.1 首版应提供的能力

  • Wi-Fi STA 模式连接现场路由器;AP 模式用于初次配网和无路由器现场。可保留千兆以太网作为产线和救援通道。
  • 局域网 mDNS 发现(例如 flasher-xxxx.local)和浏览器 Web UI;支持扫码打开设备地址。
  • 上传固件、选择目标 profile、开始/取消任务、实时日志、历史记录、导出结果。
  • 通过 USB Host 运行 SoC 的 USB BootROM 烧录;通过 UART 运行 ROM ISP;通过独立调试探针运行 SWD/JTAG。
  • 自动切换 RESET、BOOT/STRAP、目标电源和 USB VBUS,并执行写入后的读回/CRC/哈希验证。
  • REST/gRPC API 供产线脚本、MES 或 CI 调用。

3.2 明确不属于首版

  • 将任意远端 USB 设备无客户端地显示为 Windows/macOS/iOS 的物理 USB 设备。
  • 自行仿真或绕开 SEGGER J-Link 授权;需 J-Link 时接入真实 J-Link 或采购合规 OEM 方案。
  • 无芯片型号、无协议资料情况下声称支持某一个厂商的所有产品。
  • 直接把用户上传的 shell 脚本以 root 身份执行。

4. 芯片与板级硬件选型

4.1 主控推荐

原型和通用量产首选:RK3566/RK3568 SOM + 自研载板。

原因是其 Linux BSP、USB、PCIe/SDIO、eMMC 和网络资源适合运行多种命令行烧录工具、Web 服务及多个并发工位。第一版使用成熟 SOM 降低 DDR、PMIC 与高速信号风险;验证目标芯片覆盖率后再决定是否自研核心板。

备选:NXP i.MX 8M Mini/Plus SOM。

若更重视长期供货、工业温度和文档支持,可选该路线,但成本与开发周期通常更高。

不建议作为本产品唯一主控:ESP32-S3。

它适合低成本 UART/SWD 无线烧录器;但不适合承载通用 Linux 工具、多路 USB Host、固件仓库、网页服务以及 RK/Allwinner/i.MX 的统一烧录框架。

主控最低建议:4 核 ARM、1 GB RAM、8 GB eMMC、至少两个可用 USB 2.0 High-Speed Host 通道或一个 Host 加 HS Hub、千兆以太网、5 GHz Wi-Fi 接口。最终选型必须以具体 SoC 的引脚复用和 USB 端口拓扑为准。

4.2 推荐载板组成

模块推荐配置设计目的
核心板RK3566/RK3568 SOM,1 GB RAM、16 GB eMMC运行 Linux、工具链和镜像缓存
Wi-Fi已认证 5 GHz Wi-Fi 5/6 模组,PCIe 或 SDIO 接口,外置天线座上传大镜像与稳定的现场连接;避免首版自研射频
有线网1 GbE RJ45,可选 PoE PD产线稳定连接、Wi-Fi 故障恢复、远程维护
USB 下载口2–4 个 USB 2.0 HS Host;多口使用 USB2514B 等同级 HS Hub连接处于 RK Maskrom、FEL、SDP、DFU 等模式的目标
USB-C 口Type-C 仅作为 Host/供电源时,配 CC/Rp 配置与可控 VBUS正确识别 C-C 线和保护目标供电
串口下载口2–4 路 UART,3.3 V 默认,可选 1.8/2.5/5 V 电平转换BootROM 下载及独立日志通道
SWD/JTAG独立实时调试 MCU + 10-pin/20-pin 接口及转接线稳定产生调试时序,覆盖 ARM MCU
目标控制每工位独立 RESET、BOOT/STRAP、PWR_EN、VBUS_EN、检测 GPIO自动进下载模式、异常恢复、保护主控
保护USB ESD、TVS、每口限流高边开关、反接/过流保护防止样机短路、反灌电和插拔损坏
安全安全元件/TPM(可选)存放设备证书、密钥、烧录授权

4.3 为何 SWD/JTAG 要增加一个实时 MCU

Linux SoC 的 GPIO 可控制 RESET/BOOT,但不应直接承担高频、时序严格的 SWD/JTAG 波形。建议使用 STM32H7 或 LPC55Sxx 级 MCU,实现 CMSIS-DAP v2 或与主控约定私有的调试控制协议;Linux 主控负责调度和日志,调试 MCU 专注于实时接口。

如果首版只要求 USB BootROM/UART,则可以先不放调试 MCU;但“多 MCU 通用”基本都会需要 SWD/JTAG,建议在 PCB 上预留该模块和接口。

4.4 端口设计原则

  • 每个烧录工位必须有唯一、稳定的物理路径。Linux 通过 udev 按 USB 路径建立固定名称,不能依赖随机的 /dev/ttyUSB0。
  • 每个工位最好可以独立控制目标电源和复位;USB Host 的 VBUS 也需可单独开关,便于强制重新枚举。
  • BOOT/STRAP 必须按目标板要求做开漏或隔离,不能直接把 3.3 V GPIO 连到未知电平域。
  • USB BootROM、UART BootROM、SWD/JTAG 和调试日志应有明确的物理连接规范;将接线图编码为 profile 的一部分。

5. 软件架构与技术栈

5.1 系统软件

层推荐选型职责
嵌入式系统原型:Buildroot;量产:Yocto + Linux LTS最小系统、BSP、升级、开源许可证管理
设备服务Go 或 Rust 编写 flasherdAPI、任务状态机、并发/端口锁、审计与工具调度
Web UIVue/React 编译成静态资源,由 flasherd 或 Nginx 提供无需安装客户端的操作页面
通信HTTPS REST 或 gRPC;WebSocket 推送日志;mDNS 发现浏览器、移动端和产线系统接入
数据SQLite + eMMC 文件仓库任务、设备配置、日志和固件元数据
外设访问libusb、libgpiod、termios、udevUSB、GPIO、串口和稳定设备命名
运行监控systemd、watchdog、journald/结构化日志开机自启、异常复位、诊断

浏览器不直接连接 USB/串口。所有硬件访问都在 flasherd 和其受控的烧录适配器内进行,因此 Chrome、Windows、Linux、Android、iOS 的差异不会影响实际烧录。

5.2 烧录适配器层

每种芯片家族提供一个 adapter。adapter 接受经过验证的 profile 和固件,执行固定、可审计的步骤,返回统一结果,而不是把不同厂商工具的原始命令暴露给网页。

Adapter典型工具/协议连接方式
uart-bootesptool、STM32 UART bootloader、厂商 ISPUART + RESET/BOOT
swd-jtagOpenOCD、pyOCD、CMSIS-DAP调试 MCU + SWD/JTAG
dfudfu-util、厂商 DFU CLIUSB Host
rockchiprkdeveloptool/合规厂商 Linux 工具USB Host + Maskrom/Loader 控制
allwinnersunxi-fel/sunxi-toolsUSB Host + FEL 控制
nxp-imxUUU(按许可和 ARM Linux 支持验证)USB Host + SDP/fastboot

每个 adapter 统一支持:连接探测、进入下载模式、擦除、写入、验证、复位启动、超时、失败恢复、版本上报与结构化日志。

5.3 Profile(烧录配置)

多芯片产品的关键不是把工具堆在设备上,而是维护 profile。profile 至少要包含:

id: rk3568-maskrom-emmc-v1
vendor: rockchip
chip: rk3568
adapter: rockchip
port: usb-slot-1
boot_sequence: [power_off, boot_strap_on, power_on, reset_pulse]
images:
  - name: loader
    artifact: MiniLoaderAll.bin
  - name: system
    artifact: update.img
verify: tool_readback_or_crc
timeout_seconds: 300

profile 应受版本控制与签名保护。生产环境只允许选择已发布 profile;管理员可以新增 profile,但不能通过普通用户输入任意可执行命令。

5.4 任务状态机

已上传 → 校验通过 → 等待端口 → 切换下载模式 → 探测目标 → 擦除 → 写入 → 校验 → 启动 → 成功/失败。

状态与原始日志实时写入 SQLite/文件系统。浏览器断开、Wi-Fi 重连或页面刷新不会丢失任务;失败时保留适配器版本、profile、目标端口、电源状态和错误码,方便定位产线问题。

6. 网络与 Web 服务方案

6.1 联网方式

  • STA 模式:设备加入用户 Wi-Fi,通过 DHCP 获取地址;mDNS 发布设备名称。
  • AP 模式:首次配置或脱网现场由烧录器创建热点;用户连接后设置 Wi-Fi 凭据。
  • 以太网模式:工厂场景的优先选项,网络稳定、便于固定 IP/PoE 和集中管理。

支持 STA + AP 回退:若指定时间内无法连接已配置网络,开放配置热点,但不应在生产环境无认证地开放烧录权限。

6.2 Web/API

  • POST /api/v1/artifacts:分块/断点续传固件,并计算 SHA-256。
  • GET /api/v1/profiles:读取被授权的目标配置。
  • POST /api/v1/jobs:创建指定端口的烧录任务。
  • GET /api/v1/jobs/{id} 与 WebSocket:读取状态和实时日志。
  • POST /api/v1/devices/wifi:仅管理员可修改 Wi-Fi 配置。

镜像较大时使用分块上传和本地缓存;上传完成后再开始烧录。不要在浏览器上传流未结束时直接将数据转给目标板。

7. 首批兼容矩阵与验证方法

立项时应冻结实际样板,而非只列“RK、全志、NXP”。建议最少覆盖:

类别最小验证对象验证内容
UART MCUSTM32 或 ESP32 开发板自动 BOOT/RESET、下载、校验、串口日志
SWD MCUNordic/STM32/RP2040 任一板CMSIS-DAP、擦写、读回、复位
Rockchip一个指定 RK 型号开发板Maskrom/Loader 枚举、Loader、eMMC/NAND 写入与恢复
Allwinner一个指定全志开发板FEL 枚举、镜像写入和断电恢复
NXP i.MX一个指定 i.MX 开发板SDP/UUU、存储烧录、fastboot/启动验证

每个样板应连续执行至少数百次烧录并记录成功率、平均时间、异常恢复时间和失效原因。不同 PCB 的 Boot 拉脚、电源时序、USB Type-C 角色和安全启动设置不同,必须分别形成 profile。

8. 安全、可靠性与量产要求

  • 采用 HTTPS、管理员/操作员角色、设备唯一证书;生产网络不允许匿名任务提交。
  • 固件记录 SHA-256,可选签名验证;安全启动或熔丝操作只能由专用、授权 profile 执行。
  • 每工位互斥锁、超时、USB 重新枚举、目标掉电恢复和工具进程看门狗是必需功能。
  • 电源设计要防止目标板反向向 USB 口供电;单端口过流后不能影响其他工位。
  • 使用 A/B OTA 或可恢复的系统升级机制;保留可通过以太网/串口救援的路径。
  • 记录固件哈希、profile 版本、操作者、起止时间、目标识别信息和最终结果,满足追溯需求。

9. 实施顺序

  1. 硬件 PoC:RK3566/RK3568 SOM、认证 Wi-Fi 模组、一个 USB Host、一个 UART、一个目标控制口;先打通 Web 上传 + STM32/ESP 烧录。
  2. SoC 适配 PoC:针对已经选定的 RK、Allwinner、NXP 三块目标板,在设备端运行各自工具并固化 profile。
  3. MVP 载板:增加 USB HS Hub、2–4 工位控制、调试 MCU、千兆网、电源保护和 eMMC 存储。
  4. 产品化:账号/签名/审计、OTA、产线 API、自动化回归工装和射频/EMC/温升测试。

10. 当前需要决策的项目输入

要冻结原理图和软件 backlog,需要项目方给出:

  1. 首批必须支持的精确芯片型号与对应开发板,尤其是 RK、全志、NXP 的具体型号。
  2. 每类目标的固件格式、存储介质、是否已开启安全启动、所需下载接口与 Boot/RESET 时序。
  3. 需要的并发工位数、单镜像最大大小、期望烧录时间和目标成本。
  4. 是否允许目标供电由烧录器提供、各板卡的电压/最大电流及连接器要求。
  5. 是否需要内网远程管理、云端设备管理、MES/CI、固件签名和完整审计。

在这些输入明确之前,RK3566/RK3568 SOM + Linux 本地适配器是风险最低的启动选择;USB 透传可以保留为后续可选功能,而不进入第一版架构关键路径。

多协议无线烧录器:技术方案与可行性分析

1. 结论

这个项目可实现,建议把产品定义为:通过局域网发现、管理和操作的多协议烧录/调试网关。设备接收固件后,在本地通过 UART、SWD/JTAG 或目标板的 USB BootROM 协议完成烧录;桌面端可额外提供“远程 USB 设备”能力。

不能把 Wi-Fi 端的设备描述为“无需软件即可成为电脑物理 USB Hub”。USB Hub 是连接在电脑物理 USB 总线上的设备;Wi-Fi 侧能实现的是 USB over IP(远程 USB),需要电脑端虚拟主机控制器/驱动或商业客户端。Linux 最容易支持,Windows/macOS 要安装并维护客户端,iOS 不支持把任意远端 USB 外设注册为系统 USB 设备。

最稳妥的主路径是“上传固件后本地烧录”,而不是把每个 USB 包实时通过 Wi-Fi 转发。这对产线烧录、网络抖动、断线恢复、日志留存和安全性都更可靠。

2. 范围与兼容性边界

2.1 第一阶段应支持的目标

目标类别典型接口/协议实现方式可行性
STM32、GD32、ESP、ATSAM 等 MCUUART Bootloader、USB DFU、SWD/JTAG本地命令行烧录器或专用调试 MCU高
Nordic、RP2040、NXP LPC/MCX 等 MCUSWD/JTAG、USB/UART ISPCMSIS-DAP/OpenOCD + 厂商工具高
NXP i.MX 系列 SoCUSB SDP、UUU、UART/fastboot设备端运行 UUU/libusb 流程高,按型号验证
Rockchip RK 系列Maskrom/Loader USB、串口设备端运行 rkdeveloptool/厂商 Linux 工具中高,按芯片和 Loader 验证
Allwinner 系列FEL USB、串口sunxi-fel/sunxi-tools中高,按芯片验证
其他 USB 启动 SoC厂商 USB BootROM 协议为每个厂商编写适配器取决于工具授权与协议公开度

“支持某厂商”不等于支持其全部型号。必须按 芯片型号 + 板级启动方式 + 固件格式 + 所需权限/密钥 建立烧录配置(profile)和回归测试矩阵。

2.2 不应承诺的能力

  • 不承诺 iOS 将远端任意 USB 设备显示为系统本地 USB 设备;iOS App 仅应承担发现、上传、任务控制和日志查看。
  • 不承诺未授权地仿真 SEGGER J-Link。若客户需要 J-Link 生态,使用真实 J-Link、SEGGER 合法 OEM 方案,或提供 CMSIS-DAP/OpenOCD 路径。
  • 不承诺 USB 等时流(摄像头、音频)和所有私有 USB 驱动都能稳定透传。烧录所需的 Control/Bulk 设备是 USB over IP 的优先范围。

3. 总体架构

PC / macOS / Linux App          Android / iOS App
  mDNS + HTTPS/WebSocket           mDNS + HTTPS/WebSocket
             \                         /
              +------- Wi-Fi / Ethernet -------+
                                                  \
                             无线烧录器(Embedded Linux)
                         ┌──────── 控制与任务服务 ────────┐
                         │ 认证、文件库、队列、日志、校验 │
                         └───────┬───────────┬───────────┘
                              烧录适配器    USB/IP(可选)
                         ┌───────┼───────────┼───────────┐
                         │ UART  │ SWD/JTAG  │ USB Host  │
                         │复位脚 │ 专用探针  │ Hub/端口  │
                         └───────┴───────────┴───────────┘
                                             │
                MCU BootROM / 调试口 / RK Maskrom / i.MX SDP / Allwinner FEL

任务流程:客户端发现设备 → 认证 → 上传固件及 profile → 设备端校验 SHA-256/签名 → 锁定目标端口 → 复位并进入下载模式 → 执行对应烧录器 → 读回/CRC 校验 → 返回结构化日志与结果。断线后任务在设备端继续,客户端重新连接即可读取状态。

4. 推荐硬件方案

4.1 主控:采用 Linux SoC,而不是单独使用 ESP32

通用 SoC USB 烧录需要运行厂商 CLI、libusb、USB Host 和文件/日志服务;ESP32-S3 适合作为低成本串口/SWD 产品,但不适合作为 RK、Allwinner、i.MX 等通用 USB 烧录主控。

方案推荐用途优点代价/注意事项
RK3566/RK3568 + 自定义载板推荐的通用量产主方案Linux 生态成熟、算力和 USB/PCIe 资源充足、适合 5 GHz Wi-Fi需逐一核对所选封装/SDK 的 USB Host 端口拓扑;首版建议先用 SOM
NXP i.MX 8M Mini/Plus + 自定义载板长供货、工业项目文档、生命周期和工业支持通常更好成本较高,板级开发周期更长
ESP32-S3 + 调试 MCU仅 UART/SWD/JTAG 的低成本型号功耗和成本低,开发快不作为多类 SoC USB 烧录与 USB 透传主平台

建议先以 RK3566/RK3568 SOM 开发原型,配 1 GB RAM、8/16 GB eMMC。量产再将 SOM 或已验证的核心板集成到自研载板。选择最终型号前,以“实际可同时使用的 USB Host 数、Wi-Fi 接口、供货周期、Linux BSP”作为硬性门槛,不能只看芯片的 USB 标称数量。

4.2 关键器件与接口

模块建议原因
Wi-Fi经过认证的 5 GHz Wi-Fi 5/6 模组,优先 PCIe/SDIO 方案;可评估 AP6256、QCA9377 同级模组使用已认证模组可降低射频和合规风险;2.4 GHz 仅作为兼容,不作为高速目标
有线网络1 GbE RJ45,最好带 PoE 或至少预留产线、调试和 Wi-Fi 故障恢复需要稳定回退链路
USB Host至少 2 个独立 USB 2.0 High-Speed Host;需要多口时用工业级 HS Hub(如 USB2514B 同级)RK/Allwinner/i.MX 的下载常以目标 SoC 的 USB Device 形式连接本机 Host
USB-C/供电USB-C 5 V 输入,独立大电流 DC/DC;每端口限流高边开关、ESD 与过流检测目标板上电、枚举异常与短路隔离
串口2–4 路 3.3 V UART,带可配置电平转换和 TX/RX/RTS/CTS覆盖常见 BootROM 和日志通道
SWD/JTAG20-pin Cortex Debug、10-pin Cortex Debug、2x5 1.27 mm 等转接线;电平检测与双向缓冲各开发板的接口不同,不能只留一种插座
专用调试控制器STM32H7/LPC55Sxx 级 MCU,独立处理 SWD/JTAG 时序和 RESET/BOOTLinux GPIO 不适合承担严格实时的调试波形;可实现 CMSIS-DAP v2 或作为自研探针
目标控制每个工位独立 RESET、BOOT0/STRAP、PWR_EN、VBUS_EN、检测 GPIO自动进入下载模式,并避免多板互相影响
隔离与保护USB ESD、可选 USB/调试信号数字隔离、TVS、保险丝/电子保险、反接保护面对来源不明的样机和产线误接时必须保护主设备
安全存储TPM/安全元件(ATECC608/SE050 同级)或 SoC 安全存储保存设备证书、密钥和授权凭据

若要求 USB 3.x 透传,必须使用具有实际 USB 3.x Host 通道、对应 SuperSpeed Hub/连接器以及 5 GHz 高带宽 Wi-Fi 的硬件;第一版不建议把它作为必选项。多数 SoC BootROM 烧录使用 USB 2.0 已足够。

4.3 调试口和烧录口的建议分工

  • USB Host 口:专门连接进入 Maskrom/FEL/SDP/DFU 的 SoC/MCU,供本地 libusb 工具使用。
  • 专用 SWD/JTAG 口:由实时调试 MCU 驱动,不与 Linux GPIO 直接复用。
  • UART 口:可同时承担 ROM 下载和日志采集;下载期间应由模拟开关/多路复用器避免互相抢占。
  • 独立目标供电:每个工位可软件上电/下电,并采样电流。不可默认由 USB VBUS 给所有目标板供电。

5. 软件技术栈

5.1 设备端

层级建议技术职责
系统Yocto(量产)或 Buildroot(原型)+ Linux LTSBSP、最小根文件系统、升级与许可证管理
底层访问libusb、libgpiod、termios、USB serial、专用 MCU 通信协议USB、GPIO、串口、探针控制
烧录工具OpenOCD/CMSIS-DAP、dfu-util、STM32CubeProgrammer CLI(注意许可)、esptool、pyOCD;rkdeveloptool;UUU;sunxi-tools每类 target 的实际下载、擦除和校验
任务服务Rust 或 Go(推荐)任务队列、端口互斥、超时、恢复、审计和结构化日志
APIHTTPS REST/gRPC + WebSocket上传、进度、实时日志、设备配置
数据SQLite + 本地对象目录任务状态、日志、固件元数据、固件缓存
发现与安全mDNS/Bonjour、TLS、设备证书、RBAC、固件签名局域网发现、鉴权、防篡改
运维A/B OTA、健康检查、远程日志导出安全升级、故障追踪

烧录流程不应开放“上传任意 shell 命令并执行”。使用版本化 profile 描述允许的工具、参数、接线、复位序列、校验策略和超时;自定义扩展必须由管理员签名或白名单控制。

一个 profile 的核心字段可包括:target_vendor、chip_model、transport、tool_version、boot_pins、image_layout、erase_policy、verify_method、timeout、required_adapter。这样才能实现可追溯的多芯片支持。

5.2 客户端

  • 桌面端:Flutter 或 Electron/React 做界面;设备通信采用 HTTPS/WebSocket。烧录任务实际在设备端运行,PC 不依赖本机安装每一家厂商工具。
  • 移动端:Flutter/原生均可,职责为发现、认证、上传、任务选择、扫码绑定与日志查看;不承担系统级 USB 透传。
  • CLI/API:提供 CI/产线接口,支持上传、启动任务、订阅结果。比单纯 GUI 更适合批量化。

6. USB over IP / “USB Hub”功能

6.1 正确实现模型

烧录器充当 USB Host,目标设备连接在烧录器的 USB 口。设备端的 USB/IP 服务导出这个 USB 设备;PC 客户端加载虚拟 Host Controller 后,将其枚举为本机 USB 设备。网络中传输的是 USB 请求,而不是 Wi-Fi 自动携带一个“物理 Hub”。

平台可行性建议
Linux高USB/IP 生态最直接,适合先验证
Windows中需可维护的客户端/签名驱动;商业方案往往更省工程成本
macOS中需独立适配和版本回归,不能只按 Linux 结果承诺
Android低App 可网络控制,但不能可靠地注册全局远端 USB 设备
iOS/iPadOS不支持系统级通用映射只提供 App 网络控制

6.2 选型建议

先完成本地烧录主链路,再做 Linux USB/IP PoC,使用 RK/Allwinner/i.MX 各一块目标板验证枚举、下载、异常断开和重连。若 Windows/macOS 的“像本地 USB 一样使用”是商业刚需,应评估成熟商业 USB-device-server SDK(例如 VirtualHere 同类方案)的许可、驱动签名和长期系统兼容成本;不要在第一版自研跨平台内核驱动。

推荐优先级:

  1. CMSIS-DAP v2 + OpenOCD/pyOCD:标准化、可控,覆盖大量 ARM MCU。
  2. 厂商 CLI 或 BootROM 下载:用于量产烧录,通常比调试协议更快、更稳定。
  3. 真实 J-Link 的远程使用:客户已有 J-Link 生态时作为选配;遵守 SEGGER 授权与分发条款。

不要把“兼容 J-Link”作为第一阶段目标。J-Link 协议、授权、桌面工具行为和用户期望都会显著放大开发、合规和售后成本。

8. 可靠性、性能与安全要求

  • 固件采用分块上传、SHA-256 校验、可选签名验证;任务与日志必须持久化。
  • 每工位独占锁;USB 重枚举、目标掉电、Wi-Fi 中断都要有明确的状态机与可恢复错误码。
  • 下载完成后至少执行工具返回值、读回校验或目标侧 CRC 三层中的两层;安全启动芯片还应验证签名/熔丝操作权限。
  • 使用 WPA2/WPA3、TLS、设备证书和角色权限;生产环境禁止匿名烧录和任意固件导出。
  • Wi-Fi 高吞吐不能替代可靠性设计。大镜像建议先缓存到 eMMC 后再本地刷写,避免空口中断造成目标设备处于半刷状态。
  • 设定可量化指标:支持芯片清单、最大镜像、单工位/多工位并发数、成功率、烧录时间、恢复时间、日志保留期。

9. 推荐研发阶段与验收物

阶段 A:需求冻结与样机验证

  • 冻结首批 6–10 个“真实芯片 + 开发板”组合,而不是只列厂商名。
  • 使用 RK3566/RK3568 SOM + 5 GHz Wi-Fi + USB Host Hub + STM32H7 调试板搭建验证平台。
  • 验证 STM32/ESP(UART/SWD)、RK(Maskrom)、Allwinner(FEL)、NXP i.MX(UUU)各至少一个目标。
  • 输出每条烧录链路的接线图、烧录 profile、工具许可清单和性能数据。

阶段 B:MVP

  • 实现设备发现、登录、上传、任务状态、日志、断线恢复和固件校验。
  • 完成 2 路 UART、1 路 SWD/JTAG、2 路 USB Host 及 RESET/BOOT/供电控制。
  • 发布桌面 GUI、移动端控制页和 CLI/API;只对 Linux 交付 USB/IP 试验功能。

阶段 C:量产与扩展

  • 自研载板、射频/ESD/电源/温升测试、认证模组与法规评估。
  • 扩展 profile 与自动化回归板架;引入校准、序列号、工位权限和审计。
  • 根据真实商业需求决定 Windows/macOS USB 虚拟化和 J-Link 选配是否投入。

10. 立项前必须补齐的信息

  1. 首批必须支持的准确芯片型号、板卡和数量;每种芯片的启动口、BOOT/RESET 电平与固件格式。
  2. “USB Hub”是为了哪一种具体烧录工具/设备,而非泛化诉求;是否允许安装 PC 客户端和驱动。
  3. 目标端口数、并发数、最大镜像、可接受单板烧录时间和预计年出货量。
  4. 是否需要离线产线、远程云端、账号体系、固件保密、签名烧录和安全熔丝。
  5. 成本、尺寸、供电、温度、认证、长期供货及售后期限。

以上信息明确后,才能冻结 USB 拓扑、SoC/SOM、Wi-Fi 模组、调试 MCU、端口数量和软件支持矩阵。

多协议无线烧录器:技术方案与可行性分析

1. 结论

这个项目可实现,建议把产品定义为:通过局域网发现、管理和操作的多协议烧录/调试网关。设备接收固件后,在本地通过 UART、SWD/JTAG 或目标板的 USB BootROM 协议完成烧录;桌面端可额外提供“远程 USB 设备”能力。

不能把 Wi-Fi 端的设备描述为“无需软件即可成为电脑物理 USB Hub”。USB Hub 是连接在电脑物理 USB 总线上的设备;Wi-Fi 侧能实现的是 USB over IP(远程 USB),需要电脑端虚拟主机控制器/驱动或商业客户端。Linux 最容易支持,Windows/macOS 要安装并维护客户端,iOS 不支持把任意远端 USB 外设注册为系统 USB 设备。

最稳妥的主路径是“上传固件后本地烧录”,而不是把每个 USB 包实时通过 Wi-Fi 转发。这对产线烧录、网络抖动、断线恢复、日志留存和安全性都更可靠。

2. 范围与兼容性边界

2.1 第一阶段应支持的目标

目标类别典型接口/协议实现方式可行性
STM32、GD32、ESP、ATSAM 等 MCUUART Bootloader、USB DFU、SWD/JTAG本地命令行烧录器或专用调试 MCU高
Nordic、RP2040、NXP LPC/MCX 等 MCUSWD/JTAG、USB/UART ISPCMSIS-DAP/OpenOCD + 厂商工具高
NXP i.MX 系列 SoCUSB SDP、UUU、UART/fastboot设备端运行 UUU/libusb 流程高,按型号验证
Rockchip RK 系列Maskrom/Loader USB、串口设备端运行 rkdeveloptool/厂商 Linux 工具中高,按芯片和 Loader 验证
Allwinner 系列FEL USB、串口sunxi-fel/sunxi-tools中高,按芯片验证
其他 USB 启动 SoC厂商 USB BootROM 协议为每个厂商编写适配器取决于工具授权与协议公开度

“支持某厂商”不等于支持其全部型号。必须按 芯片型号 + 板级启动方式 + 固件格式 + 所需权限/密钥 建立烧录配置(profile)和回归测试矩阵。

2.2 不应承诺的能力

  • 不承诺 iOS 将远端任意 USB 设备显示为系统本地 USB 设备;iOS App 仅应承担发现、上传、任务控制和日志查看。
  • 不承诺未授权地仿真 SEGGER J-Link。若客户需要 J-Link 生态,使用真实 J-Link、SEGGER 合法 OEM 方案,或提供 CMSIS-DAP/OpenOCD 路径。
  • 不承诺 USB 等时流(摄像头、音频)和所有私有 USB 驱动都能稳定透传。烧录所需的 Control/Bulk 设备是 USB over IP 的优先范围。

3. 总体架构

PC / macOS / Linux App          Android / iOS App
  mDNS + HTTPS/WebSocket           mDNS + HTTPS/WebSocket
             \                         /
              +------- Wi-Fi / Ethernet -------+
                                                  \
                             无线烧录器(Embedded Linux)
                         ┌──────── 控制与任务服务 ────────┐
                         │ 认证、文件库、队列、日志、校验 │
                         └───────┬───────────┬───────────┘
                              烧录适配器    USB/IP(可选)
                         ┌───────┼───────────┼───────────┐
                         │ UART  │ SWD/JTAG  │ USB Host  │
                         │复位脚 │ 专用探针  │ Hub/端口  │
                         └───────┴───────────┴───────────┘
                                             │
                MCU BootROM / 调试口 / RK Maskrom / i.MX SDP / Allwinner FEL

任务流程:客户端发现设备 → 认证 → 上传固件及 profile → 设备端校验 SHA-256/签名 → 锁定目标端口 → 复位并进入下载模式 → 执行对应烧录器 → 读回/CRC 校验 → 返回结构化日志与结果。断线后任务在设备端继续,客户端重新连接即可读取状态。

4. 推荐硬件方案

4.1 主控:采用 Linux SoC,而不是单独使用 ESP32

通用 SoC USB 烧录需要运行厂商 CLI、libusb、USB Host 和文件/日志服务;ESP32-S3 适合作为低成本串口/SWD 产品,但不适合作为 RK、Allwinner、i.MX 等通用 USB 烧录主控。

方案推荐用途优点代价/注意事项
RK3566/RK3568 + 自定义载板推荐的通用量产主方案Linux 生态成熟、算力和 USB/PCIe 资源充足、适合 5 GHz Wi-Fi需逐一核对所选封装/SDK 的 USB Host 端口拓扑;首版建议先用 SOM
NXP i.MX 8M Mini/Plus + 自定义载板长供货、工业项目文档、生命周期和工业支持通常更好成本较高,板级开发周期更长
ESP32-S3 + 调试 MCU仅 UART/SWD/JTAG 的低成本型号功耗和成本低,开发快不作为多类 SoC USB 烧录与 USB 透传主平台

建议先以 RK3566/RK3568 SOM 开发原型,配 1 GB RAM、8/16 GB eMMC。量产再将 SOM 或已验证的核心板集成到自研载板。选择最终型号前,以“实际可同时使用的 USB Host 数、Wi-Fi 接口、供货周期、Linux BSP”作为硬性门槛,不能只看芯片的 USB 标称数量。

4.2 关键器件与接口

模块建议原因
Wi-Fi经过认证的 5 GHz Wi-Fi 5/6 模组,优先 PCIe/SDIO 方案;可评估 AP6256、QCA9377 同级模组使用已认证模组可降低射频和合规风险;2.4 GHz 仅作为兼容,不作为高速目标
有线网络1 GbE RJ45,最好带 PoE 或至少预留产线、调试和 Wi-Fi 故障恢复需要稳定回退链路
USB Host至少 2 个独立 USB 2.0 High-Speed Host;需要多口时用工业级 HS Hub(如 USB2514B 同级)RK/Allwinner/i.MX 的下载常以目标 SoC 的 USB Device 形式连接本机 Host
USB-C/供电USB-C 5 V 输入,独立大电流 DC/DC;每端口限流高边开关、ESD 与过流检测目标板上电、枚举异常与短路隔离
串口2–4 路 3.3 V UART,带可配置电平转换和 TX/RX/RTS/CTS覆盖常见 BootROM 和日志通道
SWD/JTAG20-pin Cortex Debug、10-pin Cortex Debug、2x5 1.27 mm 等转接线;电平检测与双向缓冲各开发板的接口不同,不能只留一种插座
专用调试控制器STM32H7/LPC55Sxx 级 MCU,独立处理 SWD/JTAG 时序和 RESET/BOOTLinux GPIO 不适合承担严格实时的调试波形;可实现 CMSIS-DAP v2 或作为自研探针
目标控制每个工位独立 RESET、BOOT0/STRAP、PWR_EN、VBUS_EN、检测 GPIO自动进入下载模式,并避免多板互相影响
隔离与保护USB ESD、可选 USB/调试信号数字隔离、TVS、保险丝/电子保险、反接保护面对来源不明的样机和产线误接时必须保护主设备
安全存储TPM/安全元件(ATECC608/SE050 同级)或 SoC 安全存储保存设备证书、密钥和授权凭据

若要求 USB 3.x 透传,必须使用具有实际 USB 3.x Host 通道、对应 SuperSpeed Hub/连接器以及 5 GHz 高带宽 Wi-Fi 的硬件;第一版不建议把它作为必选项。多数 SoC BootROM 烧录使用 USB 2.0 已足够。

4.3 调试口和烧录口的建议分工

  • USB Host 口:专门连接进入 Maskrom/FEL/SDP/DFU 的 SoC/MCU,供本地 libusb 工具使用。
  • 专用 SWD/JTAG 口:由实时调试 MCU 驱动,不与 Linux GPIO 直接复用。
  • UART 口:可同时承担 ROM 下载和日志采集;下载期间应由模拟开关/多路复用器避免互相抢占。
  • 独立目标供电:每个工位可软件上电/下电,并采样电流。不可默认由 USB VBUS 给所有目标板供电。

5. 软件技术栈

5.1 设备端

层级建议技术职责
系统Yocto(量产)或 Buildroot(原型)+ Linux LTSBSP、最小根文件系统、升级与许可证管理
底层访问libusb、libgpiod、termios、USB serial、专用 MCU 通信协议USB、GPIO、串口、探针控制
烧录工具OpenOCD/CMSIS-DAP、dfu-util、STM32CubeProgrammer CLI(注意许可)、esptool、pyOCD;rkdeveloptool;UUU;sunxi-tools每类 target 的实际下载、擦除和校验
任务服务Rust 或 Go(推荐)任务队列、端口互斥、超时、恢复、审计和结构化日志
APIHTTPS REST/gRPC + WebSocket上传、进度、实时日志、设备配置
数据SQLite + 本地对象目录任务状态、日志、固件元数据、固件缓存
发现与安全mDNS/Bonjour、TLS、设备证书、RBAC、固件签名局域网发现、鉴权、防篡改
运维A/B OTA、健康检查、远程日志导出安全升级、故障追踪

烧录流程不应开放“上传任意 shell 命令并执行”。使用版本化 profile 描述允许的工具、参数、接线、复位序列、校验策略和超时;自定义扩展必须由管理员签名或白名单控制。

一个 profile 的核心字段可包括:target_vendor、chip_model、transport、tool_version、boot_pins、image_layout、erase_policy、verify_method、timeout、required_adapter。这样才能实现可追溯的多芯片支持。

5.2 客户端

  • 桌面端:Flutter 或 Electron/React 做界面;设备通信采用 HTTPS/WebSocket。烧录任务实际在设备端运行,PC 不依赖本机安装每一家厂商工具。
  • 移动端:Flutter/原生均可,职责为发现、认证、上传、任务选择、扫码绑定与日志查看;不承担系统级 USB 透传。
  • CLI/API:提供 CI/产线接口,支持上传、启动任务、订阅结果。比单纯 GUI 更适合批量化。

6. USB over IP / “USB Hub”功能

6.1 正确实现模型

烧录器充当 USB Host,目标设备连接在烧录器的 USB 口。设备端的 USB/IP 服务导出这个 USB 设备;PC 客户端加载虚拟 Host Controller 后,将其枚举为本机 USB 设备。网络中传输的是 USB 请求,而不是 Wi-Fi 自动携带一个“物理 Hub”。

平台可行性建议
Linux高USB/IP 生态最直接,适合先验证
Windows中需可维护的客户端/签名驱动;商业方案往往更省工程成本
macOS中需独立适配和版本回归,不能只按 Linux 结果承诺
Android低App 可网络控制,但不能可靠地注册全局远端 USB 设备
iOS/iPadOS不支持系统级通用映射只提供 App 网络控制

6.2 选型建议

先完成本地烧录主链路,再做 Linux USB/IP PoC,使用 RK/Allwinner/i.MX 各一块目标板验证枚举、下载、异常断开和重连。若 Windows/macOS 的“像本地 USB 一样使用”是商业刚需,应评估成熟商业 USB-device-server SDK(例如 VirtualHere 同类方案)的许可、驱动签名和长期系统兼容成本;不要在第一版自研跨平台内核驱动。

推荐优先级:

  1. CMSIS-DAP v2 + OpenOCD/pyOCD:标准化、可控,覆盖大量 ARM MCU。
  2. 厂商 CLI 或 BootROM 下载:用于量产烧录,通常比调试协议更快、更稳定。
  3. 真实 J-Link 的远程使用:客户已有 J-Link 生态时作为选配;遵守 SEGGER 授权与分发条款。

不要把“兼容 J-Link”作为第一阶段目标。J-Link 协议、授权、桌面工具行为和用户期望都会显著放大开发、合规和售后成本。

8. 可靠性、性能与安全要求

  • 固件采用分块上传、SHA-256 校验、可选签名验证;任务与日志必须持久化。
  • 每工位独占锁;USB 重枚举、目标掉电、Wi-Fi 中断都要有明确的状态机与可恢复错误码。
  • 下载完成后至少执行工具返回值、读回校验或目标侧 CRC 三层中的两层;安全启动芯片还应验证签名/熔丝操作权限。
  • 使用 WPA2/WPA3、TLS、设备证书和角色权限;生产环境禁止匿名烧录和任意固件导出。
  • Wi-Fi 高吞吐不能替代可靠性设计。大镜像建议先缓存到 eMMC 后再本地刷写,避免空口中断造成目标设备处于半刷状态。
  • 设定可量化指标:支持芯片清单、最大镜像、单工位/多工位并发数、成功率、烧录时间、恢复时间、日志保留期。

9. 推荐研发阶段与验收物

阶段 A:需求冻结与样机验证

  • 冻结首批 6–10 个“真实芯片 + 开发板”组合,而不是只列厂商名。
  • 使用 RK3566/RK3568 SOM + 5 GHz Wi-Fi + USB Host Hub + STM32H7 调试板搭建验证平台。
  • 验证 STM32/ESP(UART/SWD)、RK(Maskrom)、Allwinner(FEL)、NXP i.MX(UUU)各至少一个目标。
  • 输出每条烧录链路的接线图、烧录 profile、工具许可清单和性能数据。

阶段 B:MVP

  • 实现设备发现、登录、上传、任务状态、日志、断线恢复和固件校验。
  • 完成 2 路 UART、1 路 SWD/JTAG、2 路 USB Host 及 RESET/BOOT/供电控制。
  • 发布桌面 GUI、移动端控制页和 CLI/API;只对 Linux 交付 USB/IP 试验功能。

阶段 C:量产与扩展

  • 自研载板、射频/ESD/电源/温升测试、认证模组与法规评估。
  • 扩展 profile 与自动化回归板架;引入校准、序列号、工位权限和审计。
  • 根据真实商业需求决定 Windows/macOS USB 虚拟化和 J-Link 选配是否投入。

10. 立项前必须补齐的信息

  1. 首批必须支持的准确芯片型号、板卡和数量;每种芯片的启动口、BOOT/RESET 电平与固件格式。
  2. “USB Hub”是为了哪一种具体烧录工具/设备,而非泛化诉求;是否允许安装 PC 客户端和驱动。
  3. 目标端口数、并发数、最大镜像、可接受单板烧录时间和预计年出货量。
  4. 是否需要离线产线、远程云端、账号体系、固件保密、签名烧录和安全熔丝。
  5. 成本、尺寸、供电、温度、认证、长期供货及售后期限。

以上信息明确后,才能冻结 USB 拓扑、SoC/SOM、Wi-Fi 模组、调试 MCU、端口数量和软件支持矩阵。

无线烧录网关:Web 页面与设备识别方案

1. 目标与原则

无线烧录网关是一台带 USB、UART、SWD/JTAG 和目标控制电路的嵌入式 Linux 设备。Web 页面运行在浏览器中,用于查看工位、上传固件、选择烧录配置、启动任务和查看日志。

浏览器不直接识别目标设备。 目标设备接在烧录网关上,插拔、协议探测和烧录均由网关 Linux 后台完成;后台把标准化的状态和事件通过 HTTPS/WebSocket 发送给页面。这使 Windows、macOS、Linux、Android 和 iOS 都可使用同一套 Web 界面。

设计原则:

  • 以“物理工位”为中心展示,而不是只罗列 USB 设备。
  • 区分“已连接”“初步识别”“协议确认”“可烧录”,避免误判。
  • 所有烧录工作在网关本地完成;浏览器断开不应中止已开始的烧录。
  • 所有探测、任务、日志和结果可追溯。

2. 总体数据流

目标板 / 外部烧录器
        │ USB、UART、SWD/JTAG、RESET、BOOT、PWR
        ▼
嵌入式 Linux 网关
 ├─ udev / libusb:USB 插拔和描述符识别
 ├─ 串口、SWD/JTAG、BootROM 协议探测
 ├─ 工位控制:电源、复位、启动脚、VBUS
 ├─ flasherd:状态机、任务、适配器、日志和权限
 └─ HTTPS REST + WebSocket + mDNS
        │ Wi-Fi STA/AP 或以太网
        ▼
浏览器 Web UI / 手机 Web UI / 产线系统

3. Web 页面信息架构

3.1 首页:工位总览

首页应显示所有物理接口工位,例如 USB-1、USB-2、UART-1、SWD-1。用户进入页面后首先能判断设备是否插好、处于何种模式以及是否可烧录。

页面元素显示内容可执行操作
工位编号USB-1、UART-1、SWD-1 等固定物理路径进入工位详情
接口类型USB Boot、UART Boot、SWD/JTAG、外接调试器无
当前状态空闲、已接入、待识别、可烧录、烧录中、失败重新探测、断开/重置
已识别设备Rockchip Maskrom、STM32 DFU、J-Link、CMSIS-DAP、未知设备查看识别依据
目标信息芯片系列、芯片 ID、存储介质、置信度选择/确认 Profile
最近任务固件版本、结果、耗时、操作者查看日志或重新执行

推荐卡片式状态颜色:灰色为空闲,蓝色为已连接/待操作,黄色为待确认或异常,绿色为成功,红色为失败,紫色为烧录中。颜色必须辅以文字,不能仅依赖颜色表达状态。

3.2 工位详情页

工位详情用于单板操作,包含以下区域:

  1. 连接与识别:物理路径、USB VID/PID、序列号、串口参数、SWD IDCODE、最近一次探测时间和原始探测日志。
  2. 目标控制:目标电源、VBUS、RESET、BOOT/STRAP 当前状态;提供“断电重试”“进入下载模式”“重新枚举”等受权限控制的按钮。
  3. 烧录配置:可选 Profile、固件文件、镜像分区、擦除策略和验证方式。
  4. 任务执行:开始、取消、实时进度、当前步骤、耗时和结构化日志。
  5. 历史记录:该工位最近任务以及使用的 Profile/固件哈希。

3.3 其他页面

  • 固件库:上传、SHA-256、签名状态、版本、适用芯片与删除策略。
  • 任务中心:队列、并发状态、成功率、失败分类、日志导出。
  • 适配器与设备:列出网关识别到的 J-Link、CMSIS-DAP、ST-Link、USB-UART 等外设及其序列号。
  • Profile 管理:仅管理员可发布、启用、停用或回滚烧录配置。
  • 系统设置:Wi-Fi STA/AP、以太网、账号、证书、时间同步、网关升级和诊断包导出。

4. 设备识别实现思路

识别应分为“物理设备识别”和“目标芯片协议确认”两层。前者快速、无侵入;后者可能需要切换 BOOT/RESET 或发送探测命令,应由用户授权或 Profile 控制。

4.1 USB 设备与烧录器识别

Linux 后台用 udev 订阅 add/remove/change 事件,并以 libusb 或 sysfs 读取:

  • USB VID/PID、bcdDevice、厂商名、产品名、序列号;
  • USB 接口类别、端点、已绑定驱动;
  • 总线号与端口拓扑路径;
  • 设备节点,例如 /dev/ttyACM*、/dev/ttyUSB*、/dev/bus/usb/*。

识别库维护一份签名表:VID/PID + 接口类 + 可选产品字符串 映射为设备类型,如 Rockchip Maskrom/Loader、Allwinner FEL、NXP SDP、STM32 DFU、J-Link、CMSIS-DAP、ST-Link 或 USB-UART。

不能仅使用 /dev/ttyUSB0 这类动态名称。应以 USB 物理路径或 udev 规则建立稳定别名,例如:

USB 根端口 1-2.3  →  usb-slot-1
USB 根端口 1-2.4  →  usb-slot-2
/dev/serial/by-slot/uart-1  →  uart-slot-1

这样设备重新枚举后,页面仍能对应正确工位。

4.2 USB BootROM 目标识别

当 RK、Allwinner、NXP i.MX 或 MCU 进入 USB BootROM/DFU 模式时,通常会以特定 VID/PID 枚举。后台按识别规则匹配后显示“初步识别”,并可调用对应 adapter 执行只读探测:

  • Rockchip:确认 Maskrom/Loader 状态和目标信息;
  • Allwinner:确认 FEL 握手与芯片信息;
  • NXP i.MX:确认 SDP/UUU 可连接状态;
  • DFU:读取 DFU 接口能力和设备描述符。

只有探测成功且匹配已发布的 Profile 时,状态才变为“可烧录”。若仅发现普通 USB 枚举,页面应显示“未知 USB 设备”,不能声称已识别出芯片型号。

4.3 UART 目标识别

UART 没有类似 USB 的插拔/枚举机制。网关能确认的只有“USB-UART 适配器或板载 UART 已可用”,不能天然知道线缆另一端是什么 MCU。

推荐流程:

  1. 工位配置候选 Profile,例如 ESP、STM32 UART Boot、NXP ISP。
  2. 用户选择“自动探测”或指定 Profile。
  3. 网关按受控时序控制 PWR、RESET、BOOT/STRAP。
  4. adapter 向 BootROM 发送对应的安全握手命令。
  5. 根据响应、芯片 ID 或 ROM 版本确认目标;失败则恢复电平并显示原因。

页面状态应为“UART 已连接,目标待确认”或“STM32 UART Boot 已确认”,而非仅凭串口存在就猜测目标型号。

4.4 SWD/JTAG 目标识别

先识别探针,再识别目标:

  1. 用 USB 描述符与序列号识别 J-Link、CMSIS-DAP、ST-Link 等探针。
  2. 使用 CMSIS-DAP/OpenOCD 或合规的厂商工具读取 SWD DP IDCODE、JTAG IDCODE、芯片唯一 ID 或目标存储信息。
  3. 根据 IDCODE 映射芯片系列;存在歧义时要求用户选择精确型号/Profile。

调试扫描可能影响部分低功耗目标或需要上电,所以页面应提供“自动扫描”开关;生产工位默认只扫描已授权的目标类型。

4.5 识别置信度与手动确认

所有识别结果保存来源和置信度:

级别含义页面示例
未识别仅检测到物理连接已检测到 USB 设备,未匹配 Profile
初步识别VID/PID 或探针类型匹配已识别为 Rockchip Maskrom
协议确认BootROM/SWD/UART 握手成功RK3568 Loader 可连接
Profile 确认芯片、接口、存储和 Profile 均匹配可执行 rk3568-emmc-v1

用户可手动选择 Profile,但页面必须提示“手动覆盖识别结果”,并在任务日志中记录该操作。

5. 工位状态机

空闲
  └─ 物理设备插入 → 已接入
       ├─ 描述符/探针匹配 → 初步识别
       │    └─ 执行安全探测 → 协议确认
       │         └─ 匹配 Profile → 可烧录
       └─ 不匹配 → 未识别

可烧录 → 创建任务 → 切换下载模式 → 探测 → 擦除 → 写入 → 校验 → 复位启动
       └────────────────────────────────────────────→ 成功 / 失败 / 已断开

状态更新由后台写入数据库并通过 WebSocket 推送。网络或浏览器中断只影响显示;正在执行的本地烧录任务仍继续。任务完成后,用户重新进入页面即可查询结果。

6. 后端服务设计

建议在嵌入式 Linux 中运行一个核心服务 flasherd,负责接口访问、状态机、烧录工具调度和 API。它不应把任意用户输入直接传给 shell;adapter 必须根据 Profile 生成受白名单保护的固定参数。

6.1 模块划分

模块责任
Slot Manager管理物理工位、端口映射、锁和状态机
Device Monitor接收 udev 事件,读取 USB/串口/探针信息
Probe Service执行 USB BootROM、UART、SWD/JTAG 的安全探测
Adapter Runner封装 rkdeveloptool、UUU、sunxi-fel、OpenOCD、dfu-util、esptool 等工具
Power/Pin Controller控制 PWR、VBUS、RESET、BOOT/STRAP 并读取故障状态
Job Manager任务队列、超时、取消、恢复、结果和审计
Artifact Store固件上传、分块续传、SHA-256、签名与本地缓存
API GatewayREST/gRPC、WebSocket、认证、权限和限流

6.2 核心 API

API作用
GET /api/v1/slots返回全部工位及当前识别状态
GET /api/v1/slots/{id}返回单工位的设备信息、能力和最近任务
POST /api/v1/slots/{id}/probe请求受控探测或重新枚举
POST /api/v1/slots/{id}/boot-mode执行进入下载模式的电源/引脚时序
POST /api/v1/artifacts分块上传固件,服务端计算 SHA-256
GET /api/v1/profiles返回当前账号可用的烧录 Profile
POST /api/v1/jobs创建烧录任务,包含工位、Profile 和固件
GET /api/v1/jobs/{id}获取状态、进度、结果和日志索引
WS /api/v1/events推送插拔、识别、进度和日志事件

7. 推荐技术栈

层建议
前端Vue 3 + TypeScript + Vite + Element Plus;构建后为静态文件
后端Go 或 Rust;首版优先 Go,便于 API、并发、交叉编译和系统服务集成
实时通信WebSocket;页面首次加载用 REST 获取快照,再消费事件流
Linux 外设udev、libusb、udev rules、termios、libgpiod、systemd
烧录适配OpenOCD/CMSIS-DAP、dfu-util、esptool、rkdeveloptool、UUU、sunxi-tools;逐项核对许可和 ARM Linux 支持
数据SQLite 保存元数据/任务;eMMC 文件目录保存固件与完整日志
发现与安全mDNS、HTTPS、设备证书、RBAC、固件 SHA-256/签名

前端静态资源可由 flasherd 直接提供,第一版无需另引入 Nginx、容器编排或云端依赖。

8. 数据模型建议

Slot
  id, type, physical_path, state, power_state, attached_device_id

DetectedDevice
  id, slot_id, transport, vid, pid, serial, manufacturer, product,
  probe_result, chip_family, chip_id, confidence, last_seen_at

Profile
  id, version, vendor, chip_models, adapter, required_slot_type,
  boot_sequence, image_layout, verify_policy, enabled

Job
  id, slot_id, device_id, profile_id, artifact_hash, status,
  operator, created_at, started_at, finished_at, result_code

9. 实施顺序

  1. 先完成单 USB 工位:udev 插拔事件 → WebSocket 推送 → 页面显示 VID/PID、产品名、物理路径。
  2. 加入单个目标的 Profile 和“进入下载模式 → 探测 → 烧录 → 校验”闭环,例如 STM32 DFU 或 RK Maskrom。
  3. 增加 UART 与 SWD/JTAG 识别,建立“适配器”和“目标”分层模型。
  4. 扩展到多工位,解决端口固定映射、独占锁、独立电源和并发任务。
  5. 完成账号权限、固件签名、任务审计、OTA 和产线 API。

10. 验收标准

  • 插拔已支持的 USB 目标后,Web 页面在 2 秒内更新正确工位和初步识别信息。
  • 对已建立 Profile 的目标,可一键进入下载模式并在页面显示协议确认结果。
  • 同一工位在重插、USB 重新枚举后仍保持固定工位编号。
  • 浏览器断开后,已开始的烧录任务继续执行并可重新查询。
  • 每次任务记录固件哈希、Profile 版本、设备识别信息、操作者、日志、耗时和最终校验结果。
  • 未识别设备和识别不确定设备不会被自动烧录,必须由已授权用户明确选择 Profile。

无线烧录器设备连接wifi之后,上位机(ios,android,windows,linux)软件直接识别到这个设备。

上位机选择烧录的固件,烧录的方式,串口,jlink,甚至usb。

无线烧录器可以将自己模拟成一个usb-hub设备。上位机安装好启动之后,直接识别成一个hub,后面的设备接到这个hub上时,相当于直接接到上位机的usb口。能够提供高速的数据传输。速度取决于连接的wifi以及硬件最高支持的设备。

物联网项目文档

交付记录

Original 20260912

物联网项目文档

  1. 前言

    本文档为物联网设备管理平台(软硬件一体化)项目的全套工程文档,涵盖项目从立项调研到测试验收的全生命周期,包含软件工程与硬件设备开发工程相关内容。结合项目定位(软硬件一体化提供商,提供多场景物联网服务),文档采用“核心内容整合、软硬件模块分离”的结构——整体项目框架、立项、可行性分析等核心内容统一整合,软件、硬件的需求分析、设计、测试等专项内容独立分节,既保证项目整体性,又兼顾软硬件开发的专业性和独立性,解决“整合与分离”的核心需求。软件技术选型、硬件设备选型可根据自身规划,直接填充至对应章节的指定位置。

    项目核心定位:作为软硬件一体化物联网服务提供商,搭建支持MQTT、TCP、HTTP等多协议设备接入的物联网设备管理平台,面向智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,为客户提供全流程软件+硬件一体化服务,实现设备接入、数据采集、远程控制、数据分析等核心功能。

    第一部分:立项文档

    1.1 项目立项报告

    1.1.1 项目名称

    物联网设备管理平台(软硬件一体化)建设项目

    1.1.2 项目发起单位/负责人

    发起单位:__________

    项目负责人:__________

    项目团队:__________(可补充开发、测试、硬件集成等核心成员)

    1.1.3 项目背景与意义

    1.1.3.1 研究背景

    随着物联网技术的快速普及,各行业设备联网规模呈现指数级增长,从工业生产线上的传感器、智能工厂的PLC控制器,到消费领域的智能家居终端,设备类型日益复杂,连接需求愈发多样。当前市场中,多数物联网服务存在“软件与硬件脱节”“设备接入协议单一”“场景适配性差”等痛点,传统设备管理模式依赖人工巡检,故障响应滞后,数据分散存储难以形成有效价值,且不同厂商设备协议差异大,系统集成难度高,常出现“哑设备”现象。同时,智能家居、智慧农业、智能工厂等领域对物联网服务的需求持续升级,客户亟需“一站式”软硬件一体化解决方案,而非单独采购软件平台与硬件设备后自行整合,这为软硬件一体化物联网服务提供商提供了广阔的市场空间。

    在此背景下,我们计划搭建物联网设备管理平台,支持MQTT、TCP、HTTP等多协议设备接入,整合自主研发/选型的硬件设备,为各行业客户提供从设备部署、数据采集到远程控制、数据分析的全流程服务,解决行业痛点,满足市场需求。

    1.1.3.2 项目意义

    1. 商业意义:立足软硬件一体化定位,填补市场“一站式”物联网服务空白,拓展智能家居、智慧农业、智能工厂等多场景客户群体,打造差异化竞争优势,实现商业价值变现;

    2. 技术意义:整合多协议接入技术、软硬件协同技术,形成可复用、可扩展的物联网设备管理体系,提升自身技术积累,为后续场景拓展奠定基础;

    3. 行业意义:助力各行业客户实现设备智能化管理,降低运维成本、提升数据利用价值,推动传统行业数字化转型,契合国家数字经济与物联网产业发展战略。

    1.1.4 项目目标

    1.1.4.1 总体目标

    搭建一套稳定、高效、可扩展的物联网设备管理平台,实现软硬件深度协同,支持多协议设备接入、多场景服务落地,成为专业的物联网软硬件一体化服务提供商,满足客户在设备管理、数据采集、远程控制等方面的核心需求,提升客户满意度与市场占有率。

    1.1.4.2 阶段性目标

    1. 立项调研阶段(1-2周):完成市场调研、技术调研,确定软硬件选型方案,完善可行性分析;

    2. 需求分析与设计阶段(3-4周):完成软件、硬件的需求分析,完成概要设计、详细设计,输出设计文档;

    3. 开发实现阶段(8-10周):完成软件平台开发、硬件设备选型与集成,实现软硬件协同联调;

    4. 测试验收阶段(2-3周):完成软件、硬件及系统集成测试,修复问题,通过验收,输出测试报告;

    5. 上线部署阶段(1-2周):完成平台上线、硬件部署,提供客户培训与技术支持,进入运维阶段。

    1.1.5 项目范围

    1. 软件范围:物联网设备管理平台开发,包括设备接入模块、数据采集与存储模块、远程控制模块、数据分析模块、用户管理模块、权限管理模块等,支持MQTT、TCP、HTTP等多协议接入;

    2. 硬件范围:硬件设备选型、集成与调试,包括温湿度传感器、控制终端、通信模块等,适配多场景部署,与软件平台实现无缝对接;

    3. 服务范围:为客户提供软硬件一体化部署、调试、培训、售后技术支持,覆盖智能家居、智慧农业、智能工厂等核心场景;

    4. 排除范围:不涉及硬件设备的核心芯片自主研发(仅做选型与集成),不涉及第三方平台的二次开发(除非客户特殊需求)。

    1.1.6 项目资源需求

    1. 人力资源:软件开发工程师、硬件工程师、测试工程师、产品经理、项目管理人员、运维工程师;

    2. 硬件资源:测试用硬件设备(传感器、控制终端、通信模块等)、服务器、网络设备;

    3. 软件资源:开发工具、测试工具、数据库、操作系统、协议调试工具等;

    4. 资金资源:研发资金、硬件采购资金、测试资金、培训资金等。

    1.1.7 立项审批意见

    审批人:__________

    审批意见:__________

    审批日期:__________

第二部分:可行性分析报告

2.1 概述

本报告针对物联网设备管理平台(软硬件一体化)项目,从市场、技术、经济、操作、风险五个维度进行可行性分析,判断项目是否具备实施条件,为项目决策提供科学依据。本次分析基于当前市场环境、技术水平及自身资源,结合项目目标与范围,确保分析结果真实、可靠、具有指导性。

2.2 市场可行性分析

  1. 市场需求:随着物联网技术在各行业的渗透,智能家居、智慧农业、智能工厂等领域对设备管理平台的需求持续增长,客户对“软硬件一体化”服务的需求日益迫切,避免了单独采购软硬件的整合成本与技术壁垒,市场空间广阔;

  2. 市场竞争力:当前市场中,多数物联网服务提供商要么只做软件平台,要么只做硬件设备,软硬件一体化提供商较少,项目凭借“多协议接入”“多场景适配”“一站式服务”的优势,可形成差异化竞争,契合市场需求;

  3. 市场前景:物联网产业处于快速发展阶段,政策支持力度大,各行业数字化转型加速,未来对物联网设备管理平台及软硬件一体化服务的需求将持续提升,项目具有良好的市场前景和可持续性。

2.3 技术可行性分析

  1. 技术成熟度:MQTT、TCP、HTTP等设备接入协议已成为物联网领域的主流协议,技术成熟、应用广泛;软件平台开发(如设备管理、数据存储、远程控制)、硬件设备选型与集成技术均已成熟,不存在难以突破的技术壁垒;

  2. 技术储备:项目团队已明确软件技术选型与硬件设备选型方案,具备相关的开发、集成、测试技术能力,可支撑项目顺利实施;

  3. 软硬件协同:采用成熟的软硬件协同技术,通过标准化接口实现软件平台与硬件设备的无缝对接,可确保数据传输稳定、控制指令高效执行,解决软硬件脱节问题;

  4. 可扩展性:软件平台采用模块化设计,硬件设备支持灵活替换与扩展,可根据后续市场需求,快速适配新的场景、新的设备类型,技术扩展性强。

2.4 经济可行性分析

  1. 成本估算:项目成本主要包括硬件采购成本、研发成本、人力成本、测试成本、培训成本、运维成本等,结合项目规模与阶段性目标,成本可控,可通过合理规划优化成本;

  2. 收益预测:项目收益主要来自软硬件一体化服务收费、设备销售、售后运维服务等,随着客户群体的拓展,收益将逐步提升,预计在项目上线后1-2年内实现盈利;

  3. 投资回报:综合成本与收益分析,项目投资回报率合理,风险可控,具备良好的经济可行性,符合企业长期发展战略。

2.5 操作可行性分析

  1. 团队能力:项目团队具备软件开发、硬件集成、测试、项目管理等相关能力,可熟练完成项目各阶段工作,确保项目顺利推进;

  2. 操作难度:软件平台操作界面简洁、易用,客户可快速掌握设备接入、数据查看、远程控制等操作;硬件设备部署简单、调试便捷,可适应不同场景的部署需求;

  3. 运维保障:制定完善的运维方案,配备专业的运维工程师,可及时处理软件故障、硬件故障,保障平台与设备的稳定运行,降低操作与运维难度。

2.6 风险可行性分析

2.6.1 潜在风险

  1. 技术风险:软硬件协同过程中可能出现兼容性问题,设备接入过程中可能出现协议适配问题,影响平台稳定性;

  2. 市场风险:市场需求变化过快,或竞争对手推出同类产品,影响项目市场占有率;

  3. 成本风险:硬件采购价格波动、研发成本超支,导致项目成本增加;

  4. 进度风险:项目各阶段工作推进滞后,影响项目上线时间。

2.6.2 风险应对措施

  1. 技术风险:提前进行技术调研与测试,完善软硬件选型方案,加强软硬件协同联调,建立技术问题应急处理机制,及时解决兼容性、协议适配等问题;

  2. 市场风险:持续关注市场需求变化,加强市场调研,及时优化产品与服务,打造差异化优势,加强客户维护,提升客户粘性;

  3. 成本风险:合理规划采购计划,与供应商签订长期合作协议,控制硬件采购成本;优化研发流程,避免研发成本超支,建立成本监控机制;

  4. 进度风险:制定详细的项目进度计划,明确各阶段工作任务与时间节点,加强项目进度监控,及时调整工作安排,确保项目按时推进。

2.7 可行性结论

综合市场、技术、经济、操作、风险五个维度的分析,本项目市场需求明确、技术成熟、成本可控、风险可应对,具备完全的可行性,建议立项实施。

第三部分:需求分析文档

本章节采用“整体需求+软件需求+硬件需求”的结构,整体需求明确项目核心诉求,软件、硬件需求分别独立阐述,兼顾整合性与独立性,确保需求清晰、可落地、可验证。

3.1 整体需求

  1. 多协议接入需求:支持MQTT、TCP、HTTP等多种设备接入协议,实现不同类型、不同厂商硬件设备的快速接入,解决设备接入标准化问题;

  2. 软硬件协同需求:软件平台与硬件设备无缝对接,实现数据实时采集、远程控制指令高效下发,确保软硬件协同稳定运行;

  3. 多场景适配需求:适配智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,提供针对性的软硬件解决方案;

  4. 核心功能需求:实现设备管理、数据采集与存储、远程控制、数据分析、用户管理、权限管理等核心功能;

  5. 性能需求:平台运行稳定,数据传输延迟低,设备接入容量可扩展,硬件设备运行可靠、功耗合理;

  6. 易用性需求:软件平台操作简洁,硬件设备部署、调试便捷,客户可快速上手,降低使用成本;

  7. 安全性需求:保障设备接入安全、数据传输安全、数据存储安全,防止非法接入、数据泄露、指令篡改。

3.2 软件需求(独立模块)

3.2.1 功能需求

3.2.1.1 设备接入模块

  1. 支持MQTT、TCP、HTTP三种核心协议接入,可扩展其他物联网协议;

  2. 支持设备批量接入与单个接入,提供设备接入指引与配置工具;

  3. 实现设备接入验证(设备ID、密钥验证),防止非法设备接入;

  4. 支持设备在线状态监测,实时显示设备接入状态(在线、离线、异常),异常状态及时提醒;

  5. 支持设备接入日志记录,可查询设备接入时间、接入协议、接入状态等信息。

3.2.1.2 数据采集与存储模块

  1. 实时采集硬件设备上传的数据(如温湿度、设备运行参数、状态数据等),采集频率可配置;

  2. 支持数据格式解析与转换,确保不同设备的数据统一格式存储;

  3. 采用可靠的数据库存储数据,支持历史数据查询、导出,数据存储期限可配置;

  4. 支持数据异常检测,当数据超出预设阈值时,触发异常提醒;

  5. 保障数据传输过程中的完整性,防止数据丢失、篡改。

3.2.1.3 远程控制模块

  1. 支持通过软件平台向硬件设备下发控制指令(如开关控制、参数调节等);

  2. 实时反馈指令执行结果,显示设备执行状态;

  3. 支持控制指令日志记录,可查询指令下发时间、指令内容、执行结果;

  4. 支持批量控制多个设备,提高操作效率;

  5. 当指令执行失败时,提供失败原因提示,并支持重试功能。

3.2.1.4 数据分析模块

  1. 支持对采集的数据进行统计分析(如平均值、最大值、最小值、趋势分析等);

  2. 提供数据可视化展示(图表、报表等),便于客户直观查看数据变化;

  3. 支持自定义分析规则,根据客户需求生成针对性的分析报告;

  4. 针对不同场景(如智慧农业的土壤湿度分析、智能工厂的设备运行效率分析)提供专属分析功能。

3.2.1.5 用户管理与权限管理模块

  1. 支持用户注册、登录、密码重置、账号注销等功能;

  2. 支持用户信息管理(修改个人信息、绑定联系方式等);

  3. 支持权限分级管理,不同角色(管理员、普通用户、运维人员)拥有不同的操作权限;

  4. 管理员可管理所有用户、设备、数据,普通用户仅可查看自身绑定的设备与数据,运维人员可进行设备调试与故障处理。

3.2.1.6 系统管理模块

  1. 支持系统参数配置(如数据采集频率、异常阈值、存储期限等);

  2. 支持系统日志记录,可查询操作日志、设备日志、异常日志等;

  3. 支持系统升级与维护,确保系统稳定运行;

  4. 支持数据备份与恢复,防止数据丢失。

3.2.2 非功能需求

  1. 性能需求:平台响应时间≤1s,数据传输延迟≤500ms;支持至少1000台设备同时在线接入,可扩展至10000台以上;系统可用性≥99.9%;

  2. 安全性需求:采用加密技术(如SSL/TLS)保障数据传输安全;设备接入采用密钥验证,防止非法接入;数据存储加密,防止数据泄露;定期进行安全检测,及时修复安全漏洞;

  3. 可扩展性需求:采用模块化设计,支持功能模块扩展(如新增协议接入、新增分析功能);支持设备类型扩展,可适配新的硬件设备;

  4. 易用性需求:操作界面简洁、直观,导航清晰,客户可快速掌握操作方法;提供操作指引与帮助文档;

  5. 兼容性需求:支持Windows、Linux等主流操作系统,支持Chrome、Edge等主流浏览器;

  6. 可维护性需求:系统日志清晰,便于问题排查;模块之间低耦合,便于维护与升级。

3.2.3 接口需求

  1. 设备接入接口:支持MQTT、TCP、HTTP协议接口,用于设备与平台的数据交互;

  2. 硬件对接接口:提供标准化接口,用于软件平台与硬件设备的协同对接,支持数据采集与指令下发;

  3. 内部接口:各模块之间的接口,确保模块之间的数据交互顺畅;

  4. 外部接口(可选):提供API接口,支持与第三方平台对接,实现数据共享。

3.3 硬件需求(独立模块)

3.3.1 硬件设备类型及需求

3.3.1.1 感知设备

  1. 温湿度传感器:用于采集环境温湿度数据,精度≥±0.5℃(温度)、±5%RH(湿度);支持低功耗运行;支持MQTT/TCP/HTTP协议;适配室内外多种场景;

  2. 其他感知设备(可选):根据场景需求,配置土壤湿度传感器、光照传感器、压力传感器等,要求精度达标、运行稳定、支持对应协议。

3.3.1.2 控制终端

  1. 用于接收软件平台下发的控制指令,执行相应操作(如开关控制、参数调节);

  2. 支持与感知设备、通信模块对接,实现数据采集与指令执行;

  3. 运行稳定,响应迅速,指令执行延迟≤300ms;

  4. 支持低功耗模式,适配不同供电场景(市电、电池)。

3.3.1.3 通信模块

  1. 支持MQTT、TCP、HTTP三种核心协议,可实现设备与软件平台的数据传输;

  2. 通信稳定,信号强度强,支持远距离传输(根据场景需求,可选择Wi-Fi、4G/5G、LoRa等通信方式);

  3. 低功耗、小体积,便于部署;

  4. 支持自动重连功能,当网络中断后,可自动重新连接平台。

3.3.1.4 辅助设备

  1. 电源设备:为感知设备、控制终端、通信模块提供稳定供电,支持市电、太阳能、电池等多种供电方式;

  2. 部署支架:用于设备固定,适配室内外部署场景,防水、防尘、抗干扰。

3.3.2 硬件性能需求

  1. 运行可靠性:硬件设备平均无故障运行时间(MTBF)≥10000小时;

  2. 环境适应性:适应温度范围-20℃~60℃,湿度范围10%~90%RH;防水、防尘、抗电磁干扰;

  3. 功耗需求:感知设备、通信模块采用低功耗设计,电池供电模式下,续航时间≥6个月;

  4. 数据采集精度:各类传感器的数据采集精度符合行业标准,确保数据准确性;

  5. 兼容性:硬件设备之间可无缝对接,与软件平台通过标准化接口对接,支持协议适配。

3.3.3 硬件接口需求

  1. 通信接口:支持UART、SPI、I2C等常用接口,用于与通信模块、感知设备对接;

  2. 供电接口:标准化供电接口,支持不同供电方式接入;

  3. 扩展接口:预留扩展接口,便于后续新增设备或功能扩展。

3.3.4 硬件部署需求

  1. 部署便捷:设备体积小、重量轻,便于安装与部署,无需复杂施工;

  2. 维护便捷:设备支持远程调试、固件升级,减少现场维护工作量;

  3. 场景适配:根据智能家居、智慧农业、智能工厂等不同场景,提供对应的部署方案,确保设备运行稳定。

3.4 需求确认

需求提出人:__________

需求确认人:__________

确认日期:__________

核心逻辑:三层结构

文档可以理解为三个逻辑层次:

  1. 共识层(第1-3章):对齐背景、目标、用户,回答 “为什么做”和“为谁做”。
  2. 定义层(第4-7章):详细描述解决方案,回答 “做什么”和“做成什么样”。
  3. 约束与收尾层(第8-9章):明确边界和未尽事宜,回答 “在什么限制下做”和“如何跟踪”。

各章节详细作用分析

1. 文档概述:建立沟通基础与版本控制

  • 1.1 修订历史:【核心作用】记录每一次修改的作者、日期、原因和变更内容。这是文档的“时光机”,确保所有干系人看到的都是最新版本,且任何变更都有据可查,是责任追溯和变更管理的关键。
  • 1.2 项目背景与目标:【核心作用】阐明项目的商业驱动因素和价值。解释“为什么要启动这个项目”,以及项目成功的核心衡量指标。这是统一团队思想的基石,防止开发偏离业务初衷。
  • 1.3 文档目的与范围:【核心作用】定义本文档的读者对象和用途,并清晰划定项目的边界。明确指出“包含什么”和“不包含什么”(在范围外),这是管理需求蔓延、控制项目范围的第一道防线。
  • 1.4 名词术语解释:【关键作用】建立团队内部统一的语言体系。避免因业务术语、技术简称或行业黑话理解不一致导致的沟通成本和质量问题。
  • 1.5 参考文献:【辅助作用】列出撰写本文档所依据的会议纪要、市场报告、战略文档等,增加文档的可信度和可追溯性。

2. 干系人与用户分析:明确服务对象与场景

  • 2.1 干系人列表与关注点:【核心作用】识别所有利益相关方及其核心诉求与影响力。确保在决策和沟通中不遗漏任何关键角色,是项目管理的基础。
  • 2.2 用户角色画像:【核心作用】将抽象的用户群体具体化为有姓名、背景、目标的虚拟人物。帮助团队始终站在用户角度思考,确保产品设计是为人服务的。
  • 2.3 用户场景概述:【关键作用】描述用户画像在什么情境下会遇到什么问题、触发使用产品的动机。将用户需求和产品功能置于真实的故事场景中,让需求更鲜活。

3. 总体概述:描绘产品全景图

  • 3.1 产品愿景:【激励作用】用一句简洁有力的话描述产品的长期目标和最终状态,激发团队共鸣和热情。
  • 3.2 核心业务流程:【核心作用】通过跨职能流程图,展示不同角色如何协作完成一个端到端的核心业务。这是理解业务全貌的最佳工具,帮助技术人员理解业务上下文。
  • 3.3 系统上下文图:【关键作用】用一张图展示本系统与外部所有系统/用户的交互关系(数据流入流出)。明确系统在IT生态系统中的位置和接口,是系统架构设计的重要输入。
  • 3.4 假设与依赖:【风险管理作用】提前声明项目成功所依赖的外部条件(如“某第三方数据接口需在X月X日前提供”),识别早期风险。

4. 功能性需求:定义产品的具体能力(核心交付物)

  • 这是开发、测试工作的主要依据。
  • 4.x.x 概述:简要说明该功能模块的目的。
  • 4.x.x 用户故事/用例:【核心作用】以用户视角描述功能价值,是沟通的通用语言。
  • 4.x.x 业务流程详述:【核心作用】用活动图、流程图或步骤描述,详细说明功能的正常流程、备选流程和异常流程。这是将用户故事转化为可执行逻辑的关键。
  • 4.x.x 业务规则:【关键作用】明确功能背后的逻辑判断条件、计算公式、约束限制。是开发业务逻辑层和测试用例的直接输入。
  • 4.x.x 界面原型与说明:【可视化作用】将文字需求可视化,提前对齐UI/UX设计预期,减少返工。说明部分应解释关键交互和元素规则。
  • 4.x.x 验收标准:【合同作用】定义该功能“完成”的具体、可验证的条件。这是产品、开发、测试三方达成共识的“契约”,是测试用例的来源。

5. 非功能性需求:定义产品的品质与体验

  • 【至关重要,常被忽视】 决定产品在真实世界中是否“好用”和“耐用”。性能不好、不安全、难用的产品,功能再强也注定失败。
  • 5.1-5.6:分别从性能、安全、可用性、可靠性、兼容性、可维护性等维度提出可量化的指标(如“95%的页面加载时间小于2秒”),是测试和运维团队的工作基准。

6. 数据需求:定义系统的核心资产

  • 【承上启下作用】 业务需求的实现最终会体现在数据的产生、流转和存储上。此部分为数据库设计和前后端数据交互提供直接依据。

7. 外部接口需求:定义系统的协作方式

  • 【集成作用】详细定义本系统与外界(用户、硬件、其他软件)通信的契约,包括API的格式、协议、频率、数据样例等。是系统联调和集成测试的圣经。

8. 约束与假设:划定方案的设计边界

  • 【现实主义作用】明确告知开发团队,必须在哪些既定框架内进行设计(如“必须使用Oracle数据库”)。假设则是记录当前决策所基于的、尚未验证的判断,未来需要跟踪确认。

9. 附录:管理不确定性并提供追溯

  • 9.1 待确定问题列表:【透明化管理作用】公开记录所有悬而未决的问题(TBD),并指定负责人和解决日期。避免问题被遗忘,促进问题闭环。
  • 9.2 需求优先级矩阵:【指导迭代作用】清晰展示每个功能的优先级(如MoSCoW分类),是版本规划和迭代开发的直接输入。
  • 9.3 需求追踪矩阵:【质量保障作用】建立从需求源头(如用户故事ID)到设计文档、测试用例的双向链接。确保每个需求都被实现和验证,是应对变更、进行影响分析的强大工具。

总结

  1. 先同步“为什么”(愿景、目标),建立共识。
  2. 再明确“为谁做”(用户、干系人),聚焦服务对象。
  3. 然后详细描述“做什么”和“做多好”(功能与非功能需求),提供明确指导。
  4. 最后界定“在什么框架下做”和“如何确认做完”(约束、验收、追踪),管理风险与质量。
1. 文档概述
   1.1. 修订历史
   1.2. 项目背景与目标
   1.3. 文档目的与范围
   1.4. 名词术语解释
   1.5. 参考文献

2. 干系人与用户分析
   2.1. 干系人列表与关注点
   2.2. 用户角色画像
   2.3. 用户场景概述

3. 总体概述
   3.1. 产品愿景
   3.2. 核心业务流程(流程图)
   3.3. 系统上下文图(系统与外部实体的关系)
   3.4. 假设与依赖

4. 功能性需求
   4.1. 模块/功能点A
       4.1.1. 概述
       4.1.2. 用户故事/用例
       4.1.3. 业务流程详述
       4.1.4. 业务规则
       4.1.5. 界面原型与说明
       4.1.6. 验收标准
   4.2. 模块/功能点B
   (结构同上)

5. 非功能性需求
   5.1. 性能需求(响应时间、吞吐量、并发用户数)
   5.2. 安全性需求(认证、授权、审计、数据加密)
   5.3. 可用性需求(易用性、可访问性、帮助文档)
   5.4. 可靠性需求(可用率、平均故障间隔时间、数据备份)
   5.5. 兼容性需求(浏览器、操作系统、设备、第三方系统)
   5.6. 可维护性与可扩展性需求

6. 数据需求
   6.1. 数据实体与关系
   6.2. 关键数据字段定义
   6.3. 数据管理需求(初始化、迁移、保留、归档)

7. 外部接口需求
   7.1. 用户接口(UI风格指南)
   7.2. 硬件接口
   7.3. 软件接口(API规范、第三方系统集成方式)
   7.4. 通信接口(协议、数据格式)

8. 约束与假设
   8.1. 技术约束(指定技术栈、平台等)
   8.2. 业务约束(合规性要求、运营限制)
   8.3. 项目约束(预算、工期、资源)
   8.4. 项目假设清单

9. 附录
   9.1. 待确定问题列表
   9.2. 需求优先级矩阵(MoSCoW分类)
   9.3. 需求追踪矩阵(链接到设计、测试用例)xxxxxxxxxx 初期(6个月):- 设备数量:10,000台- 日均消息数:1000万条(每台设备10秒上报一次)- 日均数据量:10GB(每条消息1KB)- 存储需求:热数据7天≈70GB,全年数据≈3.6TB中期(2年):- 设备数量:100,000台- 日均消息数:1亿条- 日均数据量:100GB- 存储需求:全年数据≈36TB

第四部分:概要设计文档

本章节延续“整体概要设计+软件概要设计+硬件概要设计”的结构,整体设计明确项目架构框架,软件、硬件概要设计分别独立阐述,明确各模块的核心设计思路、接口设计、模块间交互关系,为详细设计奠定基础。

4.1 整体概要设计

4.1.1 项目架构设计

项目采用“云-边-端”协同的四层架构,实现软硬件一体化协同运行,具体架构如下:

  1. 感知层:由各类硬件设备组成(感知设备、控制终端、通信模块等),负责数据采集与指令执行,是项目的“终端入口”;

  2. 边缘层:部署在设备侧的边缘计算节点,承担协议转换、数据预处理、本地规则计算等功能,减少网络带宽占用,实现本地故障快速响应;

  3. 平台层:即物联网设备管理软件平台,包含各类核心功能模块,负责设备管理、数据处理、远程控制、数据分析等,是项目的“核心大脑”;

  4. 应用层:面向不同场景的可视化界面与服务,为客户提供设备操作、数据查看、分析报告等服务,适配智能家居、智慧农业、智能工厂等多场景。

4.1.2 软硬件协同架构

软件平台与硬件设备通过标准化接口实现协同,具体交互流程如下:

  1. 硬件设备通过通信模块,采用MQTT/TCP/HTTP协议接入软件平台,完成身份验证;

  2. 感知设备采集数据后,通过通信模块上传至软件平台,软件平台对数据进行解析、存储与分析;

  3. 软件平台下发控制指令,通过通信模块传输至控制终端,控制终端执行指令,并将执行结果反馈至软件平台;

  4. 边缘层负责数据预处理与本地决策,当出现紧急情况时,可直接控制硬件设备,同时将相关信息上报至软件平台。

4.1.3 设计原则

  1. 模块化设计:软件、硬件均采用模块化设计,便于功能扩展、维护与升级;

  2. 标准化设计:接口、协议采用行业标准,确保软硬件兼容性与可扩展性;

  3. 稳定性设计:优先选择成熟技术与设备,确保系统与设备运行稳定,降低故障发生率;

  4. 安全性设计:融入安全设计理念,保障设备接入、数据传输、存储的安全性;

  5. 易用性设计:兼顾软件操作与硬件部署的易用性,降低客户使用与维护成本。

4.2 软件概要设计(独立模块)

4.2.1 软件架构设计

软件平台采用分层架构设计,从上至下分为应用层、业务逻辑层、数据访问层、数据存储层,各层独立运行、相互协作,具体如下:

  1. 应用层:面向用户的操作界面,包括设备管理界面、数据查看界面、远程控制界面、数据分析界面等,负责用户交互;

  2. 业务逻辑层:核心业务处理层,包含设备接入、数据采集、远程控制、数据分析、用户管理等功能模块,负责业务逻辑处理;

  3. 数据访问层:负责与数据存储层交互,实现数据的查询、新增、修改、删除等操作,为业务逻辑层提供数据支持;

  4. 数据存储层:采用数据库存储各类数据(设备数据、用户数据、日志数据等),确保数据安全、可靠。

4.2.2 核心模块设计

4.2.2.1 设备接入模块

  1. 模块功能:负责设备接入验证、协议解析、在线状态监测、接入日志记录;

  2. 核心逻辑:设备发起接入请求→模块验证设备身份(设备ID、密钥)→验证通过后,根据接入协议解析数据→记录接入日志,更新设备在线状态;

  3. 依赖模块:数据访问层(存储设备信息、接入日志)、数据采集模块(接收设备上传的数据)。

4.2.2.2 数据采集与存储模块

  1. 模块功能:接收设备上传的数据、解析数据格式、存储数据、异常数据检测、数据备份与恢复;

  2. 核心逻辑:接收设备数据→解析数据格式(统一为标准格式)→检测数据是否异常→将正常数据存储至数据库,异常数据触发提醒并记录→定期进行数据备份;

  3. 依赖模块:设备接入模块(获取设备数据)、数据访问层(存储数据)、数据分析模块(提供数据支持)。

4.2.2.3 远程控制模块

  1. 模块功能:下发控制指令、接收指令执行结果、记录指令日志、指令重试;

  2. 核心逻辑:用户发起控制指令→模块生成标准化指令→通过通信接口下发至设备→接收设备执行结果→记录指令日志,若执行失败则提醒并支持重试;

  3. 依赖模块:设备接入模块(获取设备在线状态)、数据访问层(存储指令日志)。

4.2.2.4 数据分析模块

  1. 模块功能:数据统计分析、数据可视化、自定义分析规则、生成分析报告;

  2. 核心逻辑:从数据存储层获取数据→根据预设规则或自定义规则进行统计分析→生成可视化图表与分析报告→提供数据查询与导出功能;

  3. 依赖模块:数据采集与存储模块(获取数据)、数据访问层(存储分析结果)。

4.2.2.5 用户管理与权限管理模块

  1. 模块功能:用户注册、登录、信息管理、权限分配、角色管理;

  2. 核心逻辑:用户注册/登录→验证用户信息→根据角色分配权限→用户可修改个人信息,管理员可管理用户与权限;

  3. 依赖模块:数据访问层(存储用户信息、权限信息)。

4.2.2.6 系统管理模块

  1. 模块功能:系统参数配置、日志管理、系统升级、数据备份与恢复;

  2. 核心逻辑:管理员配置系统参数→模块记录各类日志→支持系统在线升级→定期进行数据备份,出现异常时可恢复数据;

  3. 依赖模块:数据访问层(存储系统参数、日志数据、备份数据)。

4.2.3 接口设计

4.2.3.1 设备接入接口

  1. MQTT接口:用于设备与平台的数据交互,采用MQTT 3.1.1协议,端口:1883(TCP)、8883(SSL);

  2. TCP接口:用于设备与平台的双向通信,端口:8080,数据格式:JSON;

  3. HTTP接口:用于设备上传数据与接收指令,请求方式:POST/GET,数据格式:JSON。

4.2.3.2 内部模块接口

  1. 设备接入模块→数据采集模块:提供设备数据接口,传递设备上传的数据;

  2. 远程控制模块→设备接入模块:提供指令下发接口,传递控制指令;

  3. 各模块→数据访问层:提供数据查询、新增、修改、删除接口,实现数据交互。

4.2.3.3 外部接口(可选)

提供RESTful API接口,支持与第三方平台对接,数据格式:JSON,采用API密钥验证,确保接口安全。

4.2.4 数据存储设计

  1. 数据库选型:__________(根据自身技术选型填写,如MySQL、MongoDB等);

  2. 数据分类存储:

(1)设备数据:存储设备ID、设备类型、接入协议、在线状态、部署位置等信息;

(2)采集数据:存储设备上传的温湿度、运行参数等数据,按时间戳排序;

(3)用户数据:存储用户账号、密码(加密存储)、个人信息、角色权限等信息;

(4)日志数据:存储设备接入日志、操作日志、指令日志、异常日志等信息;

(5)系统数据:存储系统参数、配置信息、备份数据等信息。

4.2.5 技术选型补充

  1. 开发语言:__________(如Java、Python、Go等);

  2. 前端框架:__________(如Vue、React等);

  3. 服务器:__________(如阿里云、腾讯云服务器等);

  4. 其他技术:__________(如消息队列、缓存技术等,根据自身选型填写)。

4.3 硬件概要设计(独立模块)

4.3.1 硬件整体架构设计

硬件系统由感知层设备、控制终端、通信模块、辅助设备组成,各设备通过标准化接口对接,形成完整的硬件体系,具体架构如下:

  1. 感知层:由温湿度传感器、其他场景化传感器组成,负责采集环境与设备数据;

  2. 控制层:由控制终端组成,负责接收软件平台指令,控制感知设备与执行器;

  3. 通信层:由通信模块组成,负责实现硬件设备与软件平台的数据传输与指令交互;

  4. 辅助层:由电源设备、部署支架等组成,为硬件系统提供供电与部署支持。

4.3.2 核心硬件设备设计

4.3.2.1 感知设备设计

  1. 温湿度传感器:

(1)核心组件:传感器芯片、数据处理单元、接口单元;

(2)工作原理:传感器芯片采集温湿度数据,经数据处理单元转换为标准格式,通过接口单元传输至控制终端;

(3)选型补充:__________(根据自身硬件选型填写传感器型号、厂商等)。

4.3.2.2 控制终端设计

  1. 核心组件:主控芯片、接口单元、执行单元、电源单元;

  2. 工作原理:主控芯片接收通信模块传输的控制指令,控制执行单元执行相应操作,同时通过接口单元获取感知设备的数据,上传至通信模块;

  3. 选型补充:__________(根据自身硬件选型填写主控芯片型号、厂商等)。

4.3.2.3 通信模块设计

  1. 核心组件:通信芯片、天线、接口单元、电源单元;

  2. 工作原理:通过通信芯片实现与软件平台的协议对接,接收平台指令并传输至控制终端,同时将控制终端上传的数据传输至平台;支持自动重连功能;

  3. 选型补充:__________(根据自身硬件选型填写通信模块型号、厂商、通信方式等)。

4.3.2.4 辅助设备设计

  1. 电源设备:采用市电+电池双供电模式,确保供电稳定;电池采用锂电池,支持充电与低功耗保护;

  2. 部署支架:采用防水、防尘、抗干扰设计,适配室内外部署,便于安装与固定。

4.3.3 硬件接口设计

  1. 感知设备与控制终端接口:采用UART接口,用于数据传输,波特率:9600bps;

  2. 控制终端与通信模块接口:采用SPI接口,用于指令与数据传输;

  3. 电源接口:采用DC 5V接口,支持市电与电池接入;

  4. 扩展接口:预留I2C接口,用于后续新增设备扩展。

4.3.4 硬件协同设计

  1. 数据传输流程:感知设备采集数据→通过UART接口传输至控制终端→控制终端处理数据→通过SPI接口传输至通信模块→通信模块通过MQTT/TCP/HTTP协议上传至软件平台;

  2. 指令执行流程:软件平台下发指令→通信模块接收指令→通过SPI接口传输至控制终端→控制终端解析指令→控制执行单元执行操作→将执行结果反馈至软件平台;

  3. 异常处理:当硬件设备出现故障时,控制终端触发异常提醒,通过通信模块上传至软件平台,同时启动备用机制(如备用电源、本地控制),确保业务连续性。

4.4 概要设计评审

评审人:__________

评审意见:__________

评审日期:__________

第五部分:详细设计文档

本章节在概要设计基础上,对软件各模块、硬件各部件、数据库、接口等进行细化设计,明确具体实现逻辑、数据结构、流程细节、硬件电路与固件设计,为编码与硬件集成提供直接依据。

5.1 软件详细设计(独立模块)

5.1.1 设备接入模块详细设计

5.1.1.1 模块类设计(以Java/Spring为例)

类名职责关键方法
DeviceAuthService设备身份验证authenticate(deviceId, secret)
MqttGatewayMQTT协议处理handleMqttMessage(topic, payload)
TcpServerHandlerTCP长连接处理channelRead(ChannelHandlerContext, Object)
HttpDeviceControllerHTTP设备接入接口uploadData(@RequestBody DeviceData)
DeviceStatusManager设备在线状态管理updateStatus(deviceId, status), heartbeat(deviceId)

5.1.1.2 设备接入流程

  1. MQTT接入:设备连接Broker(EMQX/Mosquitto)→ 携带用户名/密码 → Broker回调MqttGateway → 验证设备身份 → 订阅系统主题 → 记录接入日志 → 更新在线状态。
  2. TCP接入:设备建立Socket连接 → 发送认证JSON({deviceId, secret})→ 服务端解析验证 → 维持长连接 → 心跳保活(每30秒)。
  3. HTTP接入:设备POST /api/device/upload → 携带X-Device-Id与X-Token → 验证通过后返回200 → 数据进入采集队列。

5.1.1.3 状态管理机制

  • 使用Redis存储设备在线状态,key:device:status:{deviceId},value:online/offline,TTL:90秒(心跳超时)。
  • 监听MQTT的$SYS/brokers/+/clients/+/disconnect事件主动感知离线。
  • TCP连接断开时自动更新状态。

5.1.2 数据采集与存储模块详细设计

5.1.2.1 数据采集流程

  1. 设备上报数据 → 模块接收(同步或异步消息队列Kafka/RabbitMQ)→ 格式校验 → 解析为标准JSON结构:

    json

    {
      "deviceId": "xxx",
      "timestamp": 1700000000000,
      "data": {"temperature": 25.6, "humidity": 60}
    }
    
  2. 异常检测:根据预设阈值(如温度>50℃)触发告警,写入告警表。

  3. 存储策略:高频采集数据(秒级)写入时序数据库(如InfluxDB/TimescaleDB);设备配置、元数据写入关系库(MySQL/PG)。

5.1.2.2 数据清理与备份

  • 原始数据保存30天,自动转存到冷存储(对象存储)或删除。
  • 每日凌晨2点执行数据备份(全量+增量),备份保留7天。

5.1.3 远程控制模块详细设计

5.1.3.1 控制指令下发流程

  1. 用户在Web端点击“关闭开关” → 前端调用POST /api/control/send
  2. 后端生成指令ID(UUID) → 记录到control_log表,状态为pending
  3. 根据设备协议类型:
    • MQTT:发布到设备专属topic device/{deviceId}/control
    • TCP:通过对应Channel写入指令JSON
    • HTTP:调用设备提供的回调URL
  4. 设备执行后回复ACK → 模块更新指令状态为succeeded/failed
  5. 若10秒内未收到ACK,触发重试(最多3次),仍失败则状态置为failed并告警。

5.1.3.2 批量控制设计

  • 支持选择多个设备 → 后台并发调用单设备控制逻辑,使用线程池(最大10线程)。
  • 记录批量任务ID,可查询每个设备的执行结果。

5.1.4 用户管理与权限模块详细设计

5.1.4.1 权限模型(RBAC)

  • 表结构:user、role、permission、user_role、role_permission
  • 预置角色:
    • 管理员:所有权限
    • 普通用户:仅查看自己设备的实时数据及历史数据
    • 运维人员:设备调试、固件升级、故障日志查看

5.1.4.2 认证与授权

  • JWT令牌,有效期24小时,刷新令牌7天。
  • 接口权限使用Spring Security注解@PreAuthorize("hasPermission(...)")。
  • 设备级权限:用户与设备通过user_device表关联,查询时自动过滤。

5.1.5 接口详细设计(RESTful API示例)

5.1.5.1 设备注册接口

text

POST /api/device/register
Request Body: { "deviceName": "sensor_01", "protocol": "MQTT", "productKey": "xxx" }
Response: { "deviceId": "d_xxx", "secret": "xxxx" }

5.1.5.2 数据查询接口

text

GET /api/data/latest?deviceId=xxx
Response: { "deviceId": "xxx", "data": {...}, "timestamp": 1700000000 }

5.1.5.3 控制指令接口

text

POST /api/control/send
Request: { "deviceId": "xxx", "command": "turn_off", "params": {} }
Response: { "commandId": "cmd_xxx", "status": "pending" }

5.2 硬件详细设计(独立模块)

5.2.1 感知设备详细设计(以温湿度传感器为例)

5.2.1.1 硬件选型(示例)

组件型号/规格说明
传感器芯片SHT30精度:±0.3℃ / ±2%RH
主控MCUESP32-C3支持Wi-Fi/BLE,低功耗
通信模块内置Wi-Fi支持MQTT/TCP
电源3.7V锂电池 + 充电管理TP4056续航约6个月(每小时上报一次)

5.2.1.2 电路连接

  • SHT30的SCL→ESP32的IO22,SDA→IO21,VCC→3.3V,GND→GND
  • 电池正极→TP4056的BAT+,TP4056的OUT+→ESP32的VIN
  • 预留UART0作为调试口

5.2.1.3 固件设计

  • 采用Arduino/ESP-IDF开发
  • 主循环:读取传感器(每10秒一次)→ 平均值计算(每分钟)→ 通过MQTT上报 → 进入深度睡眠(剩余时间)
  • 上报频率可远程配置(默认60秒)
  • 支持OTA升级

5.2.2 控制终端详细设计

5.2.2.1 硬件组成

  • 主控:STM32F103C8T6
  • 继电器模块(控制220V设备)
  • 通信接口:SPI接ESP8266(透传MQTT)
  • 本地存储:AT24C02(保存设备配置)

5.2.2.2 控制逻辑

  • 监听通信模块转发的控制指令 → 解析指令类型(开关、PWM调光等)→ 驱动GPIO/继电器 → 读取传感器反馈(可选)→ 返回执行结果。

5.2.3 硬件协同时序

text

[传感器] --> UART --> [控制终端] --> SPI --> [通信模组] --> MQTT --> [平台]
[平台]   --> MQTT --> [通信模组] --> SPI --> [控制终端] --> GPIO --> [执行器]

5.3 数据库详细设计

5.3.1 关系型数据库表设计(MySQL)

5.3.1.1 设备表 device

字段类型说明
device_idVARCHAR(32) PK设备唯一标识
device_nameVARCHAR(64)设备名称
protocolENUM('MQTT','TCP','HTTP')接入协议
product_keyVARCHAR(32)产品型号
secretVARCHAR(64)设备密钥(加密存储)
statusTINYINT0-离线,1-在线
last_active_timeDATETIME最后心跳时间
created_timeDATETIME注册时间

5.3.1.2 用户表 user

字段类型说明
user_idINT AUTO PK
usernameVARCHAR(32) UNIQUE
passwordVARCHAR(128)bcrypt加密
role_idINT关联角色表
......

5.3.1.3 指令日志表 control_log

字段类型说明
command_idVARCHAR(36) PK
device_idVARCHAR(32)
commandTEXT指令内容
statusVARCHAR(16)pending/succeeded/failed
retry_countINT重试次数
create_timeDATETIME
finish_timeDATETIME

5.3.2 时序数据库设计(InfluxDB)

  • 测量名:device_data
  • Tag:device_id,sensor_type
  • Field:value(数值),unit(单位)
  • Timestamp:毫秒级时间戳

示例查询:SELECT mean(value) FROM device_data WHERE device_id='xxx' AND time > now()-1d

第六部分:测试文档

6.1 测试计划

6.1.1 测试范围与策略

  • 单元测试:软件各模块方法级测试,覆盖率≥80%
  • 集成测试:模块间接口、软硬件协同通信
  • 系统测试:端到端功能、性能、安全性、兼容性
  • 硬件测试:传感器精度、通信距离、功耗、环境适应性

6.1.2 测试环境

  • 软件:测试服务器(4C8G)、MySQL、InfluxDB、EMQX、Chrome浏览器
  • 硬件:温湿度传感器5,控制终端3,通信模组*3,可调温湿箱,直流电源

6.1.3 测试里程碑

阶段时间输出物
单元测试第9-10周单元测试报告
集成测试第11周集成测试报告
系统测试第12周系统测试报告、缺陷清单
硬件测试并行硬件测试报告
验收测试第13周验收测试报告

6.2 测试用例

6.2.1 功能测试用例(部分)

用例ID模块测试项前置条件输入/操作预期结果优先级
TC-SW-001设备接入MQTT设备正常接入平台已部署,MQTT Broker运行设备使用正确ID/Secret连接连接成功,设备状态变更为“在线”P0
TC-SW-002设备接入错误密钥拒绝同上使用错误Secret连接连接拒绝,日志记录失败P1
TC-SW-003数据采集接收并存储温湿度设备已在线设备上报温湿度数据数据在InfluxDB可查,前端显示正确P0
TC-SW-004数据采集异常数据告警设备上报温度>80℃同上系统产生告警记录,前端提示P1
TC-SW-005远程控制下发开关指令控制终端在线点击“关闭”按钮设备执行关闭,指令状态成功P0
TC-SW-006远程控制离线设备控制设备离线下发指令提示设备离线,指令状态失败P1
TC-SW-007权限管理普通用户访问其他设备用户A只绑定设备1用户A尝试查看设备2数据返回403或无权限提示P0
TC-SW-008数据分析历史数据曲线已有24小时数据选择设备、时间范围展示正确的折线图P1

6.2.2 性能测试用例

用例ID测试项负载条件指标预期结果
TC-PERF-001并发设备接入1000个MQTT设备同时连接连接成功率≥99.5%成功率达标,CPU≤70%
TC-PERF-002数据上报吞吐500设备同时每秒上报1条消息处理延迟≤500ms无积压,延迟达标
TC-PERF-003控制指令并发100个并发指令响应时间≤1s95%指令在1s内返回

6.2.3 硬件测试用例

用例ID测试项方法判定标准
TC-HW-001温度精度与标准温度计对比(0℃,25℃,50℃)误差≤±0.5℃
TC-HW-002湿度精度与标准湿度计对比(30%,60%,90%RH)误差≤±5%RH
TC-HW-003功耗测试电池供电,每小时上报一次续航≥6个月(实测计算)
TC-HW-004通信距离开阔场地测试Wi-Fi连接距离≥50米稳定连接
TC-HW-005高低温工作-20℃~60℃恒温箱运行2小时设备不宕机,数据正常

6.2.4 安全测试用例

用例ID测试项操作预期结果
TC-SEC-001未授权访问不带Token访问API返回401
TC-SEC-002SQL注入在设备ID参数中输入 ' OR '1'='1查询失败或转义,不泄露数据
TC-SEC-003通信加密抓包MQTT数据应看到TLS加密,不能明文看到密码

6.3 测试报告(模板)

6.3.1 测试概要

  • 测试版本:v1.0
  • 测试周期:202X年X月X日 - 202X年X月X日
  • 总用例数:120,通过:115,失败:5,阻塞:0
  • 缺陷总数:8(严重2,一般4,轻微2)

6.3.2 缺陷分析

缺陷ID模块描述严重程度状态
BUG-01设备接入TCP连接偶尔掉线不重连严重已修复
BUG-02远程控制批量控制时部分设备未收到指令一般已修复
...............

6.3.3 测试结论

  • 核心功能满足需求,性能指标达标,硬件精度符合标准。
  • 建议修复剩余轻微问题后上线。

第七部分:部署与运维文档

7.1 部署方案

7.1.1 软件部署架构

  • 负载均衡:Nginx(HTTPS卸载)
  • 后端服务:Spring Boot(jar包),systemd管理,3节点集群
  • 前端:Vue打包静态文件,Nginx托管
  • 数据库:MySQL主从+读写分离,InfluxDB集群
  • 消息中间件:Kafka(数据采集缓冲)
  • MQTT Broker:EMQX集群(3节点)

7.1.2 部署步骤(摘要)

  1. 安装Docker及docker-compose(或K8s)
  2. 拉取镜像:MySQL, InfluxDB, EMQX, Redis, Kafka, 后端服务镜像
  3. 配置环境变量:数据库连接、JWT密钥、MQTT地址
  4. 执行数据库初始化脚本(schema.sql,seed.sql)
  5. 启动所有容器,验证健康检查
  6. 配置Nginx反向代理与SSL证书(Let's Encrypt)
  7. 硬件设备配置:烧录固件,配置平台域名

7.1.3 硬件部署指导

  • 温湿度传感器:室内壁挂,离地1.5米,避免阳光直射
  • 控制终端:靠近被控设备,确保Wi-Fi信号强度≥-70dBm
  • 通信模块天线竖直向上,远离金属遮挡

7.2 运维手册

7.2.1 日常巡检项

  • 每日:检查服务进程、磁盘使用率、数据库连接数
  • 每周:查看错误日志,清理过期数据
  • 每月:安全补丁更新,性能容量评估

7.2.2 常见问题处理

问题现象可能原因处理步骤
设备无法接入Broker挂掉docker ps 检查EMQX,重启容器
数据显示延迟Kafka积压增加消费者实例或扩容
控制指令超时设备网络差检查设备RSSI,建议移近路由器

7.2.3 备份与恢复

  • 数据库每日全量备份脚本(mysqldump + influx backup)
  • 备份保留到OSS,保留30天
  • 恢复:停止服务 → 恢复备份 → 重启验证

第八部分:用户手册(概要)

8.1 平台操作指南

8.1.1 登录与注册

  • 访问 https://iot.xxx.com,首次使用需注册企业账号
  • 登录后进入仪表盘

8.1.2 设备管理

  • 添加设备:点击“设备管理”→“添加设备”→输入设备ID和密钥(设备外壳标签上)→选择协议→完成
  • 查看设备:列表显示设备状态,点击可查看实时数据与历史曲线

8.1.3 远程控制

  • 在设备详情页,点击“控制”选项卡 → 选择控制命令(开关、调节等)→ 确认发送 → 显示执行结果

8.1.4 数据分析

  • “数据报表”菜单 → 选择设备、时间范围、数据类型 → 生成图表,可导出Excel

8.2 硬件安装指南(以温湿度传感器为例)

  1. 打开包装,取出传感器主体和支架
  2. 使用附赠的Micro-USB线充电2小时(红灯充电,绿灯满电)
  3. 下载配网App或通过微信小程序,长按设备按键5秒进入配网模式
  4. 输入Wi-Fi密码,等待提示“配网成功”
  5. 登录平台查看设备是否在线

第九部分:项目验收文档

9.1 验收计划

  • 验收时间:测试阶段结束后3个工作日内
  • 参与人员:客户代表、项目负责人、测试经理
  • 验收标准:需求文档中的所有功能均已实现且通过测试;性能指标达标;硬件精度符合规格;文档齐全

9.2 验收清单

编号验收项是否满足(是/否)备注
1软件平台所有功能模块可正常使用
2支持MQTT/TCP/HTTP三种协议设备接入
3设备在线状态实时更新
4数据采集延迟≤500ms
5远程控制响应时间≤1s
6并发1000设备在线,系统稳定
7硬件传感器精度达标
8硬件通信距离≥50米
9提供完整文档(需求、设计、测试、部署、用户手册)
10提供源代码与固件代码

9.3 验收结论

  • 验收通过 □ 不通过 □
  • 遗留问题及处理计划:__________
  • 验收签字:
    • 客户代表:__________
    • 项目负责人:__________
    • 日期:__________

chat1:

物联网项目文档编写,我打算实现一个物联网设备管理平台,这个平台每个用户可以通过mqtt,tcp,http等方式将设备接入到平台。我打算作为一个软硬件一体化提供商,提供物联网服务,如智能家居控制,温湿度等硬件设备数据上报,智慧农业,智能工厂等,为客户提供软件和硬件服务。现在需要把文档从立项到测试所有文档进行实现。同时是否要区分软件和硬件文档,还是整合在一起。软件技术选型和硬件设备选型心里已经有底了,软硬件各种文档是不是要独立出来?还是整合到一个,尽你所能帮我把文档完成,要求从研究背景,可行性分析,需求分析,概要设计,详细设计,测试用例等一整套软件工程,以及硬件设备开发工程文档

物模型

功能说明

物模型是物理空间中的实体(如传感器、车载装置、楼宇、工厂等)在云端的数字化表示,从属性、服务和事件三个维度,分别描述了该实体是什么、能做什么、可以对外提供哪些信息。定义了物模型的这三个维度,即完成了产品功能的定义

功能类型说明
属性(Property)用于描述设备运行时具体信息和状态。例如,环境监测设备所读取的当前环境温度、智能灯开关状态、电风扇风力等级等。属性可分为读写和只读两种类型。读写类型支持读取和设置属性值,只读类型仅支持读取属性值。
服务(Service)指设备可供外部调用的指令或方法。服务调用中可设置输入和输出参数。输入参数是服务执行时的参数,输出参数是服务执行后的结果。相比于属性,服务可通过一条指令实现更复杂的业务逻辑,例如执行某项特定的任务。服务分为异步和同步两种调用方式。
事件(Event)设备运行时,主动上报给云端的信息,一般包含需要被外部感知和处理的信息、告警和故障。事件中可包含多个输出参数。例如,某项任务完成后的通知信息;设备发生故障时的温度、时间信息;设备告警时的运行状态等。事件可以被订阅和推送。

物联网平台支持为产品定义多组功能(属性、服务和事件)。一组功能定义的集合,就是一个物模型模块。多个物模型模块,彼此互不影响。

物模型模块功能,解决了工业场景中复杂的设备建模,便于在同一产品下,开发不同功能的设备。

例如,电暖扇产品的功能属性有电源开关、档位(高、中、低)和室内温度,您可以在一个模块添加前2个属性,在另一个模块添加三个属性,然后分别在不同设备端,针对不同物模型模块功能进行开发。此时,该产品下不同设备就可以实现不同功能。

CREATE TABLE thing_model (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    model_id VARCHAR(64) UNIQUE NOT NULL COMMENT '物模型ID',
    model_name VARCHAR(100) NOT NULL COMMENT '物模型名称',
    model_version VARCHAR(20) NOT NULL DEFAULT '1.0' COMMENT '模型版本',
    product_key VARCHAR(64) COMMENT '产品标识(与产品唯一绑定)',
    product_id VARCHAR(64) COMMENT '产品ID',
    category VARCHAR(50) COMMENT '设备品类',
    protocol_type VARCHAR(30) COMMENT '协议类型(MQTT/CoAP/HTTP等)',
    description TEXT COMMENT '模型描述',
    status TINYINT DEFAULT 1 COMMENT '状态:0-启用,1-禁用',
    is_standard TINYINT DEFAULT 0 COMMENT '是否标准模型',
    extend_data JSON COMMENT '扩展字段',
   `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
   `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '创建者',
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
   `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '更新者',
   `update_time` datetime DEFAULT NULL COMMENT '更新时间'
) COMMENT='物模型';
CREATE TABLE thing_property (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    model_id VARCHAR(64) NOT NULL COMMENT '物模型ID',
    identifier VARCHAR(100) NOT NULL COMMENT '属性标识符',
    name VARCHAR(100) NOT NULL COMMENT '属性名称',
    data_type VARCHAR(30) NOT NULL COMMENT '数据类型:int/float/double/string/bool/enum/date/struct等',
    data_spec JSON COMMENT '数据规范(JSON格式)',
    unit VARCHAR(20) COMMENT '单位',
    access_mode TINYINT NOT NULL COMMENT '访问模式:1-只读,2-只写,3-读写',
    required TINYINT DEFAULT 0 COMMENT '是否必选:0-否,1-是',
    is_primary TINYINT DEFAULT 0 COMMENT '是否为主属性',
    description TEXT COMMENT '属性描述',
    sort_order INT DEFAULT 0 COMMENT '排序',
    extend_config JSON COMMENT '扩展配置',
    `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
   `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '创建者',
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
   `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '更新者',
   `update_time` datetime DEFAULT NULL COMMENT '更新时间'
) COMMENT='属性定义表';

前言

这个平台主要作用不是为了如何去对接客户,如何推广使用。它更像是技术驱动型,为了达到某种技术实现。为学习而生的一个学习平台。

  • 技术整合
  • 链路学习
  • 架构规划
  • ......

技术路线和方案

硬件

软件

emqx平台所带来的方便使用的mqtt服务器。

kafka用于方便和emqx做解耦的中间件。

功能说明
项目管理
设备接入
设备管理

从开发者到物联网产品负责人的能力成长路线

核心目标

不需要把自己变成所有专业角色,而是培养把物联网产品从问题发现、方案设计、交付运营到商业闭环跑通的全局能力。

AI 会显著放大执行效率,但以下能力仍需要自己掌握:接触真实用户、判断优先级、进行关键取舍、对结果负责。

学习原则:每一项能力必须经过“真实产出 + 真实反馈 + 复盘”来掌握。

需要建立的角色视角

角色视角具体工作典型产出关注结果
用户研究 / 行业专家访谈用户、观察现场流程、识别真实痛点与约束用户画像、场景流程、痛点清单、访谈记录问题是否真实、高频、值得解决
产品经理定义目标用户、价值主张、需求优先级和最小可用版本PRD、用户故事、原型、需求路线图使用率、留存、效率提升
解决方案架构师将场景转化为端—边—云—用方案,权衡可靠性、安全与成本总体架构、设备选型、接口/数据模型、容量方案稳定性、可扩展性、单位成本
项目经理拆目标、定里程碑、管理风险、协调资源、定义验收WBS、计划、风险台账、验收标准交付准时率、范围与质量
运营 / 客户成功推动客户使用,处理培训、告警、反馈和持续改进上线手册、运营看板、FAQ、复盘报告活跃、故障闭环时效、续用率
商务 / 财务明确购买者、定价、获客成本、交付成本与回款商业模式、报价单、ROI 测算、合同范围毛利、回款、获客成本
安全与合规负责人管理设备身份、权限、数据、升级、隐私和审计威胁模型、权限矩阵、数据分级、应急预案安全事件、合规风险、可追溯性
负责人 / CEO做取舍、定战略、建立机制、对整体结果负责战略一页纸、OKR、组织/协作机制长期方向与整体结果

对个人物联网平台而言,优先深挖用户、产品、解决方案、项目交付、运营五项能力;对商务、合规和负责人角色,先建立基本判断力。

练兵项目的选择

不要一开始做“通用物联网平台”。应选择一个能够接触真实用户的 B 端细分场景,完整跑通一个闭环,例如:

  • 冷链温湿度监控与告警闭环
  • 工厂设备状态监控与故障报修
  • 园区能耗采集与异常分析
  • 小型设备厂商的设备远程运维与 OTA

最终目标不是完成一套大而全的系统,而是做出一个有人愿意试用的最小产品闭环。

12 周实战学习路线

第 1–2 周:用户思维

目标:从“我能做什么”转向“用户在哪个时刻损失最大”。

行动:

  1. 找到 5 位潜在用户或行业人士,每次访谈 30–45 分钟。
  2. 只了解其真实工作与最近经历,不推销平台和功能。
  3. 画出一次真实异常处理流程:谁发现、谁通知、谁处理、如何记录、谁负责。

访谈问题:

  • 最近一次设备或环境异常是什么时候?从发现到处理发生了什么?
  • 当时谁最着急,具体损失是什么?
  • 目前如何发现异常,使用什么工具,最麻烦的环节是什么?
  • 不处理时最坏的结果是什么?
  • 已经为解决问题付出了哪些时间、金钱或人力?
  • 为什么现有方式没有把问题解决好?

产出:5 份访谈纪要、1 张场景流程图、1 份按频率/损失/难度排序的问题清单,以及 1 句话的问题定义。

问题定义模板:

对于【某类用户】,在【特定场景】中,因为【现有方式的具体缺陷】,导致【可量化损失】;我们先帮助他实现【最小结果】。

掌握标准:能够说清用户的工作流、损失和现有替代方案,而不只是罗列功能。

第 3–4 周:产品思维

目标:把问题转化为有边界、可验证的产品方案。

先只做一条 MVP 闭环:

设备上报数据
  → 平台判定异常
  → 通知指定责任人
  → 责任人确认或处理
  → 系统记录结果
  → 管理者查看闭环情况

暂不做:通用规则引擎、复杂多租户组织、大屏、几十种协议支持、全场景设备管理和复杂 AI 分析。

轻量 PRD 要写清:谁在什么情况下使用、希望完成什么、成功后的系统状态、异常处理方式、以及如何衡量价值。

优先级公式:

优先级 = 用户损失 × 发生频率 × 愿意改变现状的意愿 ÷ 实现成本

产出:一页产品定义、10–15 条用户故事、页面或流程原型、MVP 范围和不做清单、3 个关键指标。

掌握标准:面对十个功能请求,能明确解释为什么只做其中两个。

第 5–6 周:解决方案与架构思维

目标:让产品在真实现场可部署、可维护、可靠且成本可控。

架构层次:

传感器/设备
  ↓
网关或设备直连
  ↓
消息接入与设备身份认证
  ↓
规则判断、时序数据、告警和业务数据
  ↓
Web/移动端、通知渠道、运营后台

需要回答的问题:

主题关键问题
设备接入设备身份是什么?断网数据如何处理?
网络协议为何这样选?网络差时如何重连?
数据遥测、告警、工单分别存什么,保留多久?
告警如何防止告警轰炸,如何去重、升级和恢复?
安全设备如何认证?客户间数据如何隔离?
运维如何定位离线设备、升级固件、追溯操作?
成本每增加 1,000 台设备,消息、存储、告警成本增加多少?

产出:总体架构图、设备生命周期图、数据模型、API/消息 Topic 约定、可靠性与安全清单、月成本粗算表。

掌握标准:不只会让设备连上,也能说明断网、重复上报、设备伪造、告警无人处理等情况的应对方案。

第 7–8 周:项目与交付管理

目标:按范围、质量和时间把 MVP 落地。

建议里程碑:

  1. 模拟设备可稳定上报数据。
  2. 平台完成设备注册、在线状态和数据展示。
  3. 异常规则和通知打通。
  4. 处理闭环和报表打通。
  5. 真实用户试用与问题修复。

每个里程碑都必须有可验证验收标准。例如:模拟设备连续上报 72 小时;断网重连后数据不丢失或能明确标记缺失;异常发生后 1 分钟内产生告警并通知责任人。

维护风险表,至少记录风险、预警信号和应对方式。常见风险包括用户不愿安装、网络环境差、告警太多导致关闭通知,以及需求范围持续膨胀。

掌握标准:能在延迟和失败发生前一两周识别风险,而不是到最后才发现。

第 9–10 周:运营与客户成功

目标:让客户真正用起来并获得结果。

为试用用户准备:10 分钟快速上手指南、设备安装/接入检查表、常见告警处理流程、问题反馈入口和每周运营报告。

每周复盘:本周异常、无效告警、最耗时的动作、继续使用的意愿和原因,以及只能保留一个能力时用户会保留什么。

产出:用户上线手册、运营看板、FAQ、反馈清单、每周复盘报告。

掌握标准:关注用户是否达成结果,而不只是功能是否上线。

第 11–12 周:商业与负责人思维

目标:判断产品是否值得持续投入。

明确:目标客户、购买决策者、日常用户、付费原因、实施费、设备或订阅价格、交付支持成本。

ROI 模型:

年收益 = 减少损失 + 节约人工 + 降低停机时间
年成本 = 硬件 + 安装 + 平台订阅 + 运维
ROI = (年收益 - 年成本) / 年成本

完成负责人复盘:回顾初始假设、已证实与被推翻的内容、最大瓶颈、下一个版本只做的三件事,以及必须停止做的事。

掌握标准:敢于停止低价值工作,将有限时间投入最影响结果的事情。

每周固定训练节奏

  • 2 小时:接触用户或研究行业现场。
  • 3 小时:整理需求、指标和产品决策。
  • 6–10 小时:实现当前 MVP。
  • 1 小时:项目、风险和成本复盘。
  • 30 分钟:记录本周的关键决策。

决策记录模板:

决策:本版本只支持 MQTT 设备接入。

背景:试用客户现有设备可通过网关转换为 MQTT。
备选:同时支持 HTTP、Modbus 直连。
取舍:优先验证告警闭环价值,而非协议覆盖率。
风险:后续设备接入需要适配。
验证时间:试用结束后复盘。

AI 的使用方式

将 AI 作为陪练和执行助手:

  • 扮演行业用户,训练访谈和需求澄清。
  • 审查 PRD,找出模糊需求、遗漏边界和伪需求。
  • 模拟断网、重复消息、设备伪造、告警风暴等架构问题。
  • 协助将任务拆为里程碑、风险与验收条件。
  • 从试用日志中总结异常模式和用户反馈。
  • 生成文档初稿、测试清单、部署手册和复盘模板。

AI 不能替代一手用户认知、优先级判断、价值判断和最终责任。

现在的第一步

选定一个自己能接触到真实人的细分场景。本周只完成三件事:进行 3 次用户访谈、整理访谈纪要、画出一张异常处理流程图。在此之前不写平台代码。

时序数据库(TSDB)

物联网设备上报的数据具有写多读少、高并发、数据带有时间戳、极少更新的特点。时序数据库正是为此设计的。

TimescaleDB ,InfluxDB等

利用 Redis 做缓冲

  • 架构模式:
    1. 设备上报 -> MQTT Broker (如EMQX) -> Redis 队列/消息队列。
    2. 消费组 -> 批量写入 -> MySQL (分库分表)。
物模型(thing_model)
    │
    ├── 产品(product) ←─────── 设备(device)
    │        │                      │
    │        ├── 产品模型关联         ├── 设备模型关联
    │        │                      │
    │        └── 分组(group) ←───────┼── 设备分组关系
    │                                │
    ├── 属性(property)               ├── 属性值快照
    ├── 服务(service)                ├── 动态属性/服务
    └── 事件(event)                  └── 设备影子

该项目要求针对不同的企业提供接入功能。

项目

每个账号下面都有项目

设备管理

设备接入

设备

主要功能需求概括

  • 管理公司的物联网设备,公司所有的设备都需要接入到平台进管理
  • 为用户提供物联服务
  • 公司设备包括mqtt透传设备,网关设备,智能开关等一系列可以通过物联的方式接入到平台,用户购买我司的设备之后可以通过我司提供的小程序,app控制设备。
  • 后续功能,对接企业,saas系统,企业购买我司的设备,或者通过我司平台的协议接入自己的设备到我平台。
  • 平台前期最主要的功能是完成我司物联设备的管理,包括物联设备的录入,物联设备的注册,物联设备的管理,完成平台能力。

第一阶段:

完成设备管理,设备注册,设备接入,设备功能,指令下发,设备功能配置。用户管理,设备接入时要绑定用户,一般是小程序用户直接通过扫码或者输入设备唯一编号进行设备绑定。

第二阶段:

允许用户接入自己的设备,规定协议之后,用户可以直接接入自己的设备。运行用户自定义自己的小程序,支持用户生成自己的小程序,接入到我们平台。甚至可以帮用户直接配置自己的小程序,用户只需要拖动界面规划自己的小程序界面就能完成自己的个性化定制。

第三阶段:可以和第二阶段同步进行

这个阶段针对的是企业用户,支持帮助企业定制化开发设备,支持平台客服批量帮助用户完成设备注册,支持远程协调帮助,支持派遣人员到现场帮忙安装,那么派遣的员工,以及时长平台能管理,类似内部erp系统。saas模式,支持企业进入saas后台管理自己的设备,也支持企业做设备的绑定,支持对外提供设备状态和数据给到企业的一些oem系统erp系统,也就是说支持个性化接口提供,为每个企业提供适合企业的openapi。

Pre cleanup 20260912

Artifacts before cleanup

本司设备联网管理平台产品与交付基线(完善版)

来源:本司设备联网管理.md。原文件保持不变,本文件将原始构想整理为可设计、可开发、可测试的需求基线。

1. 产品目标

建设统一的设备联网管理平台,先完成本司设备从产品定义、出厂录入、设备注册、联网鉴权、状态监测、用户绑定到远程控制的业务闭环,再扩展客户自有设备接入、企业多租户 SaaS、OpenAPI 和低代码小程序。

首期交付同时包含三类使用端:

  1. 平台管理后台:供平台管理员、运维人员管理产品、设备、用户、告警和指令。
  2. 平台后端:提供用户、设备和管理 API,并承接 MQTT/HTTP/TCP 接入层的领域能力。
  3. 用户小程序:供个人用户登录、扫码或输入绑定码、查看状态、控制本人设备。

2. 角色与权限

角色核心权限数据边界
平台管理员产品与物模型、设备、用户、告警、指令、审计、系统参数平台全部数据
平台运维设备注册与诊断、状态、指令、告警处置平台授权数据;不能修改高危系统配置
个人用户登录、绑定、解绑申请、查看遥测、控制设备仅本人绑定设备
企业管理员(二/三期)成员、设备、应用、接口凭证、工单本企业租户
设备身份鉴权、上报属性/事件、接收指令并返回 ACK仅设备自身 Topic/接口

3. 第一期:本司设备管理闭环(当前实现目标)

3.1 产品与物模型

  • 管理产品名称、ProductKey、品类、默认接入协议和物模型版本。
  • 同一产品下的设备共享属性、服务、事件定义;设备只保存运行值和差异化配置。
  • MVP 内置智能开关、RS485 网关和温湿度传感器三个演示产品。

3.2 设备注册和接入

  • 支持后台逐台注册;批量导入列入一期增强项。
  • 每台设备生成不可重复的设备 ID、设备密钥和一次性交付语义的绑定码。
  • 设备以 deviceId + deviceSecret 鉴权;生产环境密钥只在创建时展示,服务端仅保存加密或哈希值。
  • 首期协议以 MQTT 为主,HTTP 遥测接口用于联调;TCP 通过独立适配器后续接入。
  • 平台记录在线/离线、最后活跃时间、固件版本、当前属性和遥测历史。

3.3 用户绑定

  • 小程序用户完成微信登录后,可扫码或手工输入绑定码。
  • 绑定必须原子执行:无效码失败;已经归属其他用户的设备禁止再次绑定;重复绑定给同一用户可幂等成功。
  • 所有小程序设备查询和控制必须同时校验登录身份与设备归属,不能仅凭设备 ID 操作。
  • 解绑、分享家庭成员、所有权转移列入一期增强项,并要求二次确认和审计。

3.4 设备控制

  • 管理后台和设备所有者可下发物模型服务或属性设置指令。
  • 完整状态机为 pending → sent → succeeded | failed | timeout | canceled。
  • 每条指令保留指令 ID、设备、操作人、参数、创建时间、完成时间、重试次数和失败原因。
  • 生产控制链路必须以设备 ACK 为最终结果,HTTP 请求成功不能等同于设备执行成功。
  • MVP 对在线设备同步模拟成功、离线设备保留为等待中,用于验证交互闭环。

3.5 告警与审计

  • 告警覆盖设备离线、遥测越限、设备故障事件、指令失败和平台链路异常。
  • 告警状态为待处理、处理中、已恢复;支持处理人、处理备注和通知策略扩展。
  • 登录、注册设备、修改设备、绑定设备、下发指令和处置告警等关键动作必须写审计日志。

3.6 首期验收场景

编号场景验收结果
AC-01管理员登录并查看总览返回设备总数、在线数、用户数、待处理告警和当日指令
AC-02创建产品并注册设备ProductKey、序列号保持唯一,设备初始为未绑定/离线
AC-03合法设备上报遥测保存遥测、合并设备影子、刷新在线和最后活跃时间
AC-04非法密钥上报返回 401,不保存数据、不改变设备状态
AC-05用户输入有效绑定码设备归属当前用户,小程序列表立即可见
AC-06用户访问他人设备返回 404/403,不泄露设备详情
AC-07用户控制在线设备创建指令,收到 ACK 后状态成功且影子值更新
AC-08控制离线设备指令进入等待/超时路径,不伪报成功
AC-09运维访问管理员审计接口返回 403
AC-10管理员处置告警告警转为已恢复并形成审计记录

4. 第二阶段:开放设备与应用能力

  • 发布设备接入规范、主题规范、签名算法、SDK 和自助联调工具。
  • 允许客户创建产品、定义物模型、接入自有设备,但所有数据必须进入租户隔离边界。
  • 提供应用配置能力:先以模板和主题变量实现白标小程序,再逐步演进到受约束的低代码页面编排。
  • 低代码配置采用版本化 DSL、组件白名单、预览/发布审批和回滚机制,避免直接执行用户脚本。

5. 第三阶段:企业 SaaS 与服务履约

  • 引入企业、部门、成员、角色、项目、站点和设备组等租户实体。
  • 支持企业管理员批量注册/绑定设备、创建服务账号、配置 Webhook 和 OpenAPI 凭证。
  • 现场安装服务以工单建模:派单、接单、签到、安装清单、现场图片、工时、验收和费用。
  • 对接 OEM/ERP 时提供租户级 API、限流、签名、幂等键、回调重试、调用日志和数据授权。
  • 第二阶段与第三阶段可并行,但租户隔离和 API 权限必须先于客户开放。

6. 非功能基线

维度MVP生产一期目标
API 可用性单实例可运行≥99.9%,无状态多实例
管理 API本地数据下 P95 < 500msP95 < 300ms(不含设备 ACK)
消息链路HTTP 模拟、JSON 文件EMQX + Kafka,至少一次投递,业务幂等
设备规模3 个演示设备1,000 台同时在线,可水平扩展至 10,000+
数据安全HMAC 令牌、PBKDF2 密码TLS、KMS、密钥轮换、字段加密、最小权限
可观测性健康接口、审计记录指标、日志、追踪、SLO 和分级告警
数据恢复可复制 JSON 数据文件PostgreSQL PITR;RPO≤15min,RTO≤60min

7. 明确边界

  • MVP 不直接模拟真实微信 code2Session、MQTT Broker、短信通知和支付。
  • JSON 文件仅用于单机需求验证,不用于多实例或生产数据。
  • 首期不承诺通用低代码平台、跨企业数据共享、任意脚本运行和全协议一次性交付。
  • 生产前必须完成企业租户隔离、密钥托管、真实设备 ACK、幂等、限流、备份恢复和安全测试。

8. 当前可运行交付

实现位于 物联网项目文档/iot-platform-mvp。执行 npm test 验证 API、角色边界、设备上报、绑定控制和三端静态完整性;执行 npm start 后访问 /admin/ 使用管理后台。小程序工程位于 mini-program,可直接导入微信开发者工具。

Top level docs

需求分析与文档总览

1. 对《本司设备联网管理》的分析结论

原始文档已经明确了正确的产品主线:自有设备先行 → 用户设备控制 → 开放客户设备 → 企业 SaaS 与 OpenAPI。其价值不是单纯“做一个 MQTT 管理页面”,而是建立设备产品化、交付和持续服务能力。

已明确的内容

  • 管理对象包含 MQTT 透传设备、网关、智能开关等多类本司硬件。
  • 第一阶段优先做设备录入、注册、接入、功能配置、指令下发、用户和设备绑定。
  • 终端用户通过小程序或 App 扫码/输入唯一编号绑定并控制设备。
  • 后续允许客户按平台协议接入自有设备,并提供企业 SaaS、定制 OpenAPI 和现场安装管理。
  • 第二阶段的应用个性化和第三阶段的企业服务可以部分并行。

原文需要补齐的关键点

缺口直接风险本次处理
“录入、注册、接入、绑定”边界不清数据状态混乱、设备容易重复归属定义产品、设备身份、联网鉴权、用户绑定四个不同过程
设备控制只有“下发”没有 ACK 状态机页面显示成功但设备未执行定义 pending/sent/succeeded/failed/timeout 状态机
未定义个人、运维、平台、企业角色越权访问其他用户或企业设备建立 RBAC + 设备归属 + 后续 tenant_id 边界
物模型和设备影子没有落到业务每类硬件都写一套页面和协议以属性、服务、事件统一表达能力,以影子承载期望/上报状态
第二/三阶段范围过大首期同时建设低代码、ERP、SaaS 导致失焦以纵向 MVP 验证首期闭环,再按能力门槛迭代
安全、幂等和密钥生命周期未描述重放、冒用、重复指令和数据泄漏补充设备密钥、Token、幂等键、审计与生产门禁
指标缺少测量口径无法验收增加 AC-01~AC-10 和非功能基线

2. 关键产品决策

  1. 第一阶段只闭环,不铺摊子:优先交付一条可测试路径——创建产品 → 注册设备 → 上报 → 用户绑定 → 查看 → 控制 → 审计。
  2. 物模型是产品级契约:产品定义能力;设备保存身份、归属和运行态,不把展示字段直接硬编码成协议。
  3. 设备影子不是历史库:影子保存当前期望值/上报值及版本;遥测历史进入时序数据存储。
  4. 绑定关系必须原子且可追溯:扫码只是输入方式,真正安全边界是绑定码状态、设备当前归属和用户身份。
  5. HTTP 成功不代表控制成功:生产环境以设备 ACK 更新最终状态,超时应明确显示。
  6. 多租户在开放前建设:任何客户自有设备、白标应用或 OpenAPI 都必须先具有 tenant_id 隔离。

3. 文档导航与权威性

文档用途状态
交付记录/MODIFIED_FILE.md从原文整理的产品/交付需求基线当前范围权威来源
3需求分析.md全量软硬件需求框架已有;与需求基线联合阅读
4概要设计.md目标架构和模块划分已有;MVP 取舍见实现说明
5系统设计.md生产目标的详细设计已有;当前代码映射见 5-1
5-1系统设计-一些细节.md状态机、鉴权、数据一致性和实现映射本次完善
6.测试文档.md测试策略已有;自动验证由 npm test 执行
7.部署与运维.md本地与生产部署、备份和观测本次完善
8.用户手册.md管理后台与小程序操作本次完善
9验收文档.md阶段验收已有;以 AC-01~AC-10 作为 MVP 门槛
10.MVP实现说明.md本次实现范围、运行和技术债本次新增
11.API接口文档.md当前可执行 API 契约本次新增
12.迭代路线图.md从 MVP 到生产一期及 SaaS 的演进本次新增

若旧文档中的示例技术栈、接口路径或性能数字与当前实现冲突:当前代码以 11.API接口文档.md 为准,产品范围以 MODIFIED_FILE.md 为准,生产目标以概要/详细设计为准。

4. 本次实现覆盖矩阵

能力管理后台后端小程序自动验证
管理员/运维登录✓✓—✓
产品查看/创建✓✓—✓
设备查看/注册✓✓—✓
设备遥测与在线状态展示✓展示✓
用户设备绑定查看✓✓✓
设备指令✓✓✓✓
告警查看与处置✓✓—✓
用户与角色边界✓✓✓✓
审计日志✓✓—✓
真实 MQTT/ACK—接口语义预留—后续
企业多租户/OpenAPI———二/三期

5. 评审建议

产品评审先确认首期边界、绑定/解绑规则和离线控制体验;硬件评审确认设备身份烧录、绑定码标签和 ACK 格式;后端评审确认多租户迁移策略和消息幂等;测试评审直接以 AC-01~AC-10 转成环境化用例。完成这四项后再进入真实 EMQX 与数据库开发,可显著减少返工。

第十部分:MVP 实现说明

10.1 交付目标

本次不是只输出界面原型,而是提供一个可以直接运行和自动验证的纵向 MVP。它以同一份领域数据连接管理后台、后端和微信小程序,覆盖首期最关键的设备交付闭环。

工程路径:物联网项目文档/iot-platform-mvp。

10.2 技术选择

端技术选择理由
后端Node.js 20+ 内置 HTTP/crypto/fs零第三方依赖,可在当前环境直接运行和审查
持久化原子替换 JSON 文件便于 MVP 演示、测试隔离和回滚;明确不是生产数据库
管理后台原生 HTML/CSS/JavaScript SPA无构建步骤,服务启动即可访问;响应式桌面/移动布局
小程序原生微信小程序 WXML/WXSS/JS可直接导入微信开发者工具,保留平台 API 对接方式
测试Node.js assert + 真实 HTTP 端到端请求不使用 Mock 路由,验证认证、持久化和响应契约

10.3 已实现能力

后端

  • PBKDF2 密码哈希、HMAC 签名令牌、过期校验。
  • admin/operator/user 三种角色及接口级授权。
  • 产品查询/创建、设备查询/注册/修改、用户查询。
  • 设备凭据校验、遥测接收、属性合并、在线态刷新。
  • 小程序登录适配、设备绑定、归属过滤、设备详情。
  • 管理端/用户端指令下发、在线成功与离线等待状态。
  • 告警列表/处置、关键动作审计、健康检查。
  • JSON 写入使用临时文件加同卷重命名,避免半写文件。

管理后台

  • 登录页、响应式侧栏和角色感知菜单。
  • 运营统计、近七日趋势、最近告警。
  • 产品、设备、用户、指令、告警和审计页面。
  • 新建产品、注册设备、下发指令、处理告警等实际 API 操作。
  • 本地令牌会话、401 自动退出、错误消息和加载态。

用户小程序

  • 微信登录入口及本地 demo 登录。
  • 本人设备汇总、在线/离线状态、下拉刷新。
  • 扫码或手工输入绑定码。
  • 设备属性和基础信息查看、开关控制。
  • 个人中心和退出登录。

10.4 启动与验证

cd E:\_1\notes\项目规划\物联网项目文档\iot-platform-mvp
npm test
npm start
  • 管理后台:http://127.0.0.1:3000/admin/
  • 健康检查:http://127.0.0.1:3000/api/health
  • 管理员:admin / admin123
  • 运维人员:operator / operator123
  • 演示绑定码:IOT-GW-0002

默认数据首次运行写入 backend/data/db.json。需要恢复演示数据时,停止服务后删除该文件并重新启动;正式数据请先备份。测试使用独立临时文件,不污染运行数据。

10.5 配置

环境变量默认值说明
PORT3000HTTP 监听端口
TOKEN_SECRET内置开发值令牌签名密钥;生产必须使用长随机值并托管
DATA_FILEbackend/data/db.jsonJSON 数据文件绝对或相对路径

小程序 API 地址在 mini-program/config.js。开发者工具可使用 127.0.0.1;手机真机需要开发机局域网地址,生产必须使用已备案且加入小程序合法域名的 HTTPS 地址。

10.6 代码结构

iot-platform-mvp/
├─ backend/
│  ├─ server.js          路由、授权、静态资源和领域用例
│  └─ lib/
│     ├─ auth.js         密码与令牌
│     └─ store.js        种子数据与原子持久化
├─ admin-web/
│  ├─ index.html         应用骨架
│  ├─ styles.css         设计系统与响应式样式
│  └─ app.js             页面渲染与 API 交互
├─ mini-program/         微信小程序入口、页面和请求封装
├─ tests/verify.js       端到端验证
└─ scripts/static-check.js

10.7 已知技术债与生产门禁

  1. 存储:JSON 仅支持单进程;生产迁移 PostgreSQL/TimescaleDB 并引入数据库迁移工具。
  2. 消息:当前没有连接真实 EMQX;生产实现 Topic ACL、Kafka 消费、设备 ACK、超时重试和幂等。
  3. 认证:微信登录仅保留适配入口;生产服务端调用 code2Session,校验 AppID/密钥,并处理手机号授权和账号合并。
  4. 密钥:MVP 为联调保存设备明文密钥;生产创建时一次展示,服务端使用 KMS/哈希,提供轮换和吊销。
  5. 令牌:生产使用轮换密钥、refresh token、设备/会话撤销和更短后台令牌周期。
  6. 前端工程化:规模扩大后迁移 Vue/React + TypeScript、组件库、路由、端到端浏览器测试和构建产物完整性。
  7. 可观测性:增加结构化日志、requestId、指标、链路追踪、Broker 指标和 SLO 告警。
  8. 多租户:开放客户前所有主表加入 tenant_id,建立行级隔离测试、租户级限流和凭据 scope。

10.8 完成定义

本 MVP 的“完成”指:工程零依赖安装即可启动;确定性测试验证关键权限与业务路径;后台能执行实际数据操作;小程序页面和请求链路完整;需求、接口、部署、用户和迭代文档互相引用。它不等同于生产环境已经满足规模、安全和可靠性目标。

第十一部分:MVP API 接口文档

11.1 通用约定

  • Base URL:http://127.0.0.1:3000
  • 编码:UTF-8 JSON。
  • 管理员和小程序接口通过 Authorization: Bearer <token> 认证。
  • 成功响应:{"code":0,"message":"ok","data":...}。
  • 失败响应:{"code":"ERROR_CODE","message":"可读说明"},HTTP 状态码表达错误类型。
  • 时间为 ISO 8601 UTC 字符串;调用方按本地时区显示。
  • 当前 API 为 MVP v0 契约。生产版建议统一增加 /api/v1 前缀、requestId 和分页结构。

11.2 公共与认证接口

GET /api/health

无需认证。返回服务、数据结构版本和服务器时间。

POST /api/auth/login

管理后台登录。

{ "username": "admin", "password": "admin123" }

返回 token 和去除密码字段的 user。错误账号返回 HTTP 401 / INVALID_CREDENTIALS。

GET /api/auth/me

返回当前令牌对应用户,用于恢复后台会话。

11.3 管理后台接口

方法与路径角色说明
GET /api/admin/summaryadmin/operator汇总指标、趋势、最近告警和指令
GET /api/admin/productsadmin/operator产品列表,支持 keyword
POST /api/admin/productsadmin创建产品
GET /api/admin/devicesadmin/operator设备列表,支持 keyword、status
POST /api/admin/devicesadmin/operator注册设备
PATCH /api/admin/devices/{id}admin/operator修改名称、固件或演示状态
GET /api/admin/usersadmin/operator用户及绑定设备数量
GET /api/admin/commandsadmin/operator指令列表
POST /api/admin/commandsadmin/operator下发指令
GET /api/admin/alertsadmin/operator告警列表,支持 status
PATCH /api/admin/alerts/{id}admin/operator更新告警状态
GET /api/admin/auditsadmin最近 100 条审计日志

创建产品:

{
  "name": "四路智能开关",
  "productKey": "SWITCH-4CH",
  "category": "智能开关",
  "protocol": "MQTT",
  "modelVersion": "1.0.0"
}

注册设备:

{
  "deviceName": "客户展厅开关",
  "serialNumber": "SW20260912001",
  "productId": "p-switch",
  "firmware": "1.0.0"
}

服务端生成 id、bindCode 和 deviceSecret。生产 API 应只在创建响应展示一次 deviceSecret,列表永不返回。

下发指令:

{
  "deviceId": "d-switch-001",
  "name": "setPower",
  "params": { "power": false }
}

在线设备在 MVP 返回 succeeded;离线设备返回 pending。生产接口应优先返回 202/pending,由 ACK 异步更新。

处置告警:

{ "status": "resolved" }

11.4 设备接入接口

POST /api/device/telemetry

请求头:

X-Device-Id: d-switch-001
X-Device-Secret: dev-secret-switch-001
Content-Type: application/json

请求体:

{
  "ts": "2026-09-12T04:00:00.000Z",
  "data": { "power": true, "voltage": 220.1, "current": 0.16 }
}

成功返回 HTTP 202,并保存遥测、合并当前属性、将设备设为在线并刷新 lastSeenAt。凭据错误返回 401 DEVICE_AUTH_FAILED;data 不是对象返回 422。

真实 MQTT 接入的 Topic 和消息信封见 5-1系统设计-一些细节.md。

11.5 小程序接口

POST /api/mini/auth/wechat

{ "code": "wx.login 返回的 code", "nickname": "微信用户" }

MVP 将 code 映射为演示 openid;生产服务端必须调用微信 code2Session,不得信任客户端上传的 openid。

GET /api/mini/devices

只返回当前用户绑定设备,响应中不包含 deviceSecret。

POST /api/mini/devices/bind

{ "bindCode": "IOT-GW-0002" }

无效码返回 404;被其他用户绑定返回 409;属于同一用户时幂等返回设备。

GET /api/mini/devices/{id}

返回本人设备详情以及最近 20 条遥测。请求其他用户或未绑定设备统一返回 404。

POST /api/mini/devices/{id}/commands

{ "name": "setPower", "params": { "power": true } }

只有设备所有者可以操作。

11.6 错误码

HTTPcode场景
400INTERNAL_ERROR(带格式说明)JSON 解析失败
401UNAUTHORIZED令牌缺失、错误或过期
401INVALID_CREDENTIALS后台账号或密码错误
401DEVICE_AUTH_FAILED设备身份无效
403FORBIDDEN角色权限不足
404NOT_FOUND / DEVICE_NOT_FOUND路由或授权范围内对象不存在
409PRODUCT_KEY_EXISTS / SERIAL_EXISTS唯一键冲突
409DEVICE_ALREADY_BOUND设备已归属其他用户
422VALIDATION_ERROR必填字段或数据类型不满足

11.7 调用示例(PowerShell)

$login = Invoke-RestMethod -Method Post `
  -Uri http://127.0.0.1:3000/api/auth/login `
  -ContentType 'application/json' `
  -Body '{"username":"admin","password":"admin123"}'

$headers = @{ Authorization = "Bearer $($login.data.token)" }
Invoke-RestMethod -Uri http://127.0.0.1:3000/api/admin/devices -Headers $headers

第十二部分:迭代路线图

12.1 演进原则

先证明单设备纵向闭环,再建设稳定消息链路和数据底座;先建设租户隔离,再开放客户接入;先提供受控模板,再考虑通用低代码。每个阶段都以可观测、可回滚和可验收为完成条件。

12.2 里程碑

M0:可运行 MVP(已交付)

  • 管理后台、Node.js API、微信小程序三端贯通。
  • 产品、设备、用户绑定、遥测、指令、告警、审计核心用例。
  • 确定性自动测试和本地启动文档。

退出条件:本地 npm test 全部通过,后台实际可操作,小程序工程结构完整。

M1:生产数据与认证底座(建议 2~3 个迭代)

  • PostgreSQL 表结构、迁移、连接池、事务和唯一约束。
  • TimescaleDB 遥测分区、保留策略和聚合查询。
  • Redis 会话/缓存/在线 TTL/分布式幂等。
  • OAuth2/OIDC 管理端认证,真实微信 code2Session,设备凭据加密与轮换。
  • OpenAPI 规范生成、统一分页、错误码、requestId、结构化日志。

退出条件:数据迁移演练通过;备份恢复满足 RPO/RTO;越权、重放和密钥泄漏测试通过。

M2:真实设备消息闭环(建议 2~4 个迭代)

  • EMQX TLS、设备鉴权、Topic ACL、连接事件。
  • Kafka 消息总线、Schema 版本、死信、消费幂等和重放工具。
  • Transactional Outbox 指令发布、ACK 状态机、超时重试与取消。
  • 完整 desired/reported 设备影子、乱序保护和离线补偿策略。
  • 设备模拟器、固件联调环境和 100/1,000 台阶梯压测。

退出条件:真实开关/网关完成连续 7 天稳定联调;不丢业务数据;重复消息不产生重复副作用。

M3:本司设备试点上线(建议 2 个迭代)

  • 设备批量导入、产线烧录/凭据交付、二维码标签。
  • OTA 任务、灰度、版本分组和失败回退。
  • 告警规则、通知、运维工单和设备诊断包。
  • 管理后台工程化、浏览器 E2E;小程序隐私合规、体验优化和发布流程。
  • 监控面板、SLO、值班手册、容量模型和故障演练。

退出条件:试点设备运行 30 天;重大故障为零;核心 SLO 达标;用户和运维验收通过。

M4:企业多租户与开放接入

  • Tenant、组织、成员、角色、设备组、站点和数据授权。
  • 企业自助产品/物模型/设备注册,接入 SDK 和在线调试。
  • 租户级 API Key/OAuth Client、scope、限流、调用日志、Webhook 重试。
  • 租户隔离自动化测试、账单计量预留、数据导出/删除。
  • 白标小程序先采用主题与页面模板,不开放任意代码执行。

退出条件:租户交叉访问测试 100% 阻断;首个企业客户完成沙箱联调和数据授权确认。

M5:企业服务履约与低代码

  • 安装工单、派单、签到、物料、图片、工时、远程协助和验收。
  • OEM/ERP 连接器、字段映射和客户专属 OpenAPI 套餐。
  • 受约束的页面 DSL、组件白名单、预览、审批、版本和回滚。
  • 多品牌构建和发布流水线、配置审计和运行分析。

退出条件:现场服务全流程线上化;低代码产物通过安全沙箱和发布审核;至少两类企业方案可复制。

12.3 优先级 Backlog

优先级工作项价值前置
P0PostgreSQL 数据模型与迁移消除单机存储限制无
P0EMQX 设备鉴权/ACL 与真实 ACK控制结果可信设备协议定稿
P0绑定码一次性/过期/重置交付安全数据库事务
P0设备/消息/指令幂等避免重复副作用唯一键、messageId
P0监控、日志、备份恢复可运营生产环境
P1批量注册与二维码标签提升出厂效率产品/设备模型稳定
P1OTA 灰度和回退降低固件升级风险真实设备链路
P1用户解绑/转移/家庭分享完善用户生命周期绑定审计
P1告警规则和通知提升运维效率遥测流
P2多租户与企业门户开放企业客户M1/M2 完成
P2OpenAPI/Webhook系统集成租户和 API 治理
P3白标模板/低代码个性化交付安全沙箱和发布治理

12.4 不应提前建设的内容

  • 未有稳定物模型前建设通用拖拽页面,会把不稳定协议扩散到 UI DSL。
  • 未有租户隔离前开放客户设备,会造成数据边界返工和安全风险。
  • 未有指令 ACK 与幂等前批量控制,会放大不可确认和重复执行问题。
  • 未有真实容量数据前上复杂微服务/Kubernetes,不利于定位产品闭环问题。

12.5 每阶段质量门禁

需求和验收条件已编号;API 契约和数据库迁移向后兼容;单元/集成/端到端/设备联调测试通过;权限与租户隔离有反向用例;监控和容量预算更新;发布包含升级、验证、回滚步骤;故障复盘形成下一迭代工作项。

  1. 前言

    本文档为物联网设备管理平台(软硬件一体化)项目的全套工程文档,涵盖项目从立项调研到测试验收的全生命周期,包含软件工程与硬件设备开发工程相关内容。结合项目定位(软硬件一体化提供商,提供多场景物联网服务),文档采用“核心内容整合、软硬件模块分离”的结构——整体项目框架、立项、可行性分析等核心内容统一整合,软件、硬件的需求分析、设计、测试等专项内容独立分节,既保证项目整体性,又兼顾软硬件开发的专业性和独立性,解决“整合与分离”的核心需求。软件技术选型、硬件设备选型可根据自身规划,直接填充至对应章节的指定位置。

    项目核心定位:作为软硬件一体化物联网服务提供商,搭建支持MQTT、TCP、HTTP等多协议设备接入的物联网设备管理平台,面向智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,为客户提供全流程软件+硬件一体化服务,实现设备接入、数据采集、远程控制、数据分析等核心功能。

    第一部分:立项文档

    1.1 项目立项报告

    1.1.1 项目名称

    物联网设备管理平台(软硬件一体化)建设项目

    1.1.2 项目发起单位/负责人

    发起单位:__________

    项目负责人:__________

    项目团队:__________(可补充开发、测试、硬件集成等核心成员)

    1.1.3 项目背景与意义

    1.1.3.1 研究背景

    随着物联网技术的快速普及,各行业设备联网规模呈现指数级增长,从工业生产线上的传感器、智能工厂的PLC控制器,到消费领域的智能家居终端,设备类型日益复杂,连接需求愈发多样。当前市场中,多数物联网服务存在“软件与硬件脱节”“设备接入协议单一”“场景适配性差”等痛点,传统设备管理模式依赖人工巡检,故障响应滞后,数据分散存储难以形成有效价值,且不同厂商设备协议差异大,系统集成难度高,常出现“哑设备”现象。同时,智能家居、智慧农业、智能工厂等领域对物联网服务的需求持续升级,客户亟需“一站式”软硬件一体化解决方案,而非单独采购软件平台与硬件设备后自行整合,这为软硬件一体化物联网服务提供商提供了广阔的市场空间。

    在此背景下,我们计划搭建物联网设备管理平台,支持MQTT、TCP、HTTP等多协议设备接入,整合自主研发/选型的硬件设备,为各行业客户提供从设备部署、数据采集到远程控制、数据分析的全流程服务,解决行业痛点,满足市场需求。

    1.1.3.2 项目意义

    1. 商业意义:立足软硬件一体化定位,填补市场“一站式”物联网服务空白,拓展智能家居、智慧农业、智能工厂等多场景客户群体,打造差异化竞争优势,实现商业价值变现;

    2. 技术意义:整合多协议接入技术、软硬件协同技术,形成可复用、可扩展的物联网设备管理体系,提升自身技术积累,为后续场景拓展奠定基础;

    3. 行业意义:助力各行业客户实现设备智能化管理,降低运维成本、提升数据利用价值,推动传统行业数字化转型,契合国家数字经济与物联网产业发展战略。

    1.1.4 项目目标

    1.1.4.1 总体目标

    搭建一套稳定、高效、可扩展的物联网设备管理平台,实现软硬件深度协同,支持多协议设备接入、多场景服务落地,成为专业的物联网软硬件一体化服务提供商,满足客户在设备管理、数据采集、远程控制等方面的核心需求,提升客户满意度与市场占有率。

    1.1.4.2 阶段性目标

    1. 立项调研阶段(1-2周):完成市场调研、技术调研,确定软硬件选型方案,完善可行性分析;

    2. 需求分析与设计阶段(3-4周):完成软件、硬件的需求分析,完成概要设计、详细设计,输出设计文档;

    3. 开发实现阶段(8-10周):完成软件平台开发、硬件设备选型与集成,实现软硬件协同联调;

    4. 测试验收阶段(2-3周):完成软件、硬件及系统集成测试,修复问题,通过验收,输出测试报告;

    5. 上线部署阶段(1-2周):完成平台上线、硬件部署,提供客户培训与技术支持,进入运维阶段。

    1.1.5 项目范围

    1. 软件范围:物联网设备管理平台开发,包括设备接入模块、数据采集与存储模块、远程控制模块、数据分析模块、用户管理模块、权限管理模块等,支持MQTT、TCP、HTTP等多协议接入;

    2. 硬件范围:硬件设备选型、集成与调试,包括温湿度传感器、控制终端、通信模块等,适配多场景部署,与软件平台实现无缝对接;

    3. 服务范围:为客户提供软硬件一体化部署、调试、培训、售后技术支持,覆盖智能家居、智慧农业、智能工厂等核心场景;

    4. 排除范围:不涉及硬件设备的核心芯片自主研发(仅做选型与集成),不涉及第三方平台的二次开发(除非客户特殊需求)。

    1.1.6 项目资源需求

    1. 人力资源:软件开发工程师、硬件工程师、测试工程师、产品经理、项目管理人员、运维工程师;

    2. 硬件资源:测试用硬件设备(传感器、控制终端、通信模块等)、服务器、网络设备;

    3. 软件资源:开发工具、测试工具、数据库、操作系统、协议调试工具等;

    4. 资金资源:研发资金、硬件采购资金、测试资金、培训资金等。

    1.1.7 立项审批意见

    审批人:__________

    审批意见:__________

    审批日期:__________

第二部分:可行性分析报告

2.1 概述

本报告针对物联网设备管理平台(软硬件一体化)项目,从市场、技术、经济、操作、风险五个维度进行可行性分析,判断项目是否具备实施条件,为项目决策提供科学依据。本次分析基于当前市场环境、技术水平及自身资源,结合项目目标与范围,确保分析结果真实、可靠、具有指导性。

2.2 市场可行性分析

  1. 市场需求:随着物联网技术在各行业的渗透,智能家居、智慧农业、智能工厂等领域对设备管理平台的需求持续增长,客户对“软硬件一体化”服务的需求日益迫切,避免了单独采购软硬件的整合成本与技术壁垒,市场空间广阔;

  2. 市场竞争力:当前市场中,多数物联网服务提供商要么只做软件平台,要么只做硬件设备,软硬件一体化提供商较少,项目凭借“多协议接入”“多场景适配”“一站式服务”的优势,可形成差异化竞争,契合市场需求;

  3. 市场前景:物联网产业处于快速发展阶段,政策支持力度大,各行业数字化转型加速,未来对物联网设备管理平台及软硬件一体化服务的需求将持续提升,项目具有良好的市场前景和可持续性。

2.3 技术可行性分析

  1. 技术成熟度:MQTT、TCP、HTTP等设备接入协议已成为物联网领域的主流协议,技术成熟、应用广泛;软件平台开发(如设备管理、数据存储、远程控制)、硬件设备选型与集成技术均已成熟,不存在难以突破的技术壁垒;

  2. 技术储备:项目团队已明确软件技术选型与硬件设备选型方案,具备相关的开发、集成、测试技术能力,可支撑项目顺利实施;

  3. 软硬件协同:采用成熟的软硬件协同技术,通过标准化接口实现软件平台与硬件设备的无缝对接,可确保数据传输稳定、控制指令高效执行,解决软硬件脱节问题;

  4. 可扩展性:软件平台采用模块化设计,硬件设备支持灵活替换与扩展,可根据后续市场需求,快速适配新的场景、新的设备类型,技术扩展性强。

2.4 经济可行性分析

  1. 成本估算:项目成本主要包括硬件采购成本、研发成本、人力成本、测试成本、培训成本、运维成本等,结合项目规模与阶段性目标,成本可控,可通过合理规划优化成本;

  2. 收益预测:项目收益主要来自软硬件一体化服务收费、设备销售、售后运维服务等,随着客户群体的拓展,收益将逐步提升,预计在项目上线后1-2年内实现盈利;

  3. 投资回报:综合成本与收益分析,项目投资回报率合理,风险可控,具备良好的经济可行性,符合企业长期发展战略。

2.5 操作可行性分析

  1. 团队能力:项目团队具备软件开发、硬件集成、测试、项目管理等相关能力,可熟练完成项目各阶段工作,确保项目顺利推进;

  2. 操作难度:软件平台操作界面简洁、易用,客户可快速掌握设备接入、数据查看、远程控制等操作;硬件设备部署简单、调试便捷,可适应不同场景的部署需求;

  3. 运维保障:制定完善的运维方案,配备专业的运维工程师,可及时处理软件故障、硬件故障,保障平台与设备的稳定运行,降低操作与运维难度。

2.6 风险可行性分析

2.6.1 潜在风险

  1. 技术风险:软硬件协同过程中可能出现兼容性问题,设备接入过程中可能出现协议适配问题,影响平台稳定性;

  2. 市场风险:市场需求变化过快,或竞争对手推出同类产品,影响项目市场占有率;

  3. 成本风险:硬件采购价格波动、研发成本超支,导致项目成本增加;

  4. 进度风险:项目各阶段工作推进滞后,影响项目上线时间。

2.6.2 风险应对措施

  1. 技术风险:提前进行技术调研与测试,完善软硬件选型方案,加强软硬件协同联调,建立技术问题应急处理机制,及时解决兼容性、协议适配等问题;

  2. 市场风险:持续关注市场需求变化,加强市场调研,及时优化产品与服务,打造差异化优势,加强客户维护,提升客户粘性;

  3. 成本风险:合理规划采购计划,与供应商签订长期合作协议,控制硬件采购成本;优化研发流程,避免研发成本超支,建立成本监控机制;

  4. 进度风险:制定详细的项目进度计划,明确各阶段工作任务与时间节点,加强项目进度监控,及时调整工作安排,确保项目按时推进。

2.7 可行性结论

综合市场、技术、经济、操作、风险五个维度的分析,本项目市场需求明确、技术成熟、成本可控、风险可应对,具备完全的可行性,建议立项实施。

第三部分:需求分析文档

本章节采用“整体需求+软件需求+硬件需求”的结构,整体需求明确项目核心诉求,软件、硬件需求分别独立阐述,兼顾整合性与独立性,确保需求清晰、可落地、可验证。

3.1 整体需求

  1. 多协议接入需求:支持MQTT、TCP、HTTP等多种设备接入协议,实现不同类型、不同厂商硬件设备的快速接入,解决设备接入标准化问题;

  2. 软硬件协同需求:软件平台与硬件设备无缝对接,实现数据实时采集、远程控制指令高效下发,确保软硬件协同稳定运行;

  3. 多场景适配需求:适配智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,提供针对性的软硬件解决方案;

  4. 核心功能需求:实现设备管理、数据采集与存储、远程控制、数据分析、用户管理、权限管理等核心功能;

  5. 性能需求:平台运行稳定,数据传输延迟低,设备接入容量可扩展,硬件设备运行可靠、功耗合理;

  6. 易用性需求:软件平台操作简洁,硬件设备部署、调试便捷,客户可快速上手,降低使用成本;

  7. 安全性需求:保障设备接入安全、数据传输安全、数据存储安全,防止非法接入、数据泄露、指令篡改。

3.2 软件需求(独立模块)

3.2.1 功能需求

3.2.1.1 设备接入模块

  1. 支持MQTT、TCP、HTTP三种核心协议接入,可扩展其他物联网协议;

  2. 支持设备批量接入与单个接入,提供设备接入指引与配置工具;

  3. 实现设备接入验证(设备ID、密钥验证),防止非法设备接入;

  4. 支持设备在线状态监测,实时显示设备接入状态(在线、离线、异常),异常状态及时提醒;

  5. 支持设备接入日志记录,可查询设备接入时间、接入协议、接入状态等信息。

3.2.1.2 数据采集与存储模块

  1. 实时采集硬件设备上传的数据(如温湿度、设备运行参数、状态数据等),采集频率可配置;

  2. 支持数据格式解析与转换,确保不同设备的数据统一格式存储;

  3. 采用可靠的数据库存储数据,支持历史数据查询、导出,数据存储期限可配置;

  4. 支持数据异常检测,当数据超出预设阈值时,触发异常提醒;

  5. 保障数据传输过程中的完整性,防止数据丢失、篡改。

3.2.1.3 远程控制模块

  1. 支持通过软件平台向硬件设备下发控制指令(如开关控制、参数调节等);

  2. 实时反馈指令执行结果,显示设备执行状态;

  3. 支持控制指令日志记录,可查询指令下发时间、指令内容、执行结果;

  4. 支持批量控制多个设备,提高操作效率;

  5. 当指令执行失败时,提供失败原因提示,并支持重试功能。

3.2.1.4 数据分析模块

  1. 支持对采集的数据进行统计分析(如平均值、最大值、最小值、趋势分析等);

  2. 提供数据可视化展示(图表、报表等),便于客户直观查看数据变化;

  3. 支持自定义分析规则,根据客户需求生成针对性的分析报告;

  4. 针对不同场景(如智慧农业的土壤湿度分析、智能工厂的设备运行效率分析)提供专属分析功能。

3.2.1.5 用户管理与权限管理模块

  1. 支持用户注册、登录、密码重置、账号注销等功能;

  2. 支持用户信息管理(修改个人信息、绑定联系方式等);

  3. 支持权限分级管理,不同角色(管理员、普通用户、运维人员)拥有不同的操作权限;

  4. 管理员可管理所有用户、设备、数据,普通用户仅可查看自身绑定的设备与数据,运维人员可进行设备调试与故障处理。

3.2.1.6 系统管理模块

  1. 支持系统参数配置(如数据采集频率、异常阈值、存储期限等);

  2. 支持系统日志记录,可查询操作日志、设备日志、异常日志等;

  3. 支持系统升级与维护,确保系统稳定运行;

  4. 支持数据备份与恢复,防止数据丢失。

3.2.2 非功能需求

  1. 性能需求:平台响应时间≤1s,数据传输延迟≤500ms;支持至少1000台设备同时在线接入,可扩展至10000台以上;系统可用性≥99.9%;

  2. 安全性需求:采用加密技术(如SSL/TLS)保障数据传输安全;设备接入采用密钥验证,防止非法接入;数据存储加密,防止数据泄露;定期进行安全检测,及时修复安全漏洞;

  3. 可扩展性需求:采用模块化设计,支持功能模块扩展(如新增协议接入、新增分析功能);支持设备类型扩展,可适配新的硬件设备;

  4. 易用性需求:操作界面简洁、直观,导航清晰,客户可快速掌握操作方法;提供操作指引与帮助文档;

  5. 兼容性需求:支持Windows、Linux等主流操作系统,支持Chrome、Edge等主流浏览器;

  6. 可维护性需求:系统日志清晰,便于问题排查;模块之间低耦合,便于维护与升级。

3.2.3 接口需求

  1. 设备接入接口:支持MQTT、TCP、HTTP协议接口,用于设备与平台的数据交互;

  2. 硬件对接接口:提供标准化接口,用于软件平台与硬件设备的协同对接,支持数据采集与指令下发;

  3. 内部接口:各模块之间的接口,确保模块之间的数据交互顺畅;

  4. 外部接口(可选):提供API接口,支持与第三方平台对接,实现数据共享。

3.3 硬件需求(独立模块)

3.3.1 硬件设备类型及需求

3.3.1.1 感知设备

  1. 温湿度传感器:用于采集环境温湿度数据,精度≥±0.5℃(温度)、±5%RH(湿度);支持低功耗运行;支持MQTT/TCP/HTTP协议;适配室内外多种场景;

  2. 其他感知设备(可选):根据场景需求,配置土壤湿度传感器、光照传感器、压力传感器等,要求精度达标、运行稳定、支持对应协议。

3.3.1.2 控制终端

  1. 用于接收软件平台下发的控制指令,执行相应操作(如开关控制、参数调节);

  2. 支持与感知设备、通信模块对接,实现数据采集与指令执行;

  3. 运行稳定,响应迅速,指令执行延迟≤300ms;

  4. 支持低功耗模式,适配不同供电场景(市电、电池)。

3.3.1.3 通信模块

  1. 支持MQTT、TCP、HTTP三种核心协议,可实现设备与软件平台的数据传输;

  2. 通信稳定,信号强度强,支持远距离传输(根据场景需求,可选择Wi-Fi、4G/5G、LoRa等通信方式);

  3. 低功耗、小体积,便于部署;

  4. 支持自动重连功能,当网络中断后,可自动重新连接平台。

3.3.1.4 辅助设备

  1. 电源设备:为感知设备、控制终端、通信模块提供稳定供电,支持市电、太阳能、电池等多种供电方式;

  2. 部署支架:用于设备固定,适配室内外部署场景,防水、防尘、抗干扰。

3.3.2 硬件性能需求

  1. 运行可靠性:硬件设备平均无故障运行时间(MTBF)≥10000小时;

  2. 环境适应性:适应温度范围-20℃~60℃,湿度范围10%~90%RH;防水、防尘、抗电磁干扰;

  3. 功耗需求:感知设备、通信模块采用低功耗设计,电池供电模式下,续航时间≥6个月;

  4. 数据采集精度:各类传感器的数据采集精度符合行业标准,确保数据准确性;

  5. 兼容性:硬件设备之间可无缝对接,与软件平台通过标准化接口对接,支持协议适配。

3.3.3 硬件接口需求

  1. 通信接口:支持UART、SPI、I2C等常用接口,用于与通信模块、感知设备对接;

  2. 供电接口:标准化供电接口,支持不同供电方式接入;

  3. 扩展接口:预留扩展接口,便于后续新增设备或功能扩展。

3.3.4 硬件部署需求

  1. 部署便捷:设备体积小、重量轻,便于安装与部署,无需复杂施工;

  2. 维护便捷:设备支持远程调试、固件升级,减少现场维护工作量;

  3. 场景适配:根据智能家居、智慧农业、智能工厂等不同场景,提供对应的部署方案,确保设备运行稳定。

3.4 需求确认

需求提出人:__________

需求确认人:__________

确认日期:__________

核心逻辑:三层结构

文档可以理解为三个逻辑层次:

  1. 共识层(第1-3章):对齐背景、目标、用户,回答 “为什么做”和“为谁做”。
  2. 定义层(第4-7章):详细描述解决方案,回答 “做什么”和“做成什么样”。
  3. 约束与收尾层(第8-9章):明确边界和未尽事宜,回答 “在什么限制下做”和“如何跟踪”。

各章节详细作用分析

1. 文档概述:建立沟通基础与版本控制

  • 1.1 修订历史:【核心作用】记录每一次修改的作者、日期、原因和变更内容。这是文档的“时光机”,确保所有干系人看到的都是最新版本,且任何变更都有据可查,是责任追溯和变更管理的关键。
  • 1.2 项目背景与目标:【核心作用】阐明项目的商业驱动因素和价值。解释“为什么要启动这个项目”,以及项目成功的核心衡量指标。这是统一团队思想的基石,防止开发偏离业务初衷。
  • 1.3 文档目的与范围:【核心作用】定义本文档的读者对象和用途,并清晰划定项目的边界。明确指出“包含什么”和“不包含什么”(在范围外),这是管理需求蔓延、控制项目范围的第一道防线。
  • 1.4 名词术语解释:【关键作用】建立团队内部统一的语言体系。避免因业务术语、技术简称或行业黑话理解不一致导致的沟通成本和质量问题。
  • 1.5 参考文献:【辅助作用】列出撰写本文档所依据的会议纪要、市场报告、战略文档等,增加文档的可信度和可追溯性。

2. 干系人与用户分析:明确服务对象与场景

  • 2.1 干系人列表与关注点:【核心作用】识别所有利益相关方及其核心诉求与影响力。确保在决策和沟通中不遗漏任何关键角色,是项目管理的基础。
  • 2.2 用户角色画像:【核心作用】将抽象的用户群体具体化为有姓名、背景、目标的虚拟人物。帮助团队始终站在用户角度思考,确保产品设计是为人服务的。
  • 2.3 用户场景概述:【关键作用】描述用户画像在什么情境下会遇到什么问题、触发使用产品的动机。将用户需求和产品功能置于真实的故事场景中,让需求更鲜活。

3. 总体概述:描绘产品全景图

  • 3.1 产品愿景:【激励作用】用一句简洁有力的话描述产品的长期目标和最终状态,激发团队共鸣和热情。
  • 3.2 核心业务流程:【核心作用】通过跨职能流程图,展示不同角色如何协作完成一个端到端的核心业务。这是理解业务全貌的最佳工具,帮助技术人员理解业务上下文。
  • 3.3 系统上下文图:【关键作用】用一张图展示本系统与外部所有系统/用户的交互关系(数据流入流出)。明确系统在IT生态系统中的位置和接口,是系统架构设计的重要输入。
  • 3.4 假设与依赖:【风险管理作用】提前声明项目成功所依赖的外部条件(如“某第三方数据接口需在X月X日前提供”),识别早期风险。

4. 功能性需求:定义产品的具体能力(核心交付物)

  • 这是开发、测试工作的主要依据。
  • 4.x.x 概述:简要说明该功能模块的目的。
  • 4.x.x 用户故事/用例:【核心作用】以用户视角描述功能价值,是沟通的通用语言。
  • 4.x.x 业务流程详述:【核心作用】用活动图、流程图或步骤描述,详细说明功能的正常流程、备选流程和异常流程。这是将用户故事转化为可执行逻辑的关键。
  • 4.x.x 业务规则:【关键作用】明确功能背后的逻辑判断条件、计算公式、约束限制。是开发业务逻辑层和测试用例的直接输入。
  • 4.x.x 界面原型与说明:【可视化作用】将文字需求可视化,提前对齐UI/UX设计预期,减少返工。说明部分应解释关键交互和元素规则。
  • 4.x.x 验收标准:【合同作用】定义该功能“完成”的具体、可验证的条件。这是产品、开发、测试三方达成共识的“契约”,是测试用例的来源。

5. 非功能性需求:定义产品的品质与体验

  • 【至关重要,常被忽视】 决定产品在真实世界中是否“好用”和“耐用”。性能不好、不安全、难用的产品,功能再强也注定失败。
  • 5.1-5.6:分别从性能、安全、可用性、可靠性、兼容性、可维护性等维度提出可量化的指标(如“95%的页面加载时间小于2秒”),是测试和运维团队的工作基准。

6. 数据需求:定义系统的核心资产

  • 【承上启下作用】 业务需求的实现最终会体现在数据的产生、流转和存储上。此部分为数据库设计和前后端数据交互提供直接依据。

7. 外部接口需求:定义系统的协作方式

  • 【集成作用】详细定义本系统与外界(用户、硬件、其他软件)通信的契约,包括API的格式、协议、频率、数据样例等。是系统联调和集成测试的圣经。

8. 约束与假设:划定方案的设计边界

  • 【现实主义作用】明确告知开发团队,必须在哪些既定框架内进行设计(如“必须使用Oracle数据库”)。假设则是记录当前决策所基于的、尚未验证的判断,未来需要跟踪确认。

9. 附录:管理不确定性并提供追溯

  • 9.1 待确定问题列表:【透明化管理作用】公开记录所有悬而未决的问题(TBD),并指定负责人和解决日期。避免问题被遗忘,促进问题闭环。
  • 9.2 需求优先级矩阵:【指导迭代作用】清晰展示每个功能的优先级(如MoSCoW分类),是版本规划和迭代开发的直接输入。
  • 9.3 需求追踪矩阵:【质量保障作用】建立从需求源头(如用户故事ID)到设计文档、测试用例的双向链接。确保每个需求都被实现和验证,是应对变更、进行影响分析的强大工具。

总结

  1. 先同步“为什么”(愿景、目标),建立共识。
  2. 再明确“为谁做”(用户、干系人),聚焦服务对象。
  3. 然后详细描述“做什么”和“做多好”(功能与非功能需求),提供明确指导。
  4. 最后界定“在什么框架下做”和“如何确认做完”(约束、验收、追踪),管理风险与质量。
1. 文档概述
   1.1. 修订历史
   1.2. 项目背景与目标
   1.3. 文档目的与范围
   1.4. 名词术语解释
   1.5. 参考文献

2. 干系人与用户分析
   2.1. 干系人列表与关注点
   2.2. 用户角色画像
   2.3. 用户场景概述

3. 总体概述
   3.1. 产品愿景
   3.2. 核心业务流程(流程图)
   3.3. 系统上下文图(系统与外部实体的关系)
   3.4. 假设与依赖

4. 功能性需求
   4.1. 模块/功能点A
       4.1.1. 概述
       4.1.2. 用户故事/用例
       4.1.3. 业务流程详述
       4.1.4. 业务规则
       4.1.5. 界面原型与说明
       4.1.6. 验收标准
   4.2. 模块/功能点B
   (结构同上)

5. 非功能性需求
   5.1. 性能需求(响应时间、吞吐量、并发用户数)
   5.2. 安全性需求(认证、授权、审计、数据加密)
   5.3. 可用性需求(易用性、可访问性、帮助文档)
   5.4. 可靠性需求(可用率、平均故障间隔时间、数据备份)
   5.5. 兼容性需求(浏览器、操作系统、设备、第三方系统)
   5.6. 可维护性与可扩展性需求

6. 数据需求
   6.1. 数据实体与关系
   6.2. 关键数据字段定义
   6.3. 数据管理需求(初始化、迁移、保留、归档)

7. 外部接口需求
   7.1. 用户接口(UI风格指南)
   7.2. 硬件接口
   7.3. 软件接口(API规范、第三方系统集成方式)
   7.4. 通信接口(协议、数据格式)

8. 约束与假设
   8.1. 技术约束(指定技术栈、平台等)
   8.2. 业务约束(合规性要求、运营限制)
   8.3. 项目约束(预算、工期、资源)
   8.4. 项目假设清单

9. 附录
   9.1. 待确定问题列表
   9.2. 需求优先级矩阵(MoSCoW分类)
   9.3. 需求追踪矩阵(链接到设计、测试用例)xxxxxxxxxx 初期(6个月):- 设备数量:10,000台- 日均消息数:1000万条(每台设备10秒上报一次)- 日均数据量:10GB(每条消息1KB)- 存储需求:热数据7天≈70GB,全年数据≈3.6TB中期(2年):- 设备数量:100,000台- 日均消息数:1亿条- 日均数据量:100GB- 存储需求:全年数据≈36TB

第四部分:概要设计文档

本章节延续“整体概要设计+软件概要设计+硬件概要设计”的结构,整体设计明确项目架构框架,软件、硬件概要设计分别独立阐述,明确各模块的核心设计思路、接口设计、模块间交互关系,为详细设计奠定基础。

4.1 整体概要设计

4.1.1 项目架构设计

项目采用“云-边-端”协同的四层架构,实现软硬件一体化协同运行,具体架构如下:

  1. 感知层:由各类硬件设备组成(感知设备、控制终端、通信模块等),负责数据采集与指令执行,是项目的“终端入口”;

  2. 边缘层:部署在设备侧的边缘计算节点,承担协议转换、数据预处理、本地规则计算等功能,减少网络带宽占用,实现本地故障快速响应;

  3. 平台层:即物联网设备管理软件平台,包含各类核心功能模块,负责设备管理、数据处理、远程控制、数据分析等,是项目的“核心大脑”;

  4. 应用层:面向不同场景的可视化界面与服务,为客户提供设备操作、数据查看、分析报告等服务,适配智能家居、智慧农业、智能工厂等多场景。

4.1.2 软硬件协同架构

软件平台与硬件设备通过标准化接口实现协同,具体交互流程如下:

  1. 硬件设备通过通信模块,采用MQTT/TCP/HTTP协议接入软件平台,完成身份验证;

  2. 感知设备采集数据后,通过通信模块上传至软件平台,软件平台对数据进行解析、存储与分析;

  3. 软件平台下发控制指令,通过通信模块传输至控制终端,控制终端执行指令,并将执行结果反馈至软件平台;

  4. 边缘层负责数据预处理与本地决策,当出现紧急情况时,可直接控制硬件设备,同时将相关信息上报至软件平台。

4.1.3 设计原则

  1. 模块化设计:软件、硬件均采用模块化设计,便于功能扩展、维护与升级;

  2. 标准化设计:接口、协议采用行业标准,确保软硬件兼容性与可扩展性;

  3. 稳定性设计:优先选择成熟技术与设备,确保系统与设备运行稳定,降低故障发生率;

  4. 安全性设计:融入安全设计理念,保障设备接入、数据传输、存储的安全性;

  5. 易用性设计:兼顾软件操作与硬件部署的易用性,降低客户使用与维护成本。

4.2 软件概要设计(独立模块)

4.2.1 软件架构设计

软件平台采用分层架构设计,从上至下分为应用层、业务逻辑层、数据访问层、数据存储层,各层独立运行、相互协作,具体如下:

  1. 应用层:面向用户的操作界面,包括设备管理界面、数据查看界面、远程控制界面、数据分析界面等,负责用户交互;

  2. 业务逻辑层:核心业务处理层,包含设备接入、数据采集、远程控制、数据分析、用户管理等功能模块,负责业务逻辑处理;

  3. 数据访问层:负责与数据存储层交互,实现数据的查询、新增、修改、删除等操作,为业务逻辑层提供数据支持;

  4. 数据存储层:采用数据库存储各类数据(设备数据、用户数据、日志数据等),确保数据安全、可靠。

4.2.2 核心模块设计

4.2.2.1 设备接入模块

  1. 模块功能:负责设备接入验证、协议解析、在线状态监测、接入日志记录;

  2. 核心逻辑:设备发起接入请求→模块验证设备身份(设备ID、密钥)→验证通过后,根据接入协议解析数据→记录接入日志,更新设备在线状态;

  3. 依赖模块:数据访问层(存储设备信息、接入日志)、数据采集模块(接收设备上传的数据)。

4.2.2.2 数据采集与存储模块

  1. 模块功能:接收设备上传的数据、解析数据格式、存储数据、异常数据检测、数据备份与恢复;

  2. 核心逻辑:接收设备数据→解析数据格式(统一为标准格式)→检测数据是否异常→将正常数据存储至数据库,异常数据触发提醒并记录→定期进行数据备份;

  3. 依赖模块:设备接入模块(获取设备数据)、数据访问层(存储数据)、数据分析模块(提供数据支持)。

4.2.2.3 远程控制模块

  1. 模块功能:下发控制指令、接收指令执行结果、记录指令日志、指令重试;

  2. 核心逻辑:用户发起控制指令→模块生成标准化指令→通过通信接口下发至设备→接收设备执行结果→记录指令日志,若执行失败则提醒并支持重试;

  3. 依赖模块:设备接入模块(获取设备在线状态)、数据访问层(存储指令日志)。

4.2.2.4 数据分析模块

  1. 模块功能:数据统计分析、数据可视化、自定义分析规则、生成分析报告;

  2. 核心逻辑:从数据存储层获取数据→根据预设规则或自定义规则进行统计分析→生成可视化图表与分析报告→提供数据查询与导出功能;

  3. 依赖模块:数据采集与存储模块(获取数据)、数据访问层(存储分析结果)。

4.2.2.5 用户管理与权限管理模块

  1. 模块功能:用户注册、登录、信息管理、权限分配、角色管理;

  2. 核心逻辑:用户注册/登录→验证用户信息→根据角色分配权限→用户可修改个人信息,管理员可管理用户与权限;

  3. 依赖模块:数据访问层(存储用户信息、权限信息)。

4.2.2.6 系统管理模块

  1. 模块功能:系统参数配置、日志管理、系统升级、数据备份与恢复;

  2. 核心逻辑:管理员配置系统参数→模块记录各类日志→支持系统在线升级→定期进行数据备份,出现异常时可恢复数据;

  3. 依赖模块:数据访问层(存储系统参数、日志数据、备份数据)。

4.2.3 接口设计

4.2.3.1 设备接入接口

  1. MQTT接口:用于设备与平台的数据交互,采用MQTT 3.1.1协议,端口:1883(TCP)、8883(SSL);

  2. TCP接口:用于设备与平台的双向通信,端口:8080,数据格式:JSON;

  3. HTTP接口:用于设备上传数据与接收指令,请求方式:POST/GET,数据格式:JSON。

4.2.3.2 内部模块接口

  1. 设备接入模块→数据采集模块:提供设备数据接口,传递设备上传的数据;

  2. 远程控制模块→设备接入模块:提供指令下发接口,传递控制指令;

  3. 各模块→数据访问层:提供数据查询、新增、修改、删除接口,实现数据交互。

4.2.3.3 外部接口(可选)

提供RESTful API接口,支持与第三方平台对接,数据格式:JSON,采用API密钥验证,确保接口安全。

4.2.4 数据存储设计

  1. 数据库选型:__________(根据自身技术选型填写,如MySQL、MongoDB等);

  2. 数据分类存储:

(1)设备数据:存储设备ID、设备类型、接入协议、在线状态、部署位置等信息;

(2)采集数据:存储设备上传的温湿度、运行参数等数据,按时间戳排序;

(3)用户数据:存储用户账号、密码(加密存储)、个人信息、角色权限等信息;

(4)日志数据:存储设备接入日志、操作日志、指令日志、异常日志等信息;

(5)系统数据:存储系统参数、配置信息、备份数据等信息。

4.2.5 技术选型补充

  1. 开发语言:__________(如Java、Python、Go等);

  2. 前端框架:__________(如Vue、React等);

  3. 服务器:__________(如阿里云、腾讯云服务器等);

  4. 其他技术:__________(如消息队列、缓存技术等,根据自身选型填写)。

4.3 硬件概要设计(独立模块)

4.3.1 硬件整体架构设计

硬件系统由感知层设备、控制终端、通信模块、辅助设备组成,各设备通过标准化接口对接,形成完整的硬件体系,具体架构如下:

  1. 感知层:由温湿度传感器、其他场景化传感器组成,负责采集环境与设备数据;

  2. 控制层:由控制终端组成,负责接收软件平台指令,控制感知设备与执行器;

  3. 通信层:由通信模块组成,负责实现硬件设备与软件平台的数据传输与指令交互;

  4. 辅助层:由电源设备、部署支架等组成,为硬件系统提供供电与部署支持。

4.3.2 核心硬件设备设计

4.3.2.1 感知设备设计

  1. 温湿度传感器:

(1)核心组件:传感器芯片、数据处理单元、接口单元;

(2)工作原理:传感器芯片采集温湿度数据,经数据处理单元转换为标准格式,通过接口单元传输至控制终端;

(3)选型补充:__________(根据自身硬件选型填写传感器型号、厂商等)。

4.3.2.2 控制终端设计

  1. 核心组件:主控芯片、接口单元、执行单元、电源单元;

  2. 工作原理:主控芯片接收通信模块传输的控制指令,控制执行单元执行相应操作,同时通过接口单元获取感知设备的数据,上传至通信模块;

  3. 选型补充:__________(根据自身硬件选型填写主控芯片型号、厂商等)。

4.3.2.3 通信模块设计

  1. 核心组件:通信芯片、天线、接口单元、电源单元;

  2. 工作原理:通过通信芯片实现与软件平台的协议对接,接收平台指令并传输至控制终端,同时将控制终端上传的数据传输至平台;支持自动重连功能;

  3. 选型补充:__________(根据自身硬件选型填写通信模块型号、厂商、通信方式等)。

4.3.2.4 辅助设备设计

  1. 电源设备:采用市电+电池双供电模式,确保供电稳定;电池采用锂电池,支持充电与低功耗保护;

  2. 部署支架:采用防水、防尘、抗干扰设计,适配室内外部署,便于安装与固定。

4.3.3 硬件接口设计

  1. 感知设备与控制终端接口:采用UART接口,用于数据传输,波特率:9600bps;

  2. 控制终端与通信模块接口:采用SPI接口,用于指令与数据传输;

  3. 电源接口:采用DC 5V接口,支持市电与电池接入;

  4. 扩展接口:预留I2C接口,用于后续新增设备扩展。

4.3.4 硬件协同设计

  1. 数据传输流程:感知设备采集数据→通过UART接口传输至控制终端→控制终端处理数据→通过SPI接口传输至通信模块→通信模块通过MQTT/TCP/HTTP协议上传至软件平台;

  2. 指令执行流程:软件平台下发指令→通信模块接收指令→通过SPI接口传输至控制终端→控制终端解析指令→控制执行单元执行操作→将执行结果反馈至软件平台;

  3. 异常处理:当硬件设备出现故障时,控制终端触发异常提醒,通过通信模块上传至软件平台,同时启动备用机制(如备用电源、本地控制),确保业务连续性。

4.4 概要设计评审

评审人:__________

评审意见:__________

评审日期:__________

第五部分补充:关键系统设计细节

1. 领域边界

领域实体/聚合负责不负责
产品Product、ThingModelVersion产品能力定义和版本设备当前值
设备Device、Credential、DeviceShadow身份、归属、运行态用户会话
接入Connection、Telemetry、DeviceEvent协议适配、校验、标准化业务页面
控制Command、CommandAck指令生命周期、超时、重试直接修改历史遥测
身份User、Role、Tenant、Binding认证、授权、归属MQTT 会话
运维Alert、AuditLog、WorkOrder告警、审计、履约产品物模型定义

MVP 为便于运行将这些领域放在同一 Node.js 进程中,但 API 和数据结构保持可拆分边界。

2. 设备生命周期

planned → registered → activated → online/offline → disabled → retired
              │            │
              └─ unbound ──┴─ bound → transfer_pending → bound
  • registered:平台已经生成设备身份,但设备可能从未联网。
  • activated:设备首次通过身份验证;该动作不能与用户绑定混为一谈。
  • online/offline:瞬时连接状态,应结合 Broker 连接事件和心跳 TTL 判断。
  • bound:设备拥有终端用户归属;后台运维权限不等于所有权。
  • disabled/retired:禁止新连接或指令,但历史数据和审计保留。

当前 MVP 用 status + ownerId + lastSeenAt 表达核心子集,生产库应拆成生命周期状态、连接状态和绑定状态三个字段。

3. 设备身份与绑定码

设备密钥和绑定码用途不同:设备密钥证明“这台设备是谁”,绑定码授权“一次用户归属操作”。生产实现要求:

  1. 设备密钥由安全随机数生成,烧录或产线注入,密文/哈希保存,支持轮换和吊销。
  2. MQTT 鉴权至少校验 tenant/product/device,主题 ACL 限制设备只能访问自身 Topic。
  3. 绑定码不复用设备密钥;可一次性、可过期、可由管理员重置,并对连续失败限流。
  4. 绑定事务以设备记录加锁或条件更新:owner_id IS NULL → owner_id = current_user;受影响行数为零则返回冲突。
  5. 用户侧读取设备时始终追加 owner_id = current_user,对越权对象返回统一的不存在响应。

4. MQTT 主题和消息信封

上行属性: iot/v1/{tenantId}/{productKey}/{deviceId}/property/post
上行事件: iot/v1/{tenantId}/{productKey}/{deviceId}/event/{eventKey}/post
下行指令: iot/v1/{tenantId}/{productKey}/{deviceId}/command/get
指令回执: iot/v1/{tenantId}/{productKey}/{deviceId}/command/reply

统一信封:

{
  "messageId": "01J...",
  "deviceId": "d-123",
  "timestamp": 1789142400000,
  "version": "1.0",
  "data": { "power": true }
}

服务端以 (device_id, message_id) 去重;时间戳只用于事件时间,不用于认证的唯一依据;无效物模型字段进入死信/错误流,不直接污染设备影子。

5. 指令状态机和一致性

pending ──publish──> sent ──ACK(success)──> succeeded
   │                   ├──ACK(error)─────> failed
   │                   └──deadline───────> timeout
   └──cancel─────────────────────────────> canceled

写库和发 MQTT 采用 Transactional Outbox:在同一数据库事务写入 command 与 outbox_event;发布器发送成功后标记 Outbox;重复发布由设备/服务端按 commandId 幂等。只有合法 ACK 可以更新最终状态,且状态转换通过条件更新防止迟到 ACK 覆盖取消/超时状态。

MVP backend/server.js:addCommand 对在线设备立即生成 succeeded、离线设备生成 pending。这是联调替身,不是生产 ACK 实现。

6. 设备影子合并规则

影子至少包含:

{
  "desired": { "power": true },
  "reported": { "power": false },
  "delta": { "power": true },
  "desiredVersion": 12,
  "reportedVersion": 11,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}
  • 用户控制先写 desired 和新版本;设备 ACK/属性上报更新 reported。
  • delta 是 desired 与 reported 的差异,不作为独立事实源。
  • 遥测历史只追加,不随影子覆盖;影子更新必须校验版本,防止乱序消息回退状态。

7. 授权规则

接口类型身份附加约束
管理员 APIadmin/operator产品创建和审计仅 admin
小程序设备 APIuserdevice.ownerId === user.id
设备遥测 APIdeviceX-Device-Id + X-Device-Secret,生产迁移 mTLS/MQTT 凭证
企业 APItenant member/service accounttenant_id 强制过滤 + scope

前端菜单隐藏只改善体验,后端仍对每个接口授权。审计记录操作主体、动作、对象、结果、来源 IP/客户端和关联 ID;MVP 已实现主体、动作、对象、详情和时间。

8. JSON MVP 到生产数据库的映射

MVP 集合生产表/存储
usersiam_user, tenant_member, role_binding
productsiot_product, thing_model_version
devicesiot_device, device_credential, device_binding, device_shadow
telemetryTimescaleDB hypertable telemetry_point
commandsdevice_command, command_attempt, outbox_event
alertsalert, alert_transition
audits只追加 audit_log 或日志存储

迁移时先冻结 JSON 写入,导入主数据,校验数量/唯一键/绑定关系,再切换连接配置;不直接把 JSON 文件作为数据库备份格式长期维护。

第五部分:详细设计文档

本章节在概要设计基础上,对软件各模块、硬件各部件、数据库、接口等进行细化设计,明确具体实现逻辑、数据结构、流程细节、硬件电路与固件设计,为编码与硬件集成提供直接依据。

5.1 软件详细设计(独立模块)

5.1.1 设备接入模块详细设计

5.1.1.1 模块类设计(以Java/Spring为例)

类名职责关键方法
DeviceAuthService设备身份验证authenticate(deviceId, secret)
MqttGatewayMQTT协议处理handleMqttMessage(topic, payload)
TcpServerHandlerTCP长连接处理channelRead(ChannelHandlerContext, Object)
HttpDeviceControllerHTTP设备接入接口uploadData(@RequestBody DeviceData)
DeviceStatusManager设备在线状态管理updateStatus(deviceId, status), heartbeat(deviceId)

5.1.1.2 设备接入流程

  1. MQTT接入:设备连接Broker(EMQX/Mosquitto)→ 携带用户名/密码 → Broker回调MqttGateway → 验证设备身份 → 订阅系统主题 → 记录接入日志 → 更新在线状态。
  2. TCP接入:设备建立Socket连接 → 发送认证JSON({deviceId, secret})→ 服务端解析验证 → 维持长连接 → 心跳保活(每30秒)。
  3. HTTP接入:设备POST /api/device/upload → 携带X-Device-Id与X-Token → 验证通过后返回200 → 数据进入采集队列。

5.1.1.3 状态管理机制

  • 使用Redis存储设备在线状态,key:device:status:{deviceId},value:online/offline,TTL:90秒(心跳超时)。
  • 监听MQTT的$SYS/brokers/+/clients/+/disconnect事件主动感知离线。
  • TCP连接断开时自动更新状态。

5.1.2 数据采集与存储模块详细设计

5.1.2.1 数据采集流程

  1. 设备上报数据 → 模块接收(同步或异步消息队列Kafka/RabbitMQ)→ 格式校验 → 解析为标准JSON结构:

    json

    {
      "deviceId": "xxx",
      "timestamp": 1700000000000,
      "data": {"temperature": 25.6, "humidity": 60}
    }
    
  2. 异常检测:根据预设阈值(如温度>50℃)触发告警,写入告警表。

  3. 存储策略:高频采集数据(秒级)写入时序数据库(如InfluxDB/TimescaleDB);设备配置、元数据写入关系库(MySQL/PG)。

5.1.2.2 数据清理与备份

  • 原始数据保存30天,自动转存到冷存储(对象存储)或删除。
  • 每日凌晨2点执行数据备份(全量+增量),备份保留7天。

5.1.3 远程控制模块详细设计

5.1.3.1 控制指令下发流程

  1. 用户在Web端点击“关闭开关” → 前端调用POST /api/control/send
  2. 后端生成指令ID(UUID) → 记录到control_log表,状态为pending
  3. 根据设备协议类型:
    • MQTT:发布到设备专属topic device/{deviceId}/control
    • TCP:通过对应Channel写入指令JSON
    • HTTP:调用设备提供的回调URL
  4. 设备执行后回复ACK → 模块更新指令状态为succeeded/failed
  5. 若10秒内未收到ACK,触发重试(最多3次),仍失败则状态置为failed并告警。

5.1.3.2 批量控制设计

  • 支持选择多个设备 → 后台并发调用单设备控制逻辑,使用线程池(最大10线程)。
  • 记录批量任务ID,可查询每个设备的执行结果。

5.1.4 用户管理与权限模块详细设计

5.1.4.1 权限模型(RBAC)

  • 表结构:user、role、permission、user_role、role_permission
  • 预置角色:
    • 管理员:所有权限
    • 普通用户:仅查看自己设备的实时数据及历史数据
    • 运维人员:设备调试、固件升级、故障日志查看

5.1.4.2 认证与授权

  • JWT令牌,有效期24小时,刷新令牌7天。
  • 接口权限使用Spring Security注解@PreAuthorize("hasPermission(...)")。
  • 设备级权限:用户与设备通过user_device表关联,查询时自动过滤。

5.1.5 接口详细设计(RESTful API示例)

5.1.5.1 设备注册接口

text

POST /api/device/register
Request Body: { "deviceName": "sensor_01", "protocol": "MQTT", "productKey": "xxx" }
Response: { "deviceId": "d_xxx", "secret": "xxxx" }

5.1.5.2 数据查询接口

text

GET /api/data/latest?deviceId=xxx
Response: { "deviceId": "xxx", "data": {...}, "timestamp": 1700000000 }

5.1.5.3 控制指令接口

text

POST /api/control/send
Request: { "deviceId": "xxx", "command": "turn_off", "params": {} }
Response: { "commandId": "cmd_xxx", "status": "pending" }

5.2 硬件详细设计(独立模块)

5.2.1 感知设备详细设计(以温湿度传感器为例)

5.2.1.1 硬件选型(示例)

组件型号/规格说明
传感器芯片SHT30精度:±0.3℃ / ±2%RH
主控MCUESP32-C3支持Wi-Fi/BLE,低功耗
通信模块内置Wi-Fi支持MQTT/TCP
电源3.7V锂电池 + 充电管理TP4056续航约6个月(每小时上报一次)

5.2.1.2 电路连接

  • SHT30的SCL→ESP32的IO22,SDA→IO21,VCC→3.3V,GND→GND
  • 电池正极→TP4056的BAT+,TP4056的OUT+→ESP32的VIN
  • 预留UART0作为调试口

5.2.1.3 固件设计

  • 采用Arduino/ESP-IDF开发
  • 主循环:读取传感器(每10秒一次)→ 平均值计算(每分钟)→ 通过MQTT上报 → 进入深度睡眠(剩余时间)
  • 上报频率可远程配置(默认60秒)
  • 支持OTA升级

5.2.2 控制终端详细设计

5.2.2.1 硬件组成

  • 主控:STM32F103C8T6
  • 继电器模块(控制220V设备)
  • 通信接口:SPI接ESP8266(透传MQTT)
  • 本地存储:AT24C02(保存设备配置)

5.2.2.2 控制逻辑

  • 监听通信模块转发的控制指令 → 解析指令类型(开关、PWM调光等)→ 驱动GPIO/继电器 → 读取传感器反馈(可选)→ 返回执行结果。

5.2.3 硬件协同时序

text

[传感器] --> UART --> [控制终端] --> SPI --> [通信模组] --> MQTT --> [平台]
[平台]   --> MQTT --> [通信模组] --> SPI --> [控制终端] --> GPIO --> [执行器]

5.3 数据库详细设计

5.3.1 关系型数据库表设计(MySQL)

5.3.1.1 设备表 device

字段类型说明
device_idVARCHAR(32) PK设备唯一标识
device_nameVARCHAR(64)设备名称
protocolENUM('MQTT','TCP','HTTP')接入协议
product_keyVARCHAR(32)产品型号
secretVARCHAR(64)设备密钥(加密存储)
statusTINYINT0-离线,1-在线
last_active_timeDATETIME最后心跳时间
created_timeDATETIME注册时间

5.3.1.2 用户表 user

字段类型说明
user_idINT AUTO PK
usernameVARCHAR(32) UNIQUE
passwordVARCHAR(128)bcrypt加密
role_idINT关联角色表
......

5.3.1.3 指令日志表 control_log

字段类型说明
command_idVARCHAR(36) PK
device_idVARCHAR(32)
commandTEXT指令内容
statusVARCHAR(16)pending/succeeded/failed
retry_countINT重试次数
create_timeDATETIME
finish_timeDATETIME

5.3.2 时序数据库设计(InfluxDB)

  • 测量名:device_data
  • Tag:device_id,sensor_type
  • Field:value(数值),unit(单位)
  • Timestamp:毫秒级时间戳

示例查询:SELECT mean(value) FROM device_data WHERE device_id='xxx' AND time > now()-1d

第六部分:测试文档

6.1 测试计划

6.1.1 测试范围与策略

  • 单元测试:软件各模块方法级测试,覆盖率≥80%
  • 集成测试:模块间接口、软硬件协同通信
  • 系统测试:端到端功能、性能、安全性、兼容性
  • 硬件测试:传感器精度、通信距离、功耗、环境适应性

6.1.2 测试环境

  • 软件:测试服务器(4C8G)、MySQL、InfluxDB、EMQX、Chrome浏览器
  • 硬件:温湿度传感器5,控制终端3,通信模组*3,可调温湿箱,直流电源

6.1.3 测试里程碑

阶段时间输出物
单元测试第9-10周单元测试报告
集成测试第11周集成测试报告
系统测试第12周系统测试报告、缺陷清单
硬件测试并行硬件测试报告
验收测试第13周验收测试报告

6.2 测试用例

6.2.1 功能测试用例(部分)

用例ID模块测试项前置条件输入/操作预期结果优先级
TC-SW-001设备接入MQTT设备正常接入平台已部署,MQTT Broker运行设备使用正确ID/Secret连接连接成功,设备状态变更为“在线”P0
TC-SW-002设备接入错误密钥拒绝同上使用错误Secret连接连接拒绝,日志记录失败P1
TC-SW-003数据采集接收并存储温湿度设备已在线设备上报温湿度数据数据在InfluxDB可查,前端显示正确P0
TC-SW-004数据采集异常数据告警设备上报温度>80℃同上系统产生告警记录,前端提示P1
TC-SW-005远程控制下发开关指令控制终端在线点击“关闭”按钮设备执行关闭,指令状态成功P0
TC-SW-006远程控制离线设备控制设备离线下发指令提示设备离线,指令状态失败P1
TC-SW-007权限管理普通用户访问其他设备用户A只绑定设备1用户A尝试查看设备2数据返回403或无权限提示P0
TC-SW-008数据分析历史数据曲线已有24小时数据选择设备、时间范围展示正确的折线图P1

6.2.2 性能测试用例

用例ID测试项负载条件指标预期结果
TC-PERF-001并发设备接入1000个MQTT设备同时连接连接成功率≥99.5%成功率达标,CPU≤70%
TC-PERF-002数据上报吞吐500设备同时每秒上报1条消息处理延迟≤500ms无积压,延迟达标
TC-PERF-003控制指令并发100个并发指令响应时间≤1s95%指令在1s内返回

6.2.3 硬件测试用例

用例ID测试项方法判定标准
TC-HW-001温度精度与标准温度计对比(0℃,25℃,50℃)误差≤±0.5℃
TC-HW-002湿度精度与标准湿度计对比(30%,60%,90%RH)误差≤±5%RH
TC-HW-003功耗测试电池供电,每小时上报一次续航≥6个月(实测计算)
TC-HW-004通信距离开阔场地测试Wi-Fi连接距离≥50米稳定连接
TC-HW-005高低温工作-20℃~60℃恒温箱运行2小时设备不宕机,数据正常

6.2.4 安全测试用例

用例ID测试项操作预期结果
TC-SEC-001未授权访问不带Token访问API返回401
TC-SEC-002SQL注入在设备ID参数中输入 ' OR '1'='1查询失败或转义,不泄露数据
TC-SEC-003通信加密抓包MQTT数据应看到TLS加密,不能明文看到密码

6.3 测试报告(模板)

6.3.1 测试概要

  • 测试版本:v1.0
  • 测试周期:202X年X月X日 - 202X年X月X日
  • 总用例数:120,通过:115,失败:5,阻塞:0
  • 缺陷总数:8(严重2,一般4,轻微2)

6.3.2 缺陷分析

缺陷ID模块描述严重程度状态
BUG-01设备接入TCP连接偶尔掉线不重连严重已修复
BUG-02远程控制批量控制时部分设备未收到指令一般已修复
...............

6.3.3 测试结论

  • 核心功能满足需求,性能指标达标,硬件精度符合标准。
  • 建议修复剩余轻微问题后上线。

6.4 当前 MVP 自动验证补充(2026-09-12)

6.3 的数量和日期属于原模板示例,不代表本次实测结果。本次结果只以 iot-platform-mvp/tests/verify.js 的实际输出及 交付记录/VERIFICATION.txt 为准。

6.4.1 自动场景

场景断言
健康检查服务为 up、结构版本正确
管理认证错误密码 401;正确密码取得令牌和管理员角色
角色授权运维读取审计日志返回 403
产品/设备产品列表可读;注册设备持久化且序列号唯一
设备遥测错误设备密钥 401;合法上报 202 并更新状态
小程序登录取得 user 令牌
绑定有效绑定码绑定成功;列表仅返回本人设备
越权用户不能读取未归属设备
控制本人在线设备控制成功并更新当前属性
三端完整性管理后台、后端、小程序关键文件存在且 JS/JSON 可解析

6.4.2 执行

cd .\物联网项目文档\iot-platform-mvp
npm test

测试启动随机本地端口并使用独立临时 JSON 文件,结束时关闭服务并清理临时数据,不影响演示库。任何断言失败时进程返回非零状态。

6.4.3 仍需在生产一期执行

真实微信登录、EMQX TLS/ACL、MQTT 断线重连、指令 ACK/超时/重试、乱序和重复消息、数据库故障、备份恢复、1,000 设备并发、弱网真机、固件/硬件精度、电气安全和租户隔离测试。

第七部分:部署与运维文档

7.1 部署方案

7.1.1 软件部署架构

  • 负载均衡:Nginx(HTTPS卸载)
  • 后端服务:Spring Boot(jar包),systemd管理,3节点集群
  • 前端:Vue打包静态文件,Nginx托管
  • 数据库:MySQL主从+读写分离,InfluxDB集群
  • 消息中间件:Kafka(数据采集缓冲)
  • MQTT Broker:EMQX集群(3节点)

7.1.2 部署步骤(摘要)

  1. 安装Docker及docker-compose(或K8s)
  2. 拉取镜像:MySQL, InfluxDB, EMQX, Redis, Kafka, 后端服务镜像
  3. 配置环境变量:数据库连接、JWT密钥、MQTT地址
  4. 执行数据库初始化脚本(schema.sql,seed.sql)
  5. 启动所有容器,验证健康检查
  6. 配置Nginx反向代理与SSL证书(Let's Encrypt)
  7. 硬件设备配置:烧录固件,配置平台域名

7.1.3 硬件部署指导

  • 温湿度传感器:室内壁挂,离地1.5米,避免阳光直射
  • 控制终端:靠近被控设备,确保Wi-Fi信号强度≥-70dBm
  • 通信模块天线竖直向上,远离金属遮挡

7.2 运维手册

7.2.1 日常巡检项

  • 每日:检查服务进程、磁盘使用率、数据库连接数
  • 每周:查看错误日志,清理过期数据
  • 每月:安全补丁更新,性能容量评估

7.2.2 常见问题处理

问题现象可能原因处理步骤
设备无法接入Broker挂掉docker ps 检查EMQX,重启容器
数据显示延迟Kafka积压增加消费者实例或扩容
控制指令超时设备网络差检查设备RSSI,建议移近路由器

7.2.3 备份与恢复

  • 数据库每日全量备份脚本(mysqldump + influx backup)
  • 备份保留到OSS,保留30天
  • 恢复:停止服务 → 恢复备份 → 重启验证

7.3 当前 MVP 部署(2026-09-12 补充)

7.1 描述生产目标架构;本节描述仓库中已经实现的 Node.js MVP,两者不可混作已上线能力。

7.3.1 环境要求与启动

  • Windows/Linux/macOS,Node.js 20 或更高版本。
  • 运行时无第三方 npm 依赖,不需要执行 npm install。
cd E:\_1\notes\项目规划\物联网项目文档\iot-platform-mvp
npm test
$env:TOKEN_SECRET='请替换为至少32字节随机值'
$env:DATA_FILE='E:\iot-data\db.json'
$env:PORT=3000
npm start

验证项:

Invoke-RestMethod http://127.0.0.1:3000/api/health
Start-Process http://127.0.0.1:3000/admin/

健康响应中 data.status 必须为 up;后台静态资源和 API 由同一进程提供,不需要额外处理 CORS。

7.3.2 数据文件

默认数据路径为 iot-platform-mvp/backend/data/db.json。服务首次启动会生成演示数据;写入时先创建临时文件再同卷重命名,避免进程中断留下半个 JSON。JSON 存储只能运行一个写进程,不支持共享磁盘多实例。

备份:停止写入或停止服务,复制数据文件并计算 SHA-256。恢复:停止服务,校验备份 JSON 可解析和哈希正确,用备份替换数据文件,启动后执行 npm test(独立临时库)和健康/登录/关键数据抽查。

7.3.3 进程管理示例

Windows 可使用 NSSM/任务计划程序,Linux 可使用 systemd。进程账户只需要读取代码和读写 DATA_FILE 所在目录,不应使用管理员/root 身份。服务异常退出自动重启,但连续失败应退避并告警,避免覆盖诊断日志。

反向代理应完成 HTTPS、请求体上限、访问日志和超时。/api/device/telemetry 请求体当前上限 1MB;生产应按业务进一步收紧。公网部署必须禁用默认密钥、修改演示密码并限制管理后台来源。

7.4 生产化运行手册

7.4.1 关键 SLI

链路指标初始告警建议
HTTP API请求量、P50/P95/P99、5xx、鉴权失败5 分钟 5xx > 2%
MQTT连接数、连接失败、上下行消息、丢弃数连接失败率 > 5%
Kafkaconsumer lag、重试/死信lag 持续 10 分钟增长
指令pending 时长、成功/失败/超时率10 分钟超时率 > 3%
数据库连接、慢查询、磁盘、复制延迟磁盘 > 75%,复制延迟 > 60s
设备在线率、离线时长、固件分布关键设备离线立即告警

7.4.2 故障定位顺序

  1. 用 requestId/commandId/deviceId 确认单个请求和影响范围。
  2. 检查 API、数据库、Broker、Kafka 和消费者健康,不立即重启全部组件。
  3. 控制超时依次核对指令记录、Outbox、发布结果、设备订阅、设备日志和 ACK。
  4. 遥测缺失依次核对连接、ACL、Broker 入站、Kafka lag、校验死信和时序库写入。
  5. 先止损(暂停发布/限流/切只读/回滚),保留日志和时间线,再恢复和复盘。

7.4.3 备份恢复目标

业务关系库目标 RPO≤15 分钟、RTO≤60 分钟;遥测按成本和客户合同独立设定。每季度至少一次恢复演练,必须恢复到隔离环境并验证用户数、设备数、绑定关系、最近指令和随机遥测,而不只验证备份文件存在。

第八部分:用户手册(概要)

8.1 平台操作指南

8.1.1 登录与注册

  • 访问 https://iot.xxx.com,首次使用需注册企业账号
  • 登录后进入仪表盘

8.1.2 设备管理

  • 添加设备:点击“设备管理”→“添加设备”→输入设备ID和密钥(设备外壳标签上)→选择协议→完成
  • 查看设备:列表显示设备状态,点击可查看实时数据与历史曲线

8.1.3 远程控制

  • 在设备详情页,点击“控制”选项卡 → 选择控制命令(开关、调节等)→ 确认发送 → 显示执行结果

8.1.4 数据分析

  • “数据报表”菜单 → 选择设备、时间范围、数据类型 → 生成图表,可导出Excel

8.2 硬件安装指南(以温湿度传感器为例)

  1. 打开包装,取出传感器主体和支架
  2. 使用附赠的Micro-USB线充电2小时(红灯充电,绿灯满电)
  3. 下载配网App或通过微信小程序,长按设备按键5秒进入配网模式
  4. 输入Wi-Fi密码,等待提示“配网成功”
  5. 登录平台查看设备是否在线

8.3 当前 MVP 管理后台操作(2026-09-12 补充)

8.1~8.2 是目标产品概要;本节与当前可运行工程一致。

8.3.1 登录与总览

  1. 启动 iot-platform-mvp 后打开 http://127.0.0.1:3000/admin/。
  2. 管理员使用 admin / admin123,运维使用 operator / operator123。
  3. 总览显示设备总数、在线数、终端用户数、待处理告警、今日指令、消息趋势和最近告警。
  4. 右上角刷新按钮重新请求当前页面;退出按钮清除本地登录令牌。

8.3.2 产品与设备

  • 进入“产品管理”查看 ProductKey、品类、协议、物模型版本和设备数量。
  • 管理员点击“新建产品”,ProductKey 不能重复;运维角色只能查看产品。
  • 进入“设备管理”可搜索名称/编号。点击“注册设备”,填写设备名称、唯一序列号、产品和固件版本。
  • 新设备默认为离线、未绑定;创建结果含绑定码和设备凭据语义,生产系统只应一次显示设备密钥。

8.3.3 指令、告警和审计

  • 在设备行点击“下发指令”,或进入“指令记录”选择目标设备。在线设备由 MVP 模拟立即成功,离线设备显示等待中。
  • “告警中心”显示待处理/已恢复状态;点击“标记已处理”更新告警并记录审计。
  • 只有管理员能打开“审计日志”,运维访问相应 API 会被后端拒绝。

8.4 当前 MVP 小程序操作

  1. 微信开发者工具导入 iot-platform-mvp/mini-program。
  2. 检查 config.js 中的 API 地址。开发工具可使用 http://127.0.0.1:3000,真机改为开发机局域网地址。
  3. 点击“使用本地演示身份”,或走微信一键登录适配入口。
  4. 设备页右上角点击“+”,扫码或输入演示绑定码 IOT-GW-0002。
  5. 绑定成功后返回设备页并下拉刷新;进入设备详情查看当前属性、产品、协议、固件和最后活跃时间。
  6. 在线智能开关可以切换电源;离线设备禁用开关,避免向用户伪报即时成功。
  7. “我的”页可查看用户身份并退出登录。

8.5 常见问题

现象处理
后台提示账号或密码错误检查大小写;演示管理员为 admin/admin123
页面提示登录过期重新登录;检查服务重启时 TOKEN_SECRET 是否改变
小程序请求失败确认后端已启动、config.js 地址可从当前设备访问、合法域名设置符合环境
绑定码无效去除空格并核对标签;演示码为 IOT-GW-0002
设备已被绑定当前所有者先申请解绑/转移;管理员不能直接把同一设备重复分配
设备离线检查供电、网络、设备凭据、Broker/HTTP 接入和最后活跃时间
指令等待中设备离线或尚未 ACK;不要重复快速点击,使用 commandId 查询结果

8.6 数据与账号注意事项

演示数据和默认密码只用于本地验证。生产使用独立账号、强密码/单点登录和 HTTPS;不要在截图、工单或聊天中发送设备密钥。用户只能查看本人设备,发现归属错误应停止控制并发起转移流程,由平台保留审计。

第九部分:项目验收文档

9.1 验收计划

  • 验收时间:测试阶段结束后3个工作日内
  • 参与人员:客户代表、项目负责人、测试经理
  • 验收标准:需求文档中的所有功能均已实现且通过测试;性能指标达标;硬件精度符合规格;文档齐全

9.2 验收清单

编号验收项是否满足(是/否)备注
1软件平台所有功能模块可正常使用
2支持MQTT/TCP/HTTP三种协议设备接入
3设备在线状态实时更新
4数据采集延迟≤500ms
5远程控制响应时间≤1s
6并发1000设备在线,系统稳定
7硬件传感器精度达标
8硬件通信距离≥50米
9提供完整文档(需求、设计、测试、部署、用户手册)
10提供源代码与固件代码

9.3 验收结论

  • 验收通过 □ 不通过 □
  • 遗留问题及处理计划:__________
  • 验收签字:
    • 客户代表:__________
    • 项目负责人:__________
    • 日期:__________

9.4 MVP 验收门槛(2026-09-12 补充)

9.2 的全量软硬件条目用于生产阶段,不代表零依赖 MVP 已满足 1,000 设备、三协议和硬件指标。本次 MVP 采用下列可执行门槛。

编号验收项方法通过标准
MVP-01工程可启动npm start + 健康请求/api/health 返回 200/up
MVP-02自动回归npm test全部检查通过、退出 0
MVP-03后台闭环登录、注册设备、指令、告警操作实际写入 API 数据并刷新页面
MVP-04设备接入正确/错误凭据遥测请求正确 202,错误 401 且不改变数据
MVP-05用户绑定小程序演示登录并绑定绑定后仅当前用户可见
MVP-06用户控制控制本人在线设备指令成功且属性更新;他人设备拒绝
MVP-07角色边界运维请求审计接口后端返回 403
MVP-08文档检查总览、API、部署、用户和路线图路径有效,内容与实现边界一致
MVP-09回滚在隔离副本执行回滚脚本哈希恢复且变更文件消失

生产上线验收须另行执行 12.迭代路线图.md 中 M1~M3 的质量门禁,不能用 MVP 结果替代。

chat1:

物联网项目文档编写,我打算实现一个物联网设备管理平台,这个平台每个用户可以通过mqtt,tcp,http等方式将设备接入到平台。我打算作为一个软硬件一体化提供商,提供物联网服务,如智能家居控制,温湿度等硬件设备数据上报,智慧农业,智能工厂等,为客户提供软件和硬件服务。现在需要把文档从立项到测试所有文档进行实现。同时是否要区分软件和硬件文档,还是整合在一起。软件技术选型和硬件设备选型心里已经有底了,软硬件各种文档是不是要独立出来?还是整合到一个,尽你所能帮我把文档完成,要求从研究背景,可行性分析,需求分析,概要设计,详细设计,测试用例等一整套软件工程,以及硬件设备开发工程文档

物模型

功能说明

物模型是物理空间中的实体(如传感器、车载装置、楼宇、工厂等)在云端的数字化表示,从属性、服务和事件三个维度,分别描述了该实体是什么、能做什么、可以对外提供哪些信息。定义了物模型的这三个维度,即完成了产品功能的定义

功能类型说明
属性(Property)用于描述设备运行时具体信息和状态。例如,环境监测设备所读取的当前环境温度、智能灯开关状态、电风扇风力等级等。属性可分为读写和只读两种类型。读写类型支持读取和设置属性值,只读类型仅支持读取属性值。
服务(Service)指设备可供外部调用的指令或方法。服务调用中可设置输入和输出参数。输入参数是服务执行时的参数,输出参数是服务执行后的结果。相比于属性,服务可通过一条指令实现更复杂的业务逻辑,例如执行某项特定的任务。服务分为异步和同步两种调用方式。
事件(Event)设备运行时,主动上报给云端的信息,一般包含需要被外部感知和处理的信息、告警和故障。事件中可包含多个输出参数。例如,某项任务完成后的通知信息;设备发生故障时的温度、时间信息;设备告警时的运行状态等。事件可以被订阅和推送。

物联网平台支持为产品定义多组功能(属性、服务和事件)。一组功能定义的集合,就是一个物模型模块。多个物模型模块,彼此互不影响。

物模型模块功能,解决了工业场景中复杂的设备建模,便于在同一产品下,开发不同功能的设备。

例如,电暖扇产品的功能属性有电源开关、档位(高、中、低)和室内温度,您可以在一个模块添加前2个属性,在另一个模块添加三个属性,然后分别在不同设备端,针对不同物模型模块功能进行开发。此时,该产品下不同设备就可以实现不同功能。

CREATE TABLE thing_model (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    model_id VARCHAR(64) UNIQUE NOT NULL COMMENT '物模型ID',
    model_name VARCHAR(100) NOT NULL COMMENT '物模型名称',
    model_version VARCHAR(20) NOT NULL DEFAULT '1.0' COMMENT '模型版本',
    product_key VARCHAR(64) COMMENT '产品标识(与产品唯一绑定)',
    product_id VARCHAR(64) COMMENT '产品ID',
    category VARCHAR(50) COMMENT '设备品类',
    protocol_type VARCHAR(30) COMMENT '协议类型(MQTT/CoAP/HTTP等)',
    description TEXT COMMENT '模型描述',
    status TINYINT DEFAULT 1 COMMENT '状态:0-启用,1-禁用',
    is_standard TINYINT DEFAULT 0 COMMENT '是否标准模型',
    extend_data JSON COMMENT '扩展字段',
   `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
   `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '创建者',
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
   `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '更新者',
   `update_time` datetime DEFAULT NULL COMMENT '更新时间'
) COMMENT='物模型';
CREATE TABLE thing_property (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    model_id VARCHAR(64) NOT NULL COMMENT '物模型ID',
    identifier VARCHAR(100) NOT NULL COMMENT '属性标识符',
    name VARCHAR(100) NOT NULL COMMENT '属性名称',
    data_type VARCHAR(30) NOT NULL COMMENT '数据类型:int/float/double/string/bool/enum/date/struct等',
    data_spec JSON COMMENT '数据规范(JSON格式)',
    unit VARCHAR(20) COMMENT '单位',
    access_mode TINYINT NOT NULL COMMENT '访问模式:1-只读,2-只写,3-读写',
    required TINYINT DEFAULT 0 COMMENT '是否必选:0-否,1-是',
    is_primary TINYINT DEFAULT 0 COMMENT '是否为主属性',
    description TEXT COMMENT '属性描述',
    sort_order INT DEFAULT 0 COMMENT '排序',
    extend_config JSON COMMENT '扩展配置',
    `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
   `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '创建者',
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
   `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '更新者',
   `update_time` datetime DEFAULT NULL COMMENT '更新时间'
) COMMENT='属性定义表';

IoT 平台项目规划

1. 项目定位

平台以本司硬件设备的数字化交付和持续服务为起点,而不是单纯的技术练习。通过一个可运营的设备平台沉淀产品、物模型、设备身份、用户关系、控制链路和服务数据,逐步形成面向个人用户和企业客户的物联网能力底座。

2. 成功标准

  • 本司设备能够在出厂后统一注册、鉴权、观测、绑定和控制。
  • 用户可以在小程序内完成从绑定到日常控制的自助闭环。
  • 运维能定位设备、链路、指令和告警问题,关键动作可审计。
  • 新产品主要通过配置物模型接入,而不是复制整套业务代码。
  • 开放客户设备和企业 SaaS 前具备可靠的租户隔离与 API 治理。

3. 分期规划

阶段目标核心交付退出门槛
MVP(当前)验证完整业务闭环管理后台、Node.js API、原生微信小程序、自动验证AC-01~AC-10 关键场景通过
生产一期承载本司设备PostgreSQL/TimescaleDB、Redis、EMQX、真实 ACK、监控告警、备份恢复试点设备稳定运行 30 天,SLO 达标
二期开放客户设备和应用多租户、产品自助、接入规范/SDK、白标应用模板租户隔离测试和开放 API 安全评审通过
三期企业 SaaS 与履约企业后台、设备组、工单/派单、Webhook、OEM/ERP OpenAPI首个企业客户验收并形成可复制方案

4. MVP 工作分解

4.1 管理后台

运营总览、产品列表/创建、设备列表/注册、用户与绑定关系、指令记录/下发、告警处置、管理员审计。后台适配桌面与移动浏览器。

4.2 后端

提供 HMAC 令牌认证、PBKDF2 密码校验、RBAC、产品/设备 API、设备遥测鉴权、用户绑定、设备归属校验、指令状态、告警和审计。MVP 采用原子替换的 JSON 文件持久化以免引入部署依赖。

4.3 用户小程序

提供微信登录适配入口、本地演示身份、设备总览、扫码/绑定码绑定、详情、属性展示、在线设备开关控制和个人中心。

5. 生产技术路线

设备 ──MQTT/TLS──> EMQX ──规则/桥接──> Kafka ──> 设备服务/规则引擎
 │                                      │
 └──HTTP/TCP 适配器─────────────────────┘
                                         ├─ PostgreSQL(业务/租户)
管理后台/小程序 ── HTTPS ──> API 网关 ──┼─ TimescaleDB(遥测)
                                         ├─ Redis(在线态/缓存/幂等)
                                         └─ 对象存储(固件/导出/归档)
  • EMQX 负责 MQTT 会话、鉴权、订阅和连接事件;不承载核心业务规则。
  • Kafka 解耦高频设备消息与业务处理,消费者以 deviceId + messageId 幂等。
  • PostgreSQL 保存租户、用户、产品、设备、绑定和指令元数据;TimescaleDB 保存时序遥测。
  • 控制服务经 Outbox 发布指令,设备 ACK 经消息链路回写状态,避免数据库和 Broker 双写不一致。

6. 项目节奏与责任

每两周一个迭代;产品负责人维护范围和验收条件,硬件负责人维护身份烧录/Topic/ACK 协议,后端负责人维护领域模型和消息可靠性,前端/小程序负责人维护交互与权限反馈,测试负责人维护自动化和真机矩阵,运维负责人维护环境、监控与恢复演练。

所有迭代必须满足:代码检查通过、API 回归通过、变更文档同步、敏感配置不入库、提供升级与回滚说明。

7. 当前交付入口

可运行工程位于 iot-platform-mvp;需求基线、API 契约和演进顺序分别见 交付记录/MODIFIED_FILE.md、11.API接口文档.md 和 12.迭代路线图.md。

功能说明
项目管理
设备接入
设备管理

从开发者到物联网产品负责人的能力成长路线

核心目标

不需要把自己变成所有专业角色,而是培养把物联网产品从问题发现、方案设计、交付运营到商业闭环跑通的全局能力。

AI 会显著放大执行效率,但以下能力仍需要自己掌握:接触真实用户、判断优先级、进行关键取舍、对结果负责。

学习原则:每一项能力必须经过“真实产出 + 真实反馈 + 复盘”来掌握。

需要建立的角色视角

角色视角具体工作典型产出关注结果
用户研究 / 行业专家访谈用户、观察现场流程、识别真实痛点与约束用户画像、场景流程、痛点清单、访谈记录问题是否真实、高频、值得解决
产品经理定义目标用户、价值主张、需求优先级和最小可用版本PRD、用户故事、原型、需求路线图使用率、留存、效率提升
解决方案架构师将场景转化为端—边—云—用方案,权衡可靠性、安全与成本总体架构、设备选型、接口/数据模型、容量方案稳定性、可扩展性、单位成本
项目经理拆目标、定里程碑、管理风险、协调资源、定义验收WBS、计划、风险台账、验收标准交付准时率、范围与质量
运营 / 客户成功推动客户使用,处理培训、告警、反馈和持续改进上线手册、运营看板、FAQ、复盘报告活跃、故障闭环时效、续用率
商务 / 财务明确购买者、定价、获客成本、交付成本与回款商业模式、报价单、ROI 测算、合同范围毛利、回款、获客成本
安全与合规负责人管理设备身份、权限、数据、升级、隐私和审计威胁模型、权限矩阵、数据分级、应急预案安全事件、合规风险、可追溯性
负责人 / CEO做取舍、定战略、建立机制、对整体结果负责战略一页纸、OKR、组织/协作机制长期方向与整体结果

对个人物联网平台而言,优先深挖用户、产品、解决方案、项目交付、运营五项能力;对商务、合规和负责人角色,先建立基本判断力。

练兵项目的选择

不要一开始做“通用物联网平台”。应选择一个能够接触真实用户的 B 端细分场景,完整跑通一个闭环,例如:

  • 冷链温湿度监控与告警闭环
  • 工厂设备状态监控与故障报修
  • 园区能耗采集与异常分析
  • 小型设备厂商的设备远程运维与 OTA

最终目标不是完成一套大而全的系统,而是做出一个有人愿意试用的最小产品闭环。

12 周实战学习路线

第 1–2 周:用户思维

目标:从“我能做什么”转向“用户在哪个时刻损失最大”。

行动:

  1. 找到 5 位潜在用户或行业人士,每次访谈 30–45 分钟。
  2. 只了解其真实工作与最近经历,不推销平台和功能。
  3. 画出一次真实异常处理流程:谁发现、谁通知、谁处理、如何记录、谁负责。

访谈问题:

  • 最近一次设备或环境异常是什么时候?从发现到处理发生了什么?
  • 当时谁最着急,具体损失是什么?
  • 目前如何发现异常,使用什么工具,最麻烦的环节是什么?
  • 不处理时最坏的结果是什么?
  • 已经为解决问题付出了哪些时间、金钱或人力?
  • 为什么现有方式没有把问题解决好?

产出:5 份访谈纪要、1 张场景流程图、1 份按频率/损失/难度排序的问题清单,以及 1 句话的问题定义。

问题定义模板:

对于【某类用户】,在【特定场景】中,因为【现有方式的具体缺陷】,导致【可量化损失】;我们先帮助他实现【最小结果】。

掌握标准:能够说清用户的工作流、损失和现有替代方案,而不只是罗列功能。

第 3–4 周:产品思维

目标:把问题转化为有边界、可验证的产品方案。

先只做一条 MVP 闭环:

设备上报数据
  → 平台判定异常
  → 通知指定责任人
  → 责任人确认或处理
  → 系统记录结果
  → 管理者查看闭环情况

暂不做:通用规则引擎、复杂多租户组织、大屏、几十种协议支持、全场景设备管理和复杂 AI 分析。

轻量 PRD 要写清:谁在什么情况下使用、希望完成什么、成功后的系统状态、异常处理方式、以及如何衡量价值。

优先级公式:

优先级 = 用户损失 × 发生频率 × 愿意改变现状的意愿 ÷ 实现成本

产出:一页产品定义、10–15 条用户故事、页面或流程原型、MVP 范围和不做清单、3 个关键指标。

掌握标准:面对十个功能请求,能明确解释为什么只做其中两个。

第 5–6 周:解决方案与架构思维

目标:让产品在真实现场可部署、可维护、可靠且成本可控。

架构层次:

传感器/设备
  ↓
网关或设备直连
  ↓
消息接入与设备身份认证
  ↓
规则判断、时序数据、告警和业务数据
  ↓
Web/移动端、通知渠道、运营后台

需要回答的问题:

主题关键问题
设备接入设备身份是什么?断网数据如何处理?
网络协议为何这样选?网络差时如何重连?
数据遥测、告警、工单分别存什么,保留多久?
告警如何防止告警轰炸,如何去重、升级和恢复?
安全设备如何认证?客户间数据如何隔离?
运维如何定位离线设备、升级固件、追溯操作?
成本每增加 1,000 台设备,消息、存储、告警成本增加多少?

产出:总体架构图、设备生命周期图、数据模型、API/消息 Topic 约定、可靠性与安全清单、月成本粗算表。

掌握标准:不只会让设备连上,也能说明断网、重复上报、设备伪造、告警无人处理等情况的应对方案。

第 7–8 周:项目与交付管理

目标:按范围、质量和时间把 MVP 落地。

建议里程碑:

  1. 模拟设备可稳定上报数据。
  2. 平台完成设备注册、在线状态和数据展示。
  3. 异常规则和通知打通。
  4. 处理闭环和报表打通。
  5. 真实用户试用与问题修复。

每个里程碑都必须有可验证验收标准。例如:模拟设备连续上报 72 小时;断网重连后数据不丢失或能明确标记缺失;异常发生后 1 分钟内产生告警并通知责任人。

维护风险表,至少记录风险、预警信号和应对方式。常见风险包括用户不愿安装、网络环境差、告警太多导致关闭通知,以及需求范围持续膨胀。

掌握标准:能在延迟和失败发生前一两周识别风险,而不是到最后才发现。

第 9–10 周:运营与客户成功

目标:让客户真正用起来并获得结果。

为试用用户准备:10 分钟快速上手指南、设备安装/接入检查表、常见告警处理流程、问题反馈入口和每周运营报告。

每周复盘:本周异常、无效告警、最耗时的动作、继续使用的意愿和原因,以及只能保留一个能力时用户会保留什么。

产出:用户上线手册、运营看板、FAQ、反馈清单、每周复盘报告。

掌握标准:关注用户是否达成结果,而不只是功能是否上线。

第 11–12 周:商业与负责人思维

目标:判断产品是否值得持续投入。

明确:目标客户、购买决策者、日常用户、付费原因、实施费、设备或订阅价格、交付支持成本。

ROI 模型:

年收益 = 减少损失 + 节约人工 + 降低停机时间
年成本 = 硬件 + 安装 + 平台订阅 + 运维
ROI = (年收益 - 年成本) / 年成本

完成负责人复盘:回顾初始假设、已证实与被推翻的内容、最大瓶颈、下一个版本只做的三件事,以及必须停止做的事。

掌握标准:敢于停止低价值工作,将有限时间投入最影响结果的事情。

每周固定训练节奏

  • 2 小时:接触用户或研究行业现场。
  • 3 小时:整理需求、指标和产品决策。
  • 6–10 小时:实现当前 MVP。
  • 1 小时:项目、风险和成本复盘。
  • 30 分钟:记录本周的关键决策。

决策记录模板:

决策:本版本只支持 MQTT 设备接入。

背景:试用客户现有设备可通过网关转换为 MQTT。
备选:同时支持 HTTP、Modbus 直连。
取舍:优先验证告警闭环价值,而非协议覆盖率。
风险:后续设备接入需要适配。
验证时间:试用结束后复盘。

AI 的使用方式

将 AI 作为陪练和执行助手:

  • 扮演行业用户,训练访谈和需求澄清。
  • 审查 PRD,找出模糊需求、遗漏边界和伪需求。
  • 模拟断网、重复消息、设备伪造、告警风暴等架构问题。
  • 协助将任务拆为里程碑、风险与验收条件。
  • 从试用日志中总结异常模式和用户反馈。
  • 生成文档初稿、测试清单、部署手册和复盘模板。

AI 不能替代一手用户认知、优先级判断、价值判断和最终责任。

现在的第一步

选定一个自己能接触到真实人的细分场景。本周只完成三件事:进行 3 次用户访谈、整理访谈纪要、画出一张异常处理流程图。在此之前不写平台代码。

时序数据库(TSDB)

物联网设备上报的数据具有写多读少、高并发、数据带有时间戳、极少更新的特点。时序数据库正是为此设计的。

TimescaleDB ,InfluxDB等

利用 Redis 做缓冲

  • 架构模式:
    1. 设备上报 -> MQTT Broker (如EMQX) -> Redis 队列/消息队列。
    2. 消费组 -> 批量写入 -> MySQL (分库分表)。

物联网物模型与设备影子

1. 两个概念的职责

**物模型(Thing Model)**描述某一产品“能做什么”,是设备能力的版本化契约;**设备影子(Device Shadow)**描述某一具体设备“现在/期望是什么状态”,是云端的当前态缓存。物模型属于产品,影子属于设备。

类型含义示例
属性 Property可读取或设置的持续状态power:boolean、temperature:float
服务 Service一次可调用的动作reboot()、setSchedule()
事件 Event设备主动产生的离散事实overheat、tamper

2. 智能开关物模型示例

{
  "productKey": "SWITCH-1CH",
  "version": "1.0.0",
  "properties": [
    { "key": "power", "name": "电源", "type": "boolean", "access": "readWrite", "required": true },
    { "key": "voltage", "name": "电压", "type": "float", "unit": "V", "min": 0, "max": 260, "access": "readOnly" },
    { "key": "current", "name": "电流", "type": "float", "unit": "A", "min": 0, "max": 20, "access": "readOnly" }
  ],
  "services": [
    { "key": "reboot", "name": "重启", "input": [], "output": [{ "key": "accepted", "type": "boolean" }] }
  ],
  "events": [
    { "key": "overload", "name": "过载", "level": "warning", "output": [{ "key": "current", "type": "float", "unit": "A" }] }
  ]
}

3. 设备影子示例

{
  "deviceId": "d-switch-001",
  "desired": { "power": false },
  "reported": { "power": true, "voltage": 220.3, "current": 0.18 },
  "delta": { "power": false },
  "desiredVersion": 8,
  "reportedVersion": 7,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}

当用户发出“关闭”时,云端先把 desired.power 写为 false 并递增版本。设备收到指令并执行后上报 reported.power=false;两侧一致时 delta 清空。离线设备恢复连接后可以拉取较新的 desired 版本,但必须结合指令有效期和业务规则决定是否补执行。

4. 版本与兼容

  • 物模型采用语义化版本。增加可选字段为次版本,删除字段或改变数据类型为主版本。
  • 已发布版本不可原地修改;设备注册时记录兼容的模型版本。
  • 服务端先按消息声明版本校验,再转换成内部规范结构。
  • 前端根据元数据渲染只是增强能力,关键控制仍需产品化交互和权限确认。

5. 与遥测、配置、数字孪生的区别

  • 遥测是按时间追加的事实序列,适合趋势和统计;影子是可覆盖的当前快照。
  • 设备配置是期望状态的一种来源,可进入 desired;配置模板本身属于产品/设备组策略。
  • 数字孪生还包含关系、行为仿真和生命周期,范围大于物模型加影子。

6. 当前实现

MVP 以设备 properties 字段保存简化的 reported 快照,遥测保存于 telemetry 集合,指令参数在成功后合并到 properties。生产化按 5-1系统设计-一些细节.md 拆分 desired/reported/delta 和版本,并接入时序数据库。

物模型(thing_model)
    │
    ├── 产品(product) ←─────── 设备(device)
    │        │                      │
    │        ├── 产品模型关联         ├── 设备模型关联
    │        │                      │
    │        └── 分组(group) ←───────┼── 设备分组关系
    │                                │
    ├── 属性(property)               ├── 属性值快照
    ├── 服务(service)                ├── 动态属性/服务
    └── 事件(event)                  └── 设备影子

本司物联网设备管理平台文档中心

文档版本:1.0 · 更新日期:2026-09-12 · 当前交付:可运行 MVP

快速入口

文档体系

阶段文档产出
立项1研究背景.md、2技术分析.md背景、价值、可行性
需求3需求分析.md、需求.md功能、非功能和软硬件需求
架构4概要设计.md系统边界、模块和部署架构
设计5系统设计.md、5-1系统设计-一些细节.md数据、接口、关键流程和状态机
验证6.测试文档.md、9验收文档.md测试策略、场景和验收门槛
交付7.部署与运维.md、8.用户手册.md部署、观测、恢复和操作方法
演进12.迭代路线图.md生产化、开放平台和企业 SaaS

实现目录

iot-platform-mvp/
├─ admin-web/       管理后台 SPA
├─ backend/         Node.js API、认证与 JSON 持久化
├─ mini-program/    原生微信小程序
├─ scripts/         静态完整性检查
└─ tests/           确定性端到端验证

执行:

cd .\物联网项目文档\iot-platform-mvp
npm test
npm start

浏览器进入 http://127.0.0.1:3000/admin/。管理员演示账号为 admin / admin123;小程序本地演示绑定码为 IOT-GW-0002。

变更规则

  1. 产品范围调整先更新 MODIFIED_FILE.md,并为验收条件编号。
  2. 当前接口调整必须同步 11.API接口文档.md 和自动验证。
  3. 物模型变更必须升级版本,已有设备保持兼容策略。
  4. 生产架构决策记录在概要/详细设计中,MVP 临时取舍记录在 10.MVP实现说明.md。
  5. 每次可交付版本同时保留测试证据、原始哈希、差异和回滚脚本。

该项目要求针对不同的企业提供接入功能。

项目

每个账号下面都有项目

设备管理

设备接入

设备

Pre stack migration 20260912

Docs

需求分析与文档总览

1. 对《本司设备联网管理》的分析结论

原始文档已经明确了正确的产品主线:自有设备先行 → 用户设备控制 → 开放客户设备 → 企业 SaaS 与 OpenAPI。其价值不是单纯“做一个 MQTT 管理页面”,而是建立设备产品化、交付和持续服务能力。

已明确的内容

  • 管理对象包含 MQTT 透传设备、网关、智能开关等多类本司硬件。
  • 第一阶段优先做设备录入、注册、接入、功能配置、指令下发、用户和设备绑定。
  • 终端用户通过小程序或 App 扫码/输入唯一编号绑定并控制设备。
  • 后续允许客户按平台协议接入自有设备,并提供企业 SaaS、定制 OpenAPI 和现场安装管理。
  • 第二阶段的应用个性化和第三阶段的企业服务可以部分并行。

原文需要补齐的关键点

缺口直接风险本次处理
“录入、注册、接入、绑定”边界不清数据状态混乱、设备容易重复归属定义产品、设备身份、联网鉴权、用户绑定四个不同过程
设备控制只有“下发”没有 ACK 状态机页面显示成功但设备未执行定义 pending/sent/succeeded/failed/timeout 状态机
未定义个人、运维、平台、企业角色越权访问其他用户或企业设备建立 RBAC + 设备归属 + 后续 tenant_id 边界
物模型和设备影子没有落到业务每类硬件都写一套页面和协议以属性、服务、事件统一表达能力,以影子承载期望/上报状态
第二/三阶段范围过大首期同时建设低代码、ERP、SaaS 导致失焦以纵向 MVP 验证首期闭环,再按能力门槛迭代
安全、幂等和密钥生命周期未描述重放、冒用、重复指令和数据泄漏补充设备密钥、Token、幂等键、审计与生产门禁
指标缺少测量口径无法验收增加 AC-01~AC-10 和非功能基线

2. 关键产品决策

  1. 第一阶段只闭环,不铺摊子:优先交付一条可测试路径——创建产品 → 注册设备 → 上报 → 用户绑定 → 查看 → 控制 → 审计。
  2. 物模型是产品级契约:产品定义能力;设备保存身份、归属和运行态,不把展示字段直接硬编码成协议。
  3. 设备影子不是历史库:影子保存当前期望值/上报值及版本;遥测历史进入时序数据存储。
  4. 绑定关系必须原子且可追溯:扫码只是输入方式,真正安全边界是绑定码状态、设备当前归属和用户身份。
  5. HTTP 成功不代表控制成功:生产环境以设备 ACK 更新最终状态,超时应明确显示。
  6. 多租户在开放前建设:任何客户自有设备、白标应用或 OpenAPI 都必须先具有 tenant_id 隔离。

3. 文档导航与权威性

文档用途状态
交付记录/MODIFIED_FILE.md从原文整理的产品/交付需求基线当前范围权威来源
3需求分析.md全量软硬件需求框架已有;与需求基线联合阅读
4概要设计.md目标架构和模块划分已有;MVP 取舍见实现说明
5系统设计.md生产目标的详细设计已有;当前代码映射见 5-1
5-1系统设计-一些细节.md状态机、鉴权、数据一致性和实现映射本次完善
6.测试文档.md测试策略已有;自动验证由 npm test 执行
7.部署与运维.md本地与生产部署、备份和观测本次完善
8.用户手册.md管理后台与小程序操作本次完善
9验收文档.md阶段验收已有;以 AC-01~AC-10 作为 MVP 门槛
10.MVP实现说明.md本次实现范围、运行和技术债本次新增
11.API接口文档.md当前可执行 API 契约本次新增
12.迭代路线图.md从 MVP 到生产一期及 SaaS 的演进本次新增

若旧文档中的示例技术栈、接口路径或性能数字与当前实现冲突:当前代码以 11.API接口文档.md 为准,产品范围以 MODIFIED_FILE.md 为准,生产目标以概要/详细设计为准。

4. 本次实现覆盖矩阵

能力管理后台后端小程序自动验证
管理员/运维登录✓✓—✓
产品查看/创建✓✓—✓
设备查看/注册✓✓—✓
设备遥测与在线状态展示✓展示✓
用户设备绑定查看✓✓✓
设备指令✓✓✓✓
告警查看与处置✓✓—✓
用户与角色边界✓✓✓✓
审计日志✓✓—✓
真实 MQTT/ACK—接口语义预留—后续
企业多租户/OpenAPI———二/三期

5. 评审建议

产品评审先确认首期边界、绑定/解绑规则和离线控制体验;硬件评审确认设备身份烧录、绑定码标签和 ACK 格式;后端评审确认多租户迁移策略和消息幂等;测试评审直接以 AC-01~AC-10 转成环境化用例。完成这四项后再进入真实 EMQX 与数据库开发,可显著减少返工。

  1. 前言

    本文档为物联网设备管理平台(软硬件一体化)项目的全套工程文档,涵盖项目从立项调研到测试验收的全生命周期,包含软件工程与硬件设备开发工程相关内容。结合项目定位(软硬件一体化提供商,提供多场景物联网服务),文档采用“核心内容整合、软硬件模块分离”的结构——整体项目框架、立项、可行性分析等核心内容统一整合,软件、硬件的需求分析、设计、测试等专项内容独立分节,既保证项目整体性,又兼顾软硬件开发的专业性和独立性,解决“整合与分离”的核心需求。软件技术选型、硬件设备选型可根据自身规划,直接填充至对应章节的指定位置。

    项目核心定位:作为软硬件一体化物联网服务提供商,搭建支持MQTT、TCP、HTTP等多协议设备接入的物联网设备管理平台,面向智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,为客户提供全流程软件+硬件一体化服务,实现设备接入、数据采集、远程控制、数据分析等核心功能。

    第一部分:立项文档

    1.1 项目立项报告

    1.1.1 项目名称

    物联网设备管理平台(软硬件一体化)建设项目

    1.1.2 项目发起单位/负责人

    发起单位:__________

    项目负责人:__________

    项目团队:__________(可补充开发、测试、硬件集成等核心成员)

    1.1.3 项目背景与意义

    1.1.3.1 研究背景

    随着物联网技术的快速普及,各行业设备联网规模呈现指数级增长,从工业生产线上的传感器、智能工厂的PLC控制器,到消费领域的智能家居终端,设备类型日益复杂,连接需求愈发多样。当前市场中,多数物联网服务存在“软件与硬件脱节”“设备接入协议单一”“场景适配性差”等痛点,传统设备管理模式依赖人工巡检,故障响应滞后,数据分散存储难以形成有效价值,且不同厂商设备协议差异大,系统集成难度高,常出现“哑设备”现象。同时,智能家居、智慧农业、智能工厂等领域对物联网服务的需求持续升级,客户亟需“一站式”软硬件一体化解决方案,而非单独采购软件平台与硬件设备后自行整合,这为软硬件一体化物联网服务提供商提供了广阔的市场空间。

    在此背景下,我们计划搭建物联网设备管理平台,支持MQTT、TCP、HTTP等多协议设备接入,整合自主研发/选型的硬件设备,为各行业客户提供从设备部署、数据采集到远程控制、数据分析的全流程服务,解决行业痛点,满足市场需求。

    1.1.3.2 项目意义

    1. 商业意义:立足软硬件一体化定位,填补市场“一站式”物联网服务空白,拓展智能家居、智慧农业、智能工厂等多场景客户群体,打造差异化竞争优势,实现商业价值变现;

    2. 技术意义:整合多协议接入技术、软硬件协同技术,形成可复用、可扩展的物联网设备管理体系,提升自身技术积累,为后续场景拓展奠定基础;

    3. 行业意义:助力各行业客户实现设备智能化管理,降低运维成本、提升数据利用价值,推动传统行业数字化转型,契合国家数字经济与物联网产业发展战略。

    1.1.4 项目目标

    1.1.4.1 总体目标

    搭建一套稳定、高效、可扩展的物联网设备管理平台,实现软硬件深度协同,支持多协议设备接入、多场景服务落地,成为专业的物联网软硬件一体化服务提供商,满足客户在设备管理、数据采集、远程控制等方面的核心需求,提升客户满意度与市场占有率。

    1.1.4.2 阶段性目标

    1. 立项调研阶段(1-2周):完成市场调研、技术调研,确定软硬件选型方案,完善可行性分析;

    2. 需求分析与设计阶段(3-4周):完成软件、硬件的需求分析,完成概要设计、详细设计,输出设计文档;

    3. 开发实现阶段(8-10周):完成软件平台开发、硬件设备选型与集成,实现软硬件协同联调;

    4. 测试验收阶段(2-3周):完成软件、硬件及系统集成测试,修复问题,通过验收,输出测试报告;

    5. 上线部署阶段(1-2周):完成平台上线、硬件部署,提供客户培训与技术支持,进入运维阶段。

    1.1.5 项目范围

    1. 软件范围:物联网设备管理平台开发,包括设备接入模块、数据采集与存储模块、远程控制模块、数据分析模块、用户管理模块、权限管理模块等,支持MQTT、TCP、HTTP等多协议接入;

    2. 硬件范围:硬件设备选型、集成与调试,包括温湿度传感器、控制终端、通信模块等,适配多场景部署,与软件平台实现无缝对接;

    3. 服务范围:为客户提供软硬件一体化部署、调试、培训、售后技术支持,覆盖智能家居、智慧农业、智能工厂等核心场景;

    4. 排除范围:不涉及硬件设备的核心芯片自主研发(仅做选型与集成),不涉及第三方平台的二次开发(除非客户特殊需求)。

    1.1.6 项目资源需求

    1. 人力资源:软件开发工程师、硬件工程师、测试工程师、产品经理、项目管理人员、运维工程师;

    2. 硬件资源:测试用硬件设备(传感器、控制终端、通信模块等)、服务器、网络设备;

    3. 软件资源:开发工具、测试工具、数据库、操作系统、协议调试工具等;

    4. 资金资源:研发资金、硬件采购资金、测试资金、培训资金等。

    1.1.7 立项审批意见

    审批人:__________

    审批意见:__________

    审批日期:__________

第二部分:可行性分析报告

2.1 概述

本报告针对物联网设备管理平台(软硬件一体化)项目,从市场、技术、经济、操作、风险五个维度进行可行性分析,判断项目是否具备实施条件,为项目决策提供科学依据。本次分析基于当前市场环境、技术水平及自身资源,结合项目目标与范围,确保分析结果真实、可靠、具有指导性。

2.2 市场可行性分析

  1. 市场需求:随着物联网技术在各行业的渗透,智能家居、智慧农业、智能工厂等领域对设备管理平台的需求持续增长,客户对“软硬件一体化”服务的需求日益迫切,避免了单独采购软硬件的整合成本与技术壁垒,市场空间广阔;

  2. 市场竞争力:当前市场中,多数物联网服务提供商要么只做软件平台,要么只做硬件设备,软硬件一体化提供商较少,项目凭借“多协议接入”“多场景适配”“一站式服务”的优势,可形成差异化竞争,契合市场需求;

  3. 市场前景:物联网产业处于快速发展阶段,政策支持力度大,各行业数字化转型加速,未来对物联网设备管理平台及软硬件一体化服务的需求将持续提升,项目具有良好的市场前景和可持续性。

2.3 技术可行性分析

  1. 技术成熟度:MQTT、TCP、HTTP等设备接入协议已成为物联网领域的主流协议,技术成熟、应用广泛;软件平台开发(如设备管理、数据存储、远程控制)、硬件设备选型与集成技术均已成熟,不存在难以突破的技术壁垒;

  2. 技术储备:项目团队已明确软件技术选型与硬件设备选型方案,具备相关的开发、集成、测试技术能力,可支撑项目顺利实施;

  3. 软硬件协同:采用成熟的软硬件协同技术,通过标准化接口实现软件平台与硬件设备的无缝对接,可确保数据传输稳定、控制指令高效执行,解决软硬件脱节问题;

  4. 可扩展性:软件平台采用模块化设计,硬件设备支持灵活替换与扩展,可根据后续市场需求,快速适配新的场景、新的设备类型,技术扩展性强。

2.4 经济可行性分析

  1. 成本估算:项目成本主要包括硬件采购成本、研发成本、人力成本、测试成本、培训成本、运维成本等,结合项目规模与阶段性目标,成本可控,可通过合理规划优化成本;

  2. 收益预测:项目收益主要来自软硬件一体化服务收费、设备销售、售后运维服务等,随着客户群体的拓展,收益将逐步提升,预计在项目上线后1-2年内实现盈利;

  3. 投资回报:综合成本与收益分析,项目投资回报率合理,风险可控,具备良好的经济可行性,符合企业长期发展战略。

2.5 操作可行性分析

  1. 团队能力:项目团队具备软件开发、硬件集成、测试、项目管理等相关能力,可熟练完成项目各阶段工作,确保项目顺利推进;

  2. 操作难度:软件平台操作界面简洁、易用,客户可快速掌握设备接入、数据查看、远程控制等操作;硬件设备部署简单、调试便捷,可适应不同场景的部署需求;

  3. 运维保障:制定完善的运维方案,配备专业的运维工程师,可及时处理软件故障、硬件故障,保障平台与设备的稳定运行,降低操作与运维难度。

2.6 风险可行性分析

2.6.1 潜在风险

  1. 技术风险:软硬件协同过程中可能出现兼容性问题,设备接入过程中可能出现协议适配问题,影响平台稳定性;

  2. 市场风险:市场需求变化过快,或竞争对手推出同类产品,影响项目市场占有率;

  3. 成本风险:硬件采购价格波动、研发成本超支,导致项目成本增加;

  4. 进度风险:项目各阶段工作推进滞后,影响项目上线时间。

2.6.2 风险应对措施

  1. 技术风险:提前进行技术调研与测试,完善软硬件选型方案,加强软硬件协同联调,建立技术问题应急处理机制,及时解决兼容性、协议适配等问题;

  2. 市场风险:持续关注市场需求变化,加强市场调研,及时优化产品与服务,打造差异化优势,加强客户维护,提升客户粘性;

  3. 成本风险:合理规划采购计划,与供应商签订长期合作协议,控制硬件采购成本;优化研发流程,避免研发成本超支,建立成本监控机制;

  4. 进度风险:制定详细的项目进度计划,明确各阶段工作任务与时间节点,加强项目进度监控,及时调整工作安排,确保项目按时推进。

2.7 可行性结论

综合市场、技术、经济、操作、风险五个维度的分析,本项目市场需求明确、技术成熟、成本可控、风险可应对,具备完全的可行性,建议立项实施。

第三部分:需求分析文档

本章节采用“整体需求+软件需求+硬件需求”的结构,整体需求明确项目核心诉求,软件、硬件需求分别独立阐述,兼顾整合性与独立性,确保需求清晰、可落地、可验证。

3.1 整体需求

  1. 多协议接入需求:支持MQTT、TCP、HTTP等多种设备接入协议,实现不同类型、不同厂商硬件设备的快速接入,解决设备接入标准化问题;

  2. 软硬件协同需求:软件平台与硬件设备无缝对接,实现数据实时采集、远程控制指令高效下发,确保软硬件协同稳定运行;

  3. 多场景适配需求:适配智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,提供针对性的软硬件解决方案;

  4. 核心功能需求:实现设备管理、数据采集与存储、远程控制、数据分析、用户管理、权限管理等核心功能;

  5. 性能需求:平台运行稳定,数据传输延迟低,设备接入容量可扩展,硬件设备运行可靠、功耗合理;

  6. 易用性需求:软件平台操作简洁,硬件设备部署、调试便捷,客户可快速上手,降低使用成本;

  7. 安全性需求:保障设备接入安全、数据传输安全、数据存储安全,防止非法接入、数据泄露、指令篡改。

3.2 软件需求(独立模块)

3.2.1 功能需求

3.2.1.1 设备接入模块

  1. 支持MQTT、TCP、HTTP三种核心协议接入,可扩展其他物联网协议;

  2. 支持设备批量接入与单个接入,提供设备接入指引与配置工具;

  3. 实现设备接入验证(设备ID、密钥验证),防止非法设备接入;

  4. 支持设备在线状态监测,实时显示设备接入状态(在线、离线、异常),异常状态及时提醒;

  5. 支持设备接入日志记录,可查询设备接入时间、接入协议、接入状态等信息。

3.2.1.2 数据采集与存储模块

  1. 实时采集硬件设备上传的数据(如温湿度、设备运行参数、状态数据等),采集频率可配置;

  2. 支持数据格式解析与转换,确保不同设备的数据统一格式存储;

  3. 采用可靠的数据库存储数据,支持历史数据查询、导出,数据存储期限可配置;

  4. 支持数据异常检测,当数据超出预设阈值时,触发异常提醒;

  5. 保障数据传输过程中的完整性,防止数据丢失、篡改。

3.2.1.3 远程控制模块

  1. 支持通过软件平台向硬件设备下发控制指令(如开关控制、参数调节等);

  2. 实时反馈指令执行结果,显示设备执行状态;

  3. 支持控制指令日志记录,可查询指令下发时间、指令内容、执行结果;

  4. 支持批量控制多个设备,提高操作效率;

  5. 当指令执行失败时,提供失败原因提示,并支持重试功能。

3.2.1.4 数据分析模块

  1. 支持对采集的数据进行统计分析(如平均值、最大值、最小值、趋势分析等);

  2. 提供数据可视化展示(图表、报表等),便于客户直观查看数据变化;

  3. 支持自定义分析规则,根据客户需求生成针对性的分析报告;

  4. 针对不同场景(如智慧农业的土壤湿度分析、智能工厂的设备运行效率分析)提供专属分析功能。

3.2.1.5 用户管理与权限管理模块

  1. 支持用户注册、登录、密码重置、账号注销等功能;

  2. 支持用户信息管理(修改个人信息、绑定联系方式等);

  3. 支持权限分级管理,不同角色(管理员、普通用户、运维人员)拥有不同的操作权限;

  4. 管理员可管理所有用户、设备、数据,普通用户仅可查看自身绑定的设备与数据,运维人员可进行设备调试与故障处理。

3.2.1.6 系统管理模块

  1. 支持系统参数配置(如数据采集频率、异常阈值、存储期限等);

  2. 支持系统日志记录,可查询操作日志、设备日志、异常日志等;

  3. 支持系统升级与维护,确保系统稳定运行;

  4. 支持数据备份与恢复,防止数据丢失。

3.2.2 非功能需求

  1. 性能需求:平台响应时间≤1s,数据传输延迟≤500ms;支持至少1000台设备同时在线接入,可扩展至10000台以上;系统可用性≥99.9%;

  2. 安全性需求:采用加密技术(如SSL/TLS)保障数据传输安全;设备接入采用密钥验证,防止非法接入;数据存储加密,防止数据泄露;定期进行安全检测,及时修复安全漏洞;

  3. 可扩展性需求:采用模块化设计,支持功能模块扩展(如新增协议接入、新增分析功能);支持设备类型扩展,可适配新的硬件设备;

  4. 易用性需求:操作界面简洁、直观,导航清晰,客户可快速掌握操作方法;提供操作指引与帮助文档;

  5. 兼容性需求:支持Windows、Linux等主流操作系统,支持Chrome、Edge等主流浏览器;

  6. 可维护性需求:系统日志清晰,便于问题排查;模块之间低耦合,便于维护与升级。

3.2.3 接口需求

  1. 设备接入接口:支持MQTT、TCP、HTTP协议接口,用于设备与平台的数据交互;

  2. 硬件对接接口:提供标准化接口,用于软件平台与硬件设备的协同对接,支持数据采集与指令下发;

  3. 内部接口:各模块之间的接口,确保模块之间的数据交互顺畅;

  4. 外部接口(可选):提供API接口,支持与第三方平台对接,实现数据共享。

3.3 硬件需求(独立模块)

3.3.1 硬件设备类型及需求

3.3.1.1 感知设备

  1. 温湿度传感器:用于采集环境温湿度数据,精度≥±0.5℃(温度)、±5%RH(湿度);支持低功耗运行;支持MQTT/TCP/HTTP协议;适配室内外多种场景;

  2. 其他感知设备(可选):根据场景需求,配置土壤湿度传感器、光照传感器、压力传感器等,要求精度达标、运行稳定、支持对应协议。

3.3.1.2 控制终端

  1. 用于接收软件平台下发的控制指令,执行相应操作(如开关控制、参数调节);

  2. 支持与感知设备、通信模块对接,实现数据采集与指令执行;

  3. 运行稳定,响应迅速,指令执行延迟≤300ms;

  4. 支持低功耗模式,适配不同供电场景(市电、电池)。

3.3.1.3 通信模块

  1. 支持MQTT、TCP、HTTP三种核心协议,可实现设备与软件平台的数据传输;

  2. 通信稳定,信号强度强,支持远距离传输(根据场景需求,可选择Wi-Fi、4G/5G、LoRa等通信方式);

  3. 低功耗、小体积,便于部署;

  4. 支持自动重连功能,当网络中断后,可自动重新连接平台。

3.3.1.4 辅助设备

  1. 电源设备:为感知设备、控制终端、通信模块提供稳定供电,支持市电、太阳能、电池等多种供电方式;

  2. 部署支架:用于设备固定,适配室内外部署场景,防水、防尘、抗干扰。

3.3.2 硬件性能需求

  1. 运行可靠性:硬件设备平均无故障运行时间(MTBF)≥10000小时;

  2. 环境适应性:适应温度范围-20℃~60℃,湿度范围10%~90%RH;防水、防尘、抗电磁干扰;

  3. 功耗需求:感知设备、通信模块采用低功耗设计,电池供电模式下,续航时间≥6个月;

  4. 数据采集精度:各类传感器的数据采集精度符合行业标准,确保数据准确性;

  5. 兼容性:硬件设备之间可无缝对接,与软件平台通过标准化接口对接,支持协议适配。

3.3.3 硬件接口需求

  1. 通信接口:支持UART、SPI、I2C等常用接口,用于与通信模块、感知设备对接;

  2. 供电接口:标准化供电接口,支持不同供电方式接入;

  3. 扩展接口:预留扩展接口,便于后续新增设备或功能扩展。

3.3.4 硬件部署需求

  1. 部署便捷:设备体积小、重量轻,便于安装与部署,无需复杂施工;

  2. 维护便捷:设备支持远程调试、固件升级,减少现场维护工作量;

  3. 场景适配:根据智能家居、智慧农业、智能工厂等不同场景,提供对应的部署方案,确保设备运行稳定。

3.4 需求确认

需求提出人:__________

需求确认人:__________

确认日期:__________

第四部分:概要设计文档

本章节延续“整体概要设计+软件概要设计+硬件概要设计”的结构,整体设计明确项目架构框架,软件、硬件概要设计分别独立阐述,明确各模块的核心设计思路、接口设计、模块间交互关系,为详细设计奠定基础。

4.1 整体概要设计

4.1.1 项目架构设计

项目采用“云-边-端”协同的四层架构,实现软硬件一体化协同运行,具体架构如下:

  1. 感知层:由各类硬件设备组成(感知设备、控制终端、通信模块等),负责数据采集与指令执行,是项目的“终端入口”;

  2. 边缘层:部署在设备侧的边缘计算节点,承担协议转换、数据预处理、本地规则计算等功能,减少网络带宽占用,实现本地故障快速响应;

  3. 平台层:即物联网设备管理软件平台,包含各类核心功能模块,负责设备管理、数据处理、远程控制、数据分析等,是项目的“核心大脑”;

  4. 应用层:面向不同场景的可视化界面与服务,为客户提供设备操作、数据查看、分析报告等服务,适配智能家居、智慧农业、智能工厂等多场景。

4.1.2 软硬件协同架构

软件平台与硬件设备通过标准化接口实现协同,具体交互流程如下:

  1. 硬件设备通过通信模块,采用MQTT/TCP/HTTP协议接入软件平台,完成身份验证;

  2. 感知设备采集数据后,通过通信模块上传至软件平台,软件平台对数据进行解析、存储与分析;

  3. 软件平台下发控制指令,通过通信模块传输至控制终端,控制终端执行指令,并将执行结果反馈至软件平台;

  4. 边缘层负责数据预处理与本地决策,当出现紧急情况时,可直接控制硬件设备,同时将相关信息上报至软件平台。

4.1.3 设计原则

  1. 模块化设计:软件、硬件均采用模块化设计,便于功能扩展、维护与升级;

  2. 标准化设计:接口、协议采用行业标准,确保软硬件兼容性与可扩展性;

  3. 稳定性设计:优先选择成熟技术与设备,确保系统与设备运行稳定,降低故障发生率;

  4. 安全性设计:融入安全设计理念,保障设备接入、数据传输、存储的安全性;

  5. 易用性设计:兼顾软件操作与硬件部署的易用性,降低客户使用与维护成本。

4.2 软件概要设计(独立模块)

4.2.1 软件架构设计

软件平台采用分层架构设计,从上至下分为应用层、业务逻辑层、数据访问层、数据存储层,各层独立运行、相互协作,具体如下:

  1. 应用层:面向用户的操作界面,包括设备管理界面、数据查看界面、远程控制界面、数据分析界面等,负责用户交互;

  2. 业务逻辑层:核心业务处理层,包含设备接入、数据采集、远程控制、数据分析、用户管理等功能模块,负责业务逻辑处理;

  3. 数据访问层:负责与数据存储层交互,实现数据的查询、新增、修改、删除等操作,为业务逻辑层提供数据支持;

  4. 数据存储层:采用数据库存储各类数据(设备数据、用户数据、日志数据等),确保数据安全、可靠。

4.2.2 核心模块设计

4.2.2.1 设备接入模块

  1. 模块功能:负责设备接入验证、协议解析、在线状态监测、接入日志记录;

  2. 核心逻辑:设备发起接入请求→模块验证设备身份(设备ID、密钥)→验证通过后,根据接入协议解析数据→记录接入日志,更新设备在线状态;

  3. 依赖模块:数据访问层(存储设备信息、接入日志)、数据采集模块(接收设备上传的数据)。

4.2.2.2 数据采集与存储模块

  1. 模块功能:接收设备上传的数据、解析数据格式、存储数据、异常数据检测、数据备份与恢复;

  2. 核心逻辑:接收设备数据→解析数据格式(统一为标准格式)→检测数据是否异常→将正常数据存储至数据库,异常数据触发提醒并记录→定期进行数据备份;

  3. 依赖模块:设备接入模块(获取设备数据)、数据访问层(存储数据)、数据分析模块(提供数据支持)。

4.2.2.3 远程控制模块

  1. 模块功能:下发控制指令、接收指令执行结果、记录指令日志、指令重试;

  2. 核心逻辑:用户发起控制指令→模块生成标准化指令→通过通信接口下发至设备→接收设备执行结果→记录指令日志,若执行失败则提醒并支持重试;

  3. 依赖模块:设备接入模块(获取设备在线状态)、数据访问层(存储指令日志)。

4.2.2.4 数据分析模块

  1. 模块功能:数据统计分析、数据可视化、自定义分析规则、生成分析报告;

  2. 核心逻辑:从数据存储层获取数据→根据预设规则或自定义规则进行统计分析→生成可视化图表与分析报告→提供数据查询与导出功能;

  3. 依赖模块:数据采集与存储模块(获取数据)、数据访问层(存储分析结果)。

4.2.2.5 用户管理与权限管理模块

  1. 模块功能:用户注册、登录、信息管理、权限分配、角色管理;

  2. 核心逻辑:用户注册/登录→验证用户信息→根据角色分配权限→用户可修改个人信息,管理员可管理用户与权限;

  3. 依赖模块:数据访问层(存储用户信息、权限信息)。

4.2.2.6 系统管理模块

  1. 模块功能:系统参数配置、日志管理、系统升级、数据备份与恢复;

  2. 核心逻辑:管理员配置系统参数→模块记录各类日志→支持系统在线升级→定期进行数据备份,出现异常时可恢复数据;

  3. 依赖模块:数据访问层(存储系统参数、日志数据、备份数据)。

4.2.3 接口设计

4.2.3.1 设备接入接口

  1. MQTT接口:用于设备与平台的数据交互,采用MQTT 3.1.1协议,端口:1883(TCP)、8883(SSL);

  2. TCP接口:用于设备与平台的双向通信,端口:8080,数据格式:JSON;

  3. HTTP接口:用于设备上传数据与接收指令,请求方式:POST/GET,数据格式:JSON。

4.2.3.2 内部模块接口

  1. 设备接入模块→数据采集模块:提供设备数据接口,传递设备上传的数据;

  2. 远程控制模块→设备接入模块:提供指令下发接口,传递控制指令;

  3. 各模块→数据访问层:提供数据查询、新增、修改、删除接口,实现数据交互。

4.2.3.3 外部接口(可选)

提供RESTful API接口,支持与第三方平台对接,数据格式:JSON,采用API密钥验证,确保接口安全。

4.2.4 数据存储设计

  1. 数据库选型:__________(根据自身技术选型填写,如MySQL、MongoDB等);

  2. 数据分类存储:

(1)设备数据:存储设备ID、设备类型、接入协议、在线状态、部署位置等信息;

(2)采集数据:存储设备上传的温湿度、运行参数等数据,按时间戳排序;

(3)用户数据:存储用户账号、密码(加密存储)、个人信息、角色权限等信息;

(4)日志数据:存储设备接入日志、操作日志、指令日志、异常日志等信息;

(5)系统数据:存储系统参数、配置信息、备份数据等信息。

4.2.5 技术选型补充

  1. 开发语言:__________(如Java、Python、Go等);

  2. 前端框架:__________(如Vue、React等);

  3. 服务器:__________(如阿里云、腾讯云服务器等);

  4. 其他技术:__________(如消息队列、缓存技术等,根据自身选型填写)。

4.3 硬件概要设计(独立模块)

4.3.1 硬件整体架构设计

硬件系统由感知层设备、控制终端、通信模块、辅助设备组成,各设备通过标准化接口对接,形成完整的硬件体系,具体架构如下:

  1. 感知层:由温湿度传感器、其他场景化传感器组成,负责采集环境与设备数据;

  2. 控制层:由控制终端组成,负责接收软件平台指令,控制感知设备与执行器;

  3. 通信层:由通信模块组成,负责实现硬件设备与软件平台的数据传输与指令交互;

  4. 辅助层:由电源设备、部署支架等组成,为硬件系统提供供电与部署支持。

4.3.2 核心硬件设备设计

4.3.2.1 感知设备设计

  1. 温湿度传感器:

(1)核心组件:传感器芯片、数据处理单元、接口单元;

(2)工作原理:传感器芯片采集温湿度数据,经数据处理单元转换为标准格式,通过接口单元传输至控制终端;

(3)选型补充:__________(根据自身硬件选型填写传感器型号、厂商等)。

4.3.2.2 控制终端设计

  1. 核心组件:主控芯片、接口单元、执行单元、电源单元;

  2. 工作原理:主控芯片接收通信模块传输的控制指令,控制执行单元执行相应操作,同时通过接口单元获取感知设备的数据,上传至通信模块;

  3. 选型补充:__________(根据自身硬件选型填写主控芯片型号、厂商等)。

4.3.2.3 通信模块设计

  1. 核心组件:通信芯片、天线、接口单元、电源单元;

  2. 工作原理:通过通信芯片实现与软件平台的协议对接,接收平台指令并传输至控制终端,同时将控制终端上传的数据传输至平台;支持自动重连功能;

  3. 选型补充:__________(根据自身硬件选型填写通信模块型号、厂商、通信方式等)。

4.3.2.4 辅助设备设计

  1. 电源设备:采用市电+电池双供电模式,确保供电稳定;电池采用锂电池,支持充电与低功耗保护;

  2. 部署支架:采用防水、防尘、抗干扰设计,适配室内外部署,便于安装与固定。

4.3.3 硬件接口设计

  1. 感知设备与控制终端接口:采用UART接口,用于数据传输,波特率:9600bps;

  2. 控制终端与通信模块接口:采用SPI接口,用于指令与数据传输;

  3. 电源接口:采用DC 5V接口,支持市电与电池接入;

  4. 扩展接口:预留I2C接口,用于后续新增设备扩展。

4.3.4 硬件协同设计

  1. 数据传输流程:感知设备采集数据→通过UART接口传输至控制终端→控制终端处理数据→通过SPI接口传输至通信模块→通信模块通过MQTT/TCP/HTTP协议上传至软件平台;

  2. 指令执行流程:软件平台下发指令→通信模块接收指令→通过SPI接口传输至控制终端→控制终端解析指令→控制执行单元执行操作→将执行结果反馈至软件平台;

  3. 异常处理:当硬件设备出现故障时,控制终端触发异常提醒,通过通信模块上传至软件平台,同时启动备用机制(如备用电源、本地控制),确保业务连续性。

4.4 概要设计评审

评审人:__________

评审意见:__________

评审日期:__________

第五部分补充:关键系统设计细节

1. 领域边界

领域实体/聚合负责不负责
产品Product、ThingModelVersion产品能力定义和版本设备当前值
设备Device、Credential、DeviceShadow身份、归属、运行态用户会话
接入Connection、Telemetry、DeviceEvent协议适配、校验、标准化业务页面
控制Command、CommandAck指令生命周期、超时、重试直接修改历史遥测
身份User、Role、Tenant、Binding认证、授权、归属MQTT 会话
运维Alert、AuditLog、WorkOrder告警、审计、履约产品物模型定义

MVP 为便于运行将这些领域放在同一 Node.js 进程中,但 API 和数据结构保持可拆分边界。

2. 设备生命周期

planned → registered → activated → online/offline → disabled → retired
              │            │
              └─ unbound ──┴─ bound → transfer_pending → bound
  • registered:平台已经生成设备身份,但设备可能从未联网。
  • activated:设备首次通过身份验证;该动作不能与用户绑定混为一谈。
  • online/offline:瞬时连接状态,应结合 Broker 连接事件和心跳 TTL 判断。
  • bound:设备拥有终端用户归属;后台运维权限不等于所有权。
  • disabled/retired:禁止新连接或指令,但历史数据和审计保留。

当前 MVP 用 status + ownerId + lastSeenAt 表达核心子集,生产库应拆成生命周期状态、连接状态和绑定状态三个字段。

3. 设备身份与绑定码

设备密钥和绑定码用途不同:设备密钥证明“这台设备是谁”,绑定码授权“一次用户归属操作”。生产实现要求:

  1. 设备密钥由安全随机数生成,烧录或产线注入,密文/哈希保存,支持轮换和吊销。
  2. MQTT 鉴权至少校验 tenant/product/device,主题 ACL 限制设备只能访问自身 Topic。
  3. 绑定码不复用设备密钥;可一次性、可过期、可由管理员重置,并对连续失败限流。
  4. 绑定事务以设备记录加锁或条件更新:owner_id IS NULL → owner_id = current_user;受影响行数为零则返回冲突。
  5. 用户侧读取设备时始终追加 owner_id = current_user,对越权对象返回统一的不存在响应。

4. MQTT 主题和消息信封

上行属性: iot/v1/{tenantId}/{productKey}/{deviceId}/property/post
上行事件: iot/v1/{tenantId}/{productKey}/{deviceId}/event/{eventKey}/post
下行指令: iot/v1/{tenantId}/{productKey}/{deviceId}/command/get
指令回执: iot/v1/{tenantId}/{productKey}/{deviceId}/command/reply

统一信封:

{
  "messageId": "01J...",
  "deviceId": "d-123",
  "timestamp": 1789142400000,
  "version": "1.0",
  "data": { "power": true }
}

服务端以 (device_id, message_id) 去重;时间戳只用于事件时间,不用于认证的唯一依据;无效物模型字段进入死信/错误流,不直接污染设备影子。

5. 指令状态机和一致性

pending ──publish──> sent ──ACK(success)──> succeeded
   │                   ├──ACK(error)─────> failed
   │                   └──deadline───────> timeout
   └──cancel─────────────────────────────> canceled

写库和发 MQTT 采用 Transactional Outbox:在同一数据库事务写入 command 与 outbox_event;发布器发送成功后标记 Outbox;重复发布由设备/服务端按 commandId 幂等。只有合法 ACK 可以更新最终状态,且状态转换通过条件更新防止迟到 ACK 覆盖取消/超时状态。

MVP backend/server.js:addCommand 对在线设备立即生成 succeeded、离线设备生成 pending。这是联调替身,不是生产 ACK 实现。

6. 设备影子合并规则

影子至少包含:

{
  "desired": { "power": true },
  "reported": { "power": false },
  "delta": { "power": true },
  "desiredVersion": 12,
  "reportedVersion": 11,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}
  • 用户控制先写 desired 和新版本;设备 ACK/属性上报更新 reported。
  • delta 是 desired 与 reported 的差异,不作为独立事实源。
  • 遥测历史只追加,不随影子覆盖;影子更新必须校验版本,防止乱序消息回退状态。

7. 授权规则

接口类型身份附加约束
管理员 APIadmin/operator产品创建和审计仅 admin
小程序设备 APIuserdevice.ownerId === user.id
设备遥测 APIdeviceX-Device-Id + X-Device-Secret,生产迁移 mTLS/MQTT 凭证
企业 APItenant member/service accounttenant_id 强制过滤 + scope

前端菜单隐藏只改善体验,后端仍对每个接口授权。审计记录操作主体、动作、对象、结果、来源 IP/客户端和关联 ID;MVP 已实现主体、动作、对象、详情和时间。

8. JSON MVP 到生产数据库的映射

MVP 集合生产表/存储
usersiam_user, tenant_member, role_binding
productsiot_product, thing_model_version
devicesiot_device, device_credential, device_binding, device_shadow
telemetryTimescaleDB hypertable telemetry_point
commandsdevice_command, command_attempt, outbox_event
alertsalert, alert_transition
audits只追加 audit_log 或日志存储

迁移时先冻结 JSON 写入,导入主数据,校验数量/唯一键/绑定关系,再切换连接配置;不直接把 JSON 文件作为数据库备份格式长期维护。

第五部分:详细设计文档

本章节在概要设计基础上,对软件各模块、硬件各部件、数据库、接口等进行细化设计,明确具体实现逻辑、数据结构、流程细节、硬件电路与固件设计,为编码与硬件集成提供直接依据。

5.1 软件详细设计(独立模块)

5.1.1 设备接入模块详细设计

5.1.1.1 模块类设计(以Java/Spring为例)

类名职责关键方法
DeviceAuthService设备身份验证authenticate(deviceId, secret)
MqttGatewayMQTT协议处理handleMqttMessage(topic, payload)
TcpServerHandlerTCP长连接处理channelRead(ChannelHandlerContext, Object)
HttpDeviceControllerHTTP设备接入接口uploadData(@RequestBody DeviceData)
DeviceStatusManager设备在线状态管理updateStatus(deviceId, status), heartbeat(deviceId)

5.1.1.2 设备接入流程

  1. MQTT接入:设备连接Broker(EMQX/Mosquitto)→ 携带用户名/密码 → Broker回调MqttGateway → 验证设备身份 → 订阅系统主题 → 记录接入日志 → 更新在线状态。
  2. TCP接入:设备建立Socket连接 → 发送认证JSON({deviceId, secret})→ 服务端解析验证 → 维持长连接 → 心跳保活(每30秒)。
  3. HTTP接入:设备POST /api/device/upload → 携带X-Device-Id与X-Token → 验证通过后返回200 → 数据进入采集队列。

5.1.1.3 状态管理机制

  • 使用Redis存储设备在线状态,key:device:status:{deviceId},value:online/offline,TTL:90秒(心跳超时)。
  • 监听MQTT的$SYS/brokers/+/clients/+/disconnect事件主动感知离线。
  • TCP连接断开时自动更新状态。

5.1.2 数据采集与存储模块详细设计

5.1.2.1 数据采集流程

  1. 设备上报数据 → 模块接收(同步或异步消息队列Kafka/RabbitMQ)→ 格式校验 → 解析为标准JSON结构:

    json

    {
      "deviceId": "xxx",
      "timestamp": 1700000000000,
      "data": {"temperature": 25.6, "humidity": 60}
    }
    
  2. 异常检测:根据预设阈值(如温度>50℃)触发告警,写入告警表。

  3. 存储策略:高频采集数据(秒级)写入时序数据库(如InfluxDB/TimescaleDB);设备配置、元数据写入关系库(MySQL/PG)。

5.1.2.2 数据清理与备份

  • 原始数据保存30天,自动转存到冷存储(对象存储)或删除。
  • 每日凌晨2点执行数据备份(全量+增量),备份保留7天。

5.1.3 远程控制模块详细设计

5.1.3.1 控制指令下发流程

  1. 用户在Web端点击“关闭开关” → 前端调用POST /api/control/send
  2. 后端生成指令ID(UUID) → 记录到control_log表,状态为pending
  3. 根据设备协议类型:
    • MQTT:发布到设备专属topic device/{deviceId}/control
    • TCP:通过对应Channel写入指令JSON
    • HTTP:调用设备提供的回调URL
  4. 设备执行后回复ACK → 模块更新指令状态为succeeded/failed
  5. 若10秒内未收到ACK,触发重试(最多3次),仍失败则状态置为failed并告警。

5.1.3.2 批量控制设计

  • 支持选择多个设备 → 后台并发调用单设备控制逻辑,使用线程池(最大10线程)。
  • 记录批量任务ID,可查询每个设备的执行结果。

5.1.4 用户管理与权限模块详细设计

5.1.4.1 权限模型(RBAC)

  • 表结构:user、role、permission、user_role、role_permission
  • 预置角色:
    • 管理员:所有权限
    • 普通用户:仅查看自己设备的实时数据及历史数据
    • 运维人员:设备调试、固件升级、故障日志查看

5.1.4.2 认证与授权

  • JWT令牌,有效期24小时,刷新令牌7天。
  • 接口权限使用Spring Security注解@PreAuthorize("hasPermission(...)")。
  • 设备级权限:用户与设备通过user_device表关联,查询时自动过滤。

5.1.5 接口详细设计(RESTful API示例)

5.1.5.1 设备注册接口

text

POST /api/device/register
Request Body: { "deviceName": "sensor_01", "protocol": "MQTT", "productKey": "xxx" }
Response: { "deviceId": "d_xxx", "secret": "xxxx" }

5.1.5.2 数据查询接口

text

GET /api/data/latest?deviceId=xxx
Response: { "deviceId": "xxx", "data": {...}, "timestamp": 1700000000 }

5.1.5.3 控制指令接口

text

POST /api/control/send
Request: { "deviceId": "xxx", "command": "turn_off", "params": {} }
Response: { "commandId": "cmd_xxx", "status": "pending" }

5.2 硬件详细设计(独立模块)

5.2.1 感知设备详细设计(以温湿度传感器为例)

5.2.1.1 硬件选型(示例)

组件型号/规格说明
传感器芯片SHT30精度:±0.3℃ / ±2%RH
主控MCUESP32-C3支持Wi-Fi/BLE,低功耗
通信模块内置Wi-Fi支持MQTT/TCP
电源3.7V锂电池 + 充电管理TP4056续航约6个月(每小时上报一次)

5.2.1.2 电路连接

  • SHT30的SCL→ESP32的IO22,SDA→IO21,VCC→3.3V,GND→GND
  • 电池正极→TP4056的BAT+,TP4056的OUT+→ESP32的VIN
  • 预留UART0作为调试口

5.2.1.3 固件设计

  • 采用Arduino/ESP-IDF开发
  • 主循环:读取传感器(每10秒一次)→ 平均值计算(每分钟)→ 通过MQTT上报 → 进入深度睡眠(剩余时间)
  • 上报频率可远程配置(默认60秒)
  • 支持OTA升级

5.2.2 控制终端详细设计

5.2.2.1 硬件组成

  • 主控:STM32F103C8T6
  • 继电器模块(控制220V设备)
  • 通信接口:SPI接ESP8266(透传MQTT)
  • 本地存储:AT24C02(保存设备配置)

5.2.2.2 控制逻辑

  • 监听通信模块转发的控制指令 → 解析指令类型(开关、PWM调光等)→ 驱动GPIO/继电器 → 读取传感器反馈(可选)→ 返回执行结果。

5.2.3 硬件协同时序

text

[传感器] --> UART --> [控制终端] --> SPI --> [通信模组] --> MQTT --> [平台]
[平台]   --> MQTT --> [通信模组] --> SPI --> [控制终端] --> GPIO --> [执行器]

5.3 数据库详细设计

5.3.1 关系型数据库表设计(MySQL)

5.3.1.1 设备表 device

字段类型说明
device_idVARCHAR(32) PK设备唯一标识
device_nameVARCHAR(64)设备名称
protocolENUM('MQTT','TCP','HTTP')接入协议
product_keyVARCHAR(32)产品型号
secretVARCHAR(64)设备密钥(加密存储)
statusTINYINT0-离线,1-在线
last_active_timeDATETIME最后心跳时间
created_timeDATETIME注册时间

5.3.1.2 用户表 user

字段类型说明
user_idINT AUTO PK
usernameVARCHAR(32) UNIQUE
passwordVARCHAR(128)bcrypt加密
role_idINT关联角色表
......

5.3.1.3 指令日志表 control_log

字段类型说明
command_idVARCHAR(36) PK
device_idVARCHAR(32)
commandTEXT指令内容
statusVARCHAR(16)pending/succeeded/failed
retry_countINT重试次数
create_timeDATETIME
finish_timeDATETIME

5.3.2 时序数据库设计(InfluxDB)

  • 测量名:device_data
  • Tag:device_id,sensor_type
  • Field:value(数值),unit(单位)
  • Timestamp:毫秒级时间戳

示例查询:SELECT mean(value) FROM device_data WHERE device_id='xxx' AND time > now()-1d

第六部分:测试文档

6.1 测试计划

6.1.1 测试范围与策略

  • 单元测试:软件各模块方法级测试,覆盖率≥80%
  • 集成测试:模块间接口、软硬件协同通信
  • 系统测试:端到端功能、性能、安全性、兼容性
  • 硬件测试:传感器精度、通信距离、功耗、环境适应性

6.1.2 测试环境

  • 软件:测试服务器(4C8G)、MySQL、InfluxDB、EMQX、Chrome浏览器
  • 硬件:温湿度传感器5,控制终端3,通信模组*3,可调温湿箱,直流电源

6.1.3 测试里程碑

阶段时间输出物
单元测试第9-10周单元测试报告
集成测试第11周集成测试报告
系统测试第12周系统测试报告、缺陷清单
硬件测试并行硬件测试报告
验收测试第13周验收测试报告

6.2 测试用例

6.2.1 功能测试用例(部分)

用例ID模块测试项前置条件输入/操作预期结果优先级
TC-SW-001设备接入MQTT设备正常接入平台已部署,MQTT Broker运行设备使用正确ID/Secret连接连接成功,设备状态变更为“在线”P0
TC-SW-002设备接入错误密钥拒绝同上使用错误Secret连接连接拒绝,日志记录失败P1
TC-SW-003数据采集接收并存储温湿度设备已在线设备上报温湿度数据数据在InfluxDB可查,前端显示正确P0
TC-SW-004数据采集异常数据告警设备上报温度>80℃同上系统产生告警记录,前端提示P1
TC-SW-005远程控制下发开关指令控制终端在线点击“关闭”按钮设备执行关闭,指令状态成功P0
TC-SW-006远程控制离线设备控制设备离线下发指令提示设备离线,指令状态失败P1
TC-SW-007权限管理普通用户访问其他设备用户A只绑定设备1用户A尝试查看设备2数据返回403或无权限提示P0
TC-SW-008数据分析历史数据曲线已有24小时数据选择设备、时间范围展示正确的折线图P1

6.2.2 性能测试用例

用例ID测试项负载条件指标预期结果
TC-PERF-001并发设备接入1000个MQTT设备同时连接连接成功率≥99.5%成功率达标,CPU≤70%
TC-PERF-002数据上报吞吐500设备同时每秒上报1条消息处理延迟≤500ms无积压,延迟达标
TC-PERF-003控制指令并发100个并发指令响应时间≤1s95%指令在1s内返回

6.2.3 硬件测试用例

用例ID测试项方法判定标准
TC-HW-001温度精度与标准温度计对比(0℃,25℃,50℃)误差≤±0.5℃
TC-HW-002湿度精度与标准湿度计对比(30%,60%,90%RH)误差≤±5%RH
TC-HW-003功耗测试电池供电,每小时上报一次续航≥6个月(实测计算)
TC-HW-004通信距离开阔场地测试Wi-Fi连接距离≥50米稳定连接
TC-HW-005高低温工作-20℃~60℃恒温箱运行2小时设备不宕机,数据正常

6.2.4 安全测试用例

用例ID测试项操作预期结果
TC-SEC-001未授权访问不带Token访问API返回401
TC-SEC-002SQL注入在设备ID参数中输入 ' OR '1'='1查询失败或转义,不泄露数据
TC-SEC-003通信加密抓包MQTT数据应看到TLS加密,不能明文看到密码

6.3 测试报告(模板)

6.3.1 测试概要

  • 测试版本:v1.0
  • 测试周期:202X年X月X日 - 202X年X月X日
  • 总用例数:120,通过:115,失败:5,阻塞:0
  • 缺陷总数:8(严重2,一般4,轻微2)

6.3.2 缺陷分析

缺陷ID模块描述严重程度状态
BUG-01设备接入TCP连接偶尔掉线不重连严重已修复
BUG-02远程控制批量控制时部分设备未收到指令一般已修复
...............

6.3.3 测试结论

  • 核心功能满足需求,性能指标达标,硬件精度符合标准。
  • 建议修复剩余轻微问题后上线。

6.4 当前 MVP 自动验证补充(2026-09-12)

6.3 的数量和日期属于原模板示例,不代表本次实测结果。本次结果只以 iot-platform-mvp/tests/verify.js 的实际输出及 交付记录/VERIFICATION.txt 为准。

6.4.1 自动场景

场景断言
健康检查服务为 up、结构版本正确
管理认证错误密码 401;正确密码取得令牌和管理员角色
角色授权运维读取审计日志返回 403
产品/设备产品列表可读;注册设备持久化且序列号唯一
设备遥测错误设备密钥 401;合法上报 202 并更新状态
小程序登录取得 user 令牌
绑定有效绑定码绑定成功;列表仅返回本人设备
越权用户不能读取未归属设备
控制本人在线设备控制成功并更新当前属性
三端完整性管理后台、后端、小程序关键文件存在且 JS/JSON 可解析

6.4.2 执行

cd .\物联网项目文档\iot-platform-mvp
npm test

测试启动随机本地端口并使用独立临时 JSON 文件,结束时关闭服务并清理临时数据,不影响演示库。任何断言失败时进程返回非零状态。

6.4.3 仍需在生产一期执行

真实微信登录、EMQX TLS/ACL、MQTT 断线重连、指令 ACK/超时/重试、乱序和重复消息、数据库故障、备份恢复、1,000 设备并发、弱网真机、固件/硬件精度、电气安全和租户隔离测试。

第七部分:部署与运维文档

7.1 部署方案

7.1.1 软件部署架构

  • 负载均衡:Nginx(HTTPS卸载)
  • 后端服务:Spring Boot(jar包),systemd管理,3节点集群
  • 前端:Vue打包静态文件,Nginx托管
  • 数据库:MySQL主从+读写分离,InfluxDB集群
  • 消息中间件:Kafka(数据采集缓冲)
  • MQTT Broker:EMQX集群(3节点)

7.1.2 部署步骤(摘要)

  1. 安装Docker及docker-compose(或K8s)
  2. 拉取镜像:MySQL, InfluxDB, EMQX, Redis, Kafka, 后端服务镜像
  3. 配置环境变量:数据库连接、JWT密钥、MQTT地址
  4. 执行数据库初始化脚本(schema.sql,seed.sql)
  5. 启动所有容器,验证健康检查
  6. 配置Nginx反向代理与SSL证书(Let's Encrypt)
  7. 硬件设备配置:烧录固件,配置平台域名

7.1.3 硬件部署指导

  • 温湿度传感器:室内壁挂,离地1.5米,避免阳光直射
  • 控制终端:靠近被控设备,确保Wi-Fi信号强度≥-70dBm
  • 通信模块天线竖直向上,远离金属遮挡

7.2 运维手册

7.2.1 日常巡检项

  • 每日:检查服务进程、磁盘使用率、数据库连接数
  • 每周:查看错误日志,清理过期数据
  • 每月:安全补丁更新,性能容量评估

7.2.2 常见问题处理

问题现象可能原因处理步骤
设备无法接入Broker挂掉docker ps 检查EMQX,重启容器
数据显示延迟Kafka积压增加消费者实例或扩容
控制指令超时设备网络差检查设备RSSI,建议移近路由器

7.2.3 备份与恢复

  • 数据库每日全量备份脚本(mysqldump + influx backup)
  • 备份保留到OSS,保留30天
  • 恢复:停止服务 → 恢复备份 → 重启验证

7.3 当前 MVP 部署(2026-09-12 补充)

7.1 描述生产目标架构;本节描述仓库中已经实现的 Node.js MVP,两者不可混作已上线能力。

7.3.1 环境要求与启动

  • Windows/Linux/macOS,Node.js 20 或更高版本。
  • 运行时无第三方 npm 依赖,不需要执行 npm install。
cd E:\_1\notes\项目规划\物联网项目文档\iot-platform-mvp
npm test
$env:TOKEN_SECRET='请替换为至少32字节随机值'
$env:DATA_FILE='E:\iot-data\db.json'
$env:PORT=3000
npm start

验证项:

Invoke-RestMethod http://127.0.0.1:3000/api/health
Start-Process http://127.0.0.1:3000/admin/

健康响应中 data.status 必须为 up;后台静态资源和 API 由同一进程提供,不需要额外处理 CORS。

7.3.2 数据文件

默认数据路径为 iot-platform-mvp/backend/data/db.json。服务首次启动会生成演示数据;写入时先创建临时文件再同卷重命名,避免进程中断留下半个 JSON。JSON 存储只能运行一个写进程,不支持共享磁盘多实例。

备份:停止写入或停止服务,复制数据文件并计算 SHA-256。恢复:停止服务,校验备份 JSON 可解析和哈希正确,用备份替换数据文件,启动后执行 npm test(独立临时库)和健康/登录/关键数据抽查。

7.3.3 进程管理示例

Windows 可使用 NSSM/任务计划程序,Linux 可使用 systemd。进程账户只需要读取代码和读写 DATA_FILE 所在目录,不应使用管理员/root 身份。服务异常退出自动重启,但连续失败应退避并告警,避免覆盖诊断日志。

反向代理应完成 HTTPS、请求体上限、访问日志和超时。/api/device/telemetry 请求体当前上限 1MB;生产应按业务进一步收紧。公网部署必须禁用默认密钥、修改演示密码并限制管理后台来源。

7.4 生产化运行手册

7.4.1 关键 SLI

链路指标初始告警建议
HTTP API请求量、P50/P95/P99、5xx、鉴权失败5 分钟 5xx > 2%
MQTT连接数、连接失败、上下行消息、丢弃数连接失败率 > 5%
Kafkaconsumer lag、重试/死信lag 持续 10 分钟增长
指令pending 时长、成功/失败/超时率10 分钟超时率 > 3%
数据库连接、慢查询、磁盘、复制延迟磁盘 > 75%,复制延迟 > 60s
设备在线率、离线时长、固件分布关键设备离线立即告警

7.4.2 故障定位顺序

  1. 用 requestId/commandId/deviceId 确认单个请求和影响范围。
  2. 检查 API、数据库、Broker、Kafka 和消费者健康,不立即重启全部组件。
  3. 控制超时依次核对指令记录、Outbox、发布结果、设备订阅、设备日志和 ACK。
  4. 遥测缺失依次核对连接、ACL、Broker 入站、Kafka lag、校验死信和时序库写入。
  5. 先止损(暂停发布/限流/切只读/回滚),保留日志和时间线,再恢复和复盘。

7.4.3 备份恢复目标

业务关系库目标 RPO≤15 分钟、RTO≤60 分钟;遥测按成本和客户合同独立设定。每季度至少一次恢复演练,必须恢复到隔离环境并验证用户数、设备数、绑定关系、最近指令和随机遥测,而不只验证备份文件存在。

第八部分:用户手册(概要)

8.1 平台操作指南

8.1.1 登录与注册

  • 访问 https://iot.xxx.com,首次使用需注册企业账号
  • 登录后进入仪表盘

8.1.2 设备管理

  • 添加设备:点击“设备管理”→“添加设备”→输入设备ID和密钥(设备外壳标签上)→选择协议→完成
  • 查看设备:列表显示设备状态,点击可查看实时数据与历史曲线

8.1.3 远程控制

  • 在设备详情页,点击“控制”选项卡 → 选择控制命令(开关、调节等)→ 确认发送 → 显示执行结果

8.1.4 数据分析

  • “数据报表”菜单 → 选择设备、时间范围、数据类型 → 生成图表,可导出Excel

8.2 硬件安装指南(以温湿度传感器为例)

  1. 打开包装,取出传感器主体和支架
  2. 使用附赠的Micro-USB线充电2小时(红灯充电,绿灯满电)
  3. 下载配网App或通过微信小程序,长按设备按键5秒进入配网模式
  4. 输入Wi-Fi密码,等待提示“配网成功”
  5. 登录平台查看设备是否在线

8.3 当前 MVP 管理后台操作(2026-09-12 补充)

8.1~8.2 是目标产品概要;本节与当前可运行工程一致。

8.3.1 登录与总览

  1. 启动 iot-platform-mvp 后打开 http://127.0.0.1:3000/admin/。
  2. 管理员使用 admin / admin123,运维使用 operator / operator123。
  3. 总览显示设备总数、在线数、终端用户数、待处理告警、今日指令、消息趋势和最近告警。
  4. 右上角刷新按钮重新请求当前页面;退出按钮清除本地登录令牌。

8.3.2 产品与设备

  • 进入“产品管理”查看 ProductKey、品类、协议、物模型版本和设备数量。
  • 管理员点击“新建产品”,ProductKey 不能重复;运维角色只能查看产品。
  • 进入“设备管理”可搜索名称/编号。点击“注册设备”,填写设备名称、唯一序列号、产品和固件版本。
  • 新设备默认为离线、未绑定;创建结果含绑定码和设备凭据语义,生产系统只应一次显示设备密钥。

8.3.3 指令、告警和审计

  • 在设备行点击“下发指令”,或进入“指令记录”选择目标设备。在线设备由 MVP 模拟立即成功,离线设备显示等待中。
  • “告警中心”显示待处理/已恢复状态;点击“标记已处理”更新告警并记录审计。
  • 只有管理员能打开“审计日志”,运维访问相应 API 会被后端拒绝。

8.4 当前 MVP 小程序操作

  1. 微信开发者工具导入 iot-platform-mvp/mini-program。
  2. 检查 config.js 中的 API 地址。开发工具可使用 http://127.0.0.1:3000,真机改为开发机局域网地址。
  3. 点击“使用本地演示身份”,或走微信一键登录适配入口。
  4. 设备页右上角点击“+”,扫码或输入演示绑定码 IOT-GW-0002。
  5. 绑定成功后返回设备页并下拉刷新;进入设备详情查看当前属性、产品、协议、固件和最后活跃时间。
  6. 在线智能开关可以切换电源;离线设备禁用开关,避免向用户伪报即时成功。
  7. “我的”页可查看用户身份并退出登录。

8.5 常见问题

现象处理
后台提示账号或密码错误检查大小写;演示管理员为 admin/admin123
页面提示登录过期重新登录;检查服务重启时 TOKEN_SECRET 是否改变
小程序请求失败确认后端已启动、config.js 地址可从当前设备访问、合法域名设置符合环境
绑定码无效去除空格并核对标签;演示码为 IOT-GW-0002
设备已被绑定当前所有者先申请解绑/转移;管理员不能直接把同一设备重复分配
设备离线检查供电、网络、设备凭据、Broker/HTTP 接入和最后活跃时间
指令等待中设备离线或尚未 ACK;不要重复快速点击,使用 commandId 查询结果

8.6 数据与账号注意事项

演示数据和默认密码只用于本地验证。生产使用独立账号、强密码/单点登录和 HTTPS;不要在截图、工单或聊天中发送设备密钥。用户只能查看本人设备,发现归属错误应停止控制并发起转移流程,由平台保留审计。

第九部分:项目验收文档

9.1 验收计划

  • 验收时间:测试阶段结束后3个工作日内
  • 参与人员:客户代表、项目负责人、测试经理
  • 验收标准:需求文档中的所有功能均已实现且通过测试;性能指标达标;硬件精度符合规格;文档齐全

9.2 验收清单

编号验收项是否满足(是/否)备注
1软件平台所有功能模块可正常使用
2支持MQTT/TCP/HTTP三种协议设备接入
3设备在线状态实时更新
4数据采集延迟≤500ms
5远程控制响应时间≤1s
6并发1000设备在线,系统稳定
7硬件传感器精度达标
8硬件通信距离≥50米
9提供完整文档(需求、设计、测试、部署、用户手册)
10提供源代码与固件代码

9.3 验收结论

  • 验收通过 □ 不通过 □
  • 遗留问题及处理计划:__________
  • 验收签字:
    • 客户代表:__________
    • 项目负责人:__________
    • 日期:__________

9.4 MVP 验收门槛(2026-09-12 补充)

9.2 的全量软硬件条目用于生产阶段,不代表零依赖 MVP 已满足 1,000 设备、三协议和硬件指标。本次 MVP 采用下列可执行门槛。

编号验收项方法通过标准
MVP-01工程可启动npm start + 健康请求/api/health 返回 200/up
MVP-02自动回归npm test全部检查通过、退出 0
MVP-03后台闭环登录、注册设备、指令、告警操作实际写入 API 数据并刷新页面
MVP-04设备接入正确/错误凭据遥测请求正确 202,错误 401 且不改变数据
MVP-05用户绑定小程序演示登录并绑定绑定后仅当前用户可见
MVP-06用户控制控制本人在线设备指令成功且属性更新;他人设备拒绝
MVP-07角色边界运维请求审计接口后端返回 403
MVP-08文档检查总览、API、部署、用户和路线图路径有效,内容与实现边界一致
MVP-09回滚在隔离副本执行回滚脚本哈希恢复且变更文件消失

生产上线验收须另行执行 12.迭代路线图.md 中 M1~M3 的质量门禁,不能用 MVP 结果替代。

第十部分:MVP 实现说明

10.1 交付目标

本次不是只输出界面原型,而是提供一个可以直接运行和自动验证的纵向 MVP。它以同一份领域数据连接管理后台、后端和微信小程序,覆盖首期最关键的设备交付闭环。

工程路径:物联网项目文档/iot-platform-mvp。

10.2 技术选择

端技术选择理由
后端Node.js 20+ 内置 HTTP/crypto/fs零第三方依赖,可在当前环境直接运行和审查
持久化原子替换 JSON 文件便于 MVP 演示、测试隔离和回滚;明确不是生产数据库
管理后台原生 HTML/CSS/JavaScript SPA无构建步骤,服务启动即可访问;响应式桌面/移动布局
小程序原生微信小程序 WXML/WXSS/JS可直接导入微信开发者工具,保留平台 API 对接方式
测试Node.js assert + 真实 HTTP 端到端请求不使用 Mock 路由,验证认证、持久化和响应契约

10.3 已实现能力

后端

  • PBKDF2 密码哈希、HMAC 签名令牌、过期校验。
  • admin/operator/user 三种角色及接口级授权。
  • 产品查询/创建、设备查询/注册/修改、用户查询。
  • 设备凭据校验、遥测接收、属性合并、在线态刷新。
  • 小程序登录适配、设备绑定、归属过滤、设备详情。
  • 管理端/用户端指令下发、在线成功与离线等待状态。
  • 告警列表/处置、关键动作审计、健康检查。
  • JSON 写入使用临时文件加同卷重命名,避免半写文件。

管理后台

  • 登录页、响应式侧栏和角色感知菜单。
  • 运营统计、近七日趋势、最近告警。
  • 产品、设备、用户、指令、告警和审计页面。
  • 新建产品、注册设备、下发指令、处理告警等实际 API 操作。
  • 本地令牌会话、401 自动退出、错误消息和加载态。

用户小程序

  • 微信登录入口及本地 demo 登录。
  • 本人设备汇总、在线/离线状态、下拉刷新。
  • 扫码或手工输入绑定码。
  • 设备属性和基础信息查看、开关控制。
  • 个人中心和退出登录。

10.4 启动与验证

cd E:\_1\notes\项目规划\物联网项目文档\iot-platform-mvp
npm test
npm start
  • 管理后台:http://127.0.0.1:3000/admin/
  • 健康检查:http://127.0.0.1:3000/api/health
  • 管理员:admin / admin123
  • 运维人员:operator / operator123
  • 演示绑定码:IOT-GW-0002

默认数据首次运行写入 backend/data/db.json。需要恢复演示数据时,停止服务后删除该文件并重新启动;正式数据请先备份。测试使用独立临时文件,不污染运行数据。

10.5 配置

环境变量默认值说明
PORT3000HTTP 监听端口
TOKEN_SECRET内置开发值令牌签名密钥;生产必须使用长随机值并托管
DATA_FILEbackend/data/db.jsonJSON 数据文件绝对或相对路径

小程序 API 地址在 mini-program/config.js。开发者工具可使用 127.0.0.1;手机真机需要开发机局域网地址,生产必须使用已备案且加入小程序合法域名的 HTTPS 地址。

10.6 代码结构

iot-platform-mvp/
├─ backend/
│  ├─ server.js          路由、授权、静态资源和领域用例
│  └─ lib/
│     ├─ auth.js         密码与令牌
│     └─ store.js        种子数据与原子持久化
├─ admin-web/
│  ├─ index.html         应用骨架
│  ├─ styles.css         设计系统与响应式样式
│  └─ app.js             页面渲染与 API 交互
├─ mini-program/         微信小程序入口、页面和请求封装
├─ tests/verify.js       端到端验证
└─ scripts/static-check.js

10.7 已知技术债与生产门禁

  1. 存储:JSON 仅支持单进程;生产迁移 PostgreSQL/TimescaleDB 并引入数据库迁移工具。
  2. 消息:当前没有连接真实 EMQX;生产实现 Topic ACL、Kafka 消费、设备 ACK、超时重试和幂等。
  3. 认证:微信登录仅保留适配入口;生产服务端调用 code2Session,校验 AppID/密钥,并处理手机号授权和账号合并。
  4. 密钥:MVP 为联调保存设备明文密钥;生产创建时一次展示,服务端使用 KMS/哈希,提供轮换和吊销。
  5. 令牌:生产使用轮换密钥、refresh token、设备/会话撤销和更短后台令牌周期。
  6. 前端工程化:规模扩大后迁移 Vue/React + TypeScript、组件库、路由、端到端浏览器测试和构建产物完整性。
  7. 可观测性:增加结构化日志、requestId、指标、链路追踪、Broker 指标和 SLO 告警。
  8. 多租户:开放客户前所有主表加入 tenant_id,建立行级隔离测试、租户级限流和凭据 scope。

10.8 完成定义

本 MVP 的“完成”指:工程零依赖安装即可启动;确定性测试验证关键权限与业务路径;后台能执行实际数据操作;小程序页面和请求链路完整;需求、接口、部署、用户和迭代文档互相引用。它不等同于生产环境已经满足规模、安全和可靠性目标。

第十一部分:MVP API 接口文档

11.1 通用约定

  • Base URL:http://127.0.0.1:3000
  • 编码:UTF-8 JSON。
  • 管理员和小程序接口通过 Authorization: Bearer <token> 认证。
  • 成功响应:{"code":0,"message":"ok","data":...}。
  • 失败响应:{"code":"ERROR_CODE","message":"可读说明"},HTTP 状态码表达错误类型。
  • 时间为 ISO 8601 UTC 字符串;调用方按本地时区显示。
  • 当前 API 为 MVP v0 契约。生产版建议统一增加 /api/v1 前缀、requestId 和分页结构。

11.2 公共与认证接口

GET /api/health

无需认证。返回服务、数据结构版本和服务器时间。

POST /api/auth/login

管理后台登录。

{ "username": "admin", "password": "admin123" }

返回 token 和去除密码字段的 user。错误账号返回 HTTP 401 / INVALID_CREDENTIALS。

GET /api/auth/me

返回当前令牌对应用户,用于恢复后台会话。

11.3 管理后台接口

方法与路径角色说明
GET /api/admin/summaryadmin/operator汇总指标、趋势、最近告警和指令
GET /api/admin/productsadmin/operator产品列表,支持 keyword
POST /api/admin/productsadmin创建产品
GET /api/admin/devicesadmin/operator设备列表,支持 keyword、status
POST /api/admin/devicesadmin/operator注册设备
PATCH /api/admin/devices/{id}admin/operator修改名称、固件或演示状态
GET /api/admin/usersadmin/operator用户及绑定设备数量
GET /api/admin/commandsadmin/operator指令列表
POST /api/admin/commandsadmin/operator下发指令
GET /api/admin/alertsadmin/operator告警列表,支持 status
PATCH /api/admin/alerts/{id}admin/operator更新告警状态
GET /api/admin/auditsadmin最近 100 条审计日志

创建产品:

{
  "name": "四路智能开关",
  "productKey": "SWITCH-4CH",
  "category": "智能开关",
  "protocol": "MQTT",
  "modelVersion": "1.0.0"
}

注册设备:

{
  "deviceName": "客户展厅开关",
  "serialNumber": "SW20260912001",
  "productId": "p-switch",
  "firmware": "1.0.0"
}

服务端生成 id、bindCode 和 deviceSecret。生产 API 应只在创建响应展示一次 deviceSecret,列表永不返回。

下发指令:

{
  "deviceId": "d-switch-001",
  "name": "setPower",
  "params": { "power": false }
}

在线设备在 MVP 返回 succeeded;离线设备返回 pending。生产接口应优先返回 202/pending,由 ACK 异步更新。

处置告警:

{ "status": "resolved" }

11.4 设备接入接口

POST /api/device/telemetry

请求头:

X-Device-Id: d-switch-001
X-Device-Secret: dev-secret-switch-001
Content-Type: application/json

请求体:

{
  "ts": "2026-09-12T04:00:00.000Z",
  "data": { "power": true, "voltage": 220.1, "current": 0.16 }
}

成功返回 HTTP 202,并保存遥测、合并当前属性、将设备设为在线并刷新 lastSeenAt。凭据错误返回 401 DEVICE_AUTH_FAILED;data 不是对象返回 422。

真实 MQTT 接入的 Topic 和消息信封见 5-1系统设计-一些细节.md。

11.5 小程序接口

POST /api/mini/auth/wechat

{ "code": "wx.login 返回的 code", "nickname": "微信用户" }

MVP 将 code 映射为演示 openid;生产服务端必须调用微信 code2Session,不得信任客户端上传的 openid。

GET /api/mini/devices

只返回当前用户绑定设备,响应中不包含 deviceSecret。

POST /api/mini/devices/bind

{ "bindCode": "IOT-GW-0002" }

无效码返回 404;被其他用户绑定返回 409;属于同一用户时幂等返回设备。

GET /api/mini/devices/{id}

返回本人设备详情以及最近 20 条遥测。请求其他用户或未绑定设备统一返回 404。

POST /api/mini/devices/{id}/commands

{ "name": "setPower", "params": { "power": true } }

只有设备所有者可以操作。

11.6 错误码

HTTPcode场景
400INTERNAL_ERROR(带格式说明)JSON 解析失败
401UNAUTHORIZED令牌缺失、错误或过期
401INVALID_CREDENTIALS后台账号或密码错误
401DEVICE_AUTH_FAILED设备身份无效
403FORBIDDEN角色权限不足
404NOT_FOUND / DEVICE_NOT_FOUND路由或授权范围内对象不存在
409PRODUCT_KEY_EXISTS / SERIAL_EXISTS唯一键冲突
409DEVICE_ALREADY_BOUND设备已归属其他用户
422VALIDATION_ERROR必填字段或数据类型不满足

11.7 调用示例(PowerShell)

$login = Invoke-RestMethod -Method Post `
  -Uri http://127.0.0.1:3000/api/auth/login `
  -ContentType 'application/json' `
  -Body '{"username":"admin","password":"admin123"}'

$headers = @{ Authorization = "Bearer $($login.data.token)" }
Invoke-RestMethod -Uri http://127.0.0.1:3000/api/admin/devices -Headers $headers

第十二部分:迭代路线图

12.1 演进原则

先证明单设备纵向闭环,再建设稳定消息链路和数据底座;先建设租户隔离,再开放客户接入;先提供受控模板,再考虑通用低代码。每个阶段都以可观测、可回滚和可验收为完成条件。

12.2 里程碑

M0:可运行 MVP(已交付)

  • 管理后台、Node.js API、微信小程序三端贯通。
  • 产品、设备、用户绑定、遥测、指令、告警、审计核心用例。
  • 确定性自动测试和本地启动文档。

退出条件:本地 npm test 全部通过,后台实际可操作,小程序工程结构完整。

M1:生产数据与认证底座(建议 2~3 个迭代)

  • PostgreSQL 表结构、迁移、连接池、事务和唯一约束。
  • TimescaleDB 遥测分区、保留策略和聚合查询。
  • Redis 会话/缓存/在线 TTL/分布式幂等。
  • OAuth2/OIDC 管理端认证,真实微信 code2Session,设备凭据加密与轮换。
  • OpenAPI 规范生成、统一分页、错误码、requestId、结构化日志。

退出条件:数据迁移演练通过;备份恢复满足 RPO/RTO;越权、重放和密钥泄漏测试通过。

M2:真实设备消息闭环(建议 2~4 个迭代)

  • EMQX TLS、设备鉴权、Topic ACL、连接事件。
  • Kafka 消息总线、Schema 版本、死信、消费幂等和重放工具。
  • Transactional Outbox 指令发布、ACK 状态机、超时重试与取消。
  • 完整 desired/reported 设备影子、乱序保护和离线补偿策略。
  • 设备模拟器、固件联调环境和 100/1,000 台阶梯压测。

退出条件:真实开关/网关完成连续 7 天稳定联调;不丢业务数据;重复消息不产生重复副作用。

M3:本司设备试点上线(建议 2 个迭代)

  • 设备批量导入、产线烧录/凭据交付、二维码标签。
  • OTA 任务、灰度、版本分组和失败回退。
  • 告警规则、通知、运维工单和设备诊断包。
  • 管理后台工程化、浏览器 E2E;小程序隐私合规、体验优化和发布流程。
  • 监控面板、SLO、值班手册、容量模型和故障演练。

退出条件:试点设备运行 30 天;重大故障为零;核心 SLO 达标;用户和运维验收通过。

M4:企业多租户与开放接入

  • Tenant、组织、成员、角色、设备组、站点和数据授权。
  • 企业自助产品/物模型/设备注册,接入 SDK 和在线调试。
  • 租户级 API Key/OAuth Client、scope、限流、调用日志、Webhook 重试。
  • 租户隔离自动化测试、账单计量预留、数据导出/删除。
  • 白标小程序先采用主题与页面模板,不开放任意代码执行。

退出条件:租户交叉访问测试 100% 阻断;首个企业客户完成沙箱联调和数据授权确认。

M5:企业服务履约与低代码

  • 安装工单、派单、签到、物料、图片、工时、远程协助和验收。
  • OEM/ERP 连接器、字段映射和客户专属 OpenAPI 套餐。
  • 受约束的页面 DSL、组件白名单、预览、审批、版本和回滚。
  • 多品牌构建和发布流水线、配置审计和运行分析。

退出条件:现场服务全流程线上化;低代码产物通过安全沙箱和发布审核;至少两类企业方案可复制。

12.3 优先级 Backlog

优先级工作项价值前置
P0PostgreSQL 数据模型与迁移消除单机存储限制无
P0EMQX 设备鉴权/ACL 与真实 ACK控制结果可信设备协议定稿
P0绑定码一次性/过期/重置交付安全数据库事务
P0设备/消息/指令幂等避免重复副作用唯一键、messageId
P0监控、日志、备份恢复可运营生产环境
P1批量注册与二维码标签提升出厂效率产品/设备模型稳定
P1OTA 灰度和回退降低固件升级风险真实设备链路
P1用户解绑/转移/家庭分享完善用户生命周期绑定审计
P1告警规则和通知提升运维效率遥测流
P2多租户与企业门户开放企业客户M1/M2 完成
P2OpenAPI/Webhook系统集成租户和 API 治理
P3白标模板/低代码个性化交付安全沙箱和发布治理

12.4 不应提前建设的内容

  • 未有稳定物模型前建设通用拖拽页面,会把不稳定协议扩散到 UI DSL。
  • 未有租户隔离前开放客户设备,会造成数据边界返工和安全风险。
  • 未有指令 ACK 与幂等前批量控制,会放大不可确认和重复执行问题。
  • 未有真实容量数据前上复杂微服务/Kubernetes,不利于定位产品闭环问题。

12.5 每阶段质量门禁

需求和验收条件已编号;API 契约和数据库迁移向后兼容;单元/集成/端到端/设备联调测试通过;权限与租户隔离有反向用例;监控和容量预算更新;发布包含升级、验证、回滚步骤;故障复盘形成下一迭代工作项。

物联网物模型与设备影子

1. 两个概念的职责

**物模型(Thing Model)**描述某一产品“能做什么”,是设备能力的版本化契约;**设备影子(Device Shadow)**描述某一具体设备“现在/期望是什么状态”,是云端的当前态缓存。物模型属于产品,影子属于设备。

类型含义示例
属性 Property可读取或设置的持续状态power:boolean、temperature:float
服务 Service一次可调用的动作reboot()、setSchedule()
事件 Event设备主动产生的离散事实overheat、tamper

2. 智能开关物模型示例

{
  "productKey": "SWITCH-1CH",
  "version": "1.0.0",
  "properties": [
    { "key": "power", "name": "电源", "type": "boolean", "access": "readWrite", "required": true },
    { "key": "voltage", "name": "电压", "type": "float", "unit": "V", "min": 0, "max": 260, "access": "readOnly" },
    { "key": "current", "name": "电流", "type": "float", "unit": "A", "min": 0, "max": 20, "access": "readOnly" }
  ],
  "services": [
    { "key": "reboot", "name": "重启", "input": [], "output": [{ "key": "accepted", "type": "boolean" }] }
  ],
  "events": [
    { "key": "overload", "name": "过载", "level": "warning", "output": [{ "key": "current", "type": "float", "unit": "A" }] }
  ]
}

3. 设备影子示例

{
  "deviceId": "d-switch-001",
  "desired": { "power": false },
  "reported": { "power": true, "voltage": 220.3, "current": 0.18 },
  "delta": { "power": false },
  "desiredVersion": 8,
  "reportedVersion": 7,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}

当用户发出“关闭”时,云端先把 desired.power 写为 false 并递增版本。设备收到指令并执行后上报 reported.power=false;两侧一致时 delta 清空。离线设备恢复连接后可以拉取较新的 desired 版本,但必须结合指令有效期和业务规则决定是否补执行。

4. 版本与兼容

  • 物模型采用语义化版本。增加可选字段为次版本,删除字段或改变数据类型为主版本。
  • 已发布版本不可原地修改;设备注册时记录兼容的模型版本。
  • 服务端先按消息声明版本校验,再转换成内部规范结构。
  • 前端根据元数据渲染只是增强能力,关键控制仍需产品化交互和权限确认。

5. 与遥测、配置、数字孪生的区别

  • 遥测是按时间追加的事实序列,适合趋势和统计;影子是可覆盖的当前快照。
  • 设备配置是期望状态的一种来源,可进入 desired;配置模板本身属于产品/设备组策略。
  • 数字孪生还包含关系、行为仿真和生命周期,范围大于物模型加影子。

6. 当前实现

MVP 以设备 properties 字段保存简化的 reported 快照,遥测保存于 telemetry 集合,指令参数在成功后合并到 properties。生产化按 5-1系统设计-一些细节.md 拆分 desired/reported/delta 和版本,并接入时序数据库。

本司物联网设备管理平台文档中心

文档版本:1.0 · 更新日期:2026-09-12 · 当前交付:可运行 MVP

快速入口

文档体系

阶段文档产出
立项1研究背景.md、2技术分析.md背景、价值、可行性
需求3需求分析.md、需求.md功能、非功能和软硬件需求
架构4概要设计.md系统边界、模块和部署架构
设计5系统设计.md、5-1系统设计-一些细节.md数据、接口、关键流程和状态机
验证6.测试文档.md、9验收文档.md测试策略、场景和验收门槛
交付7.部署与运维.md、8.用户手册.md部署、观测、恢复和操作方法
演进12.迭代路线图.md生产化、开放平台和企业 SaaS

实现目录

iot-platform-mvp/
├─ admin-web/       管理后台 SPA
├─ backend/         Node.js API、认证与 JSON 持久化
├─ mini-program/    原生微信小程序
├─ scripts/         静态完整性检查
└─ tests/           确定性端到端验证

执行:

cd .\物联网项目文档\iot-platform-mvp
npm test
npm start

浏览器进入 http://127.0.0.1:3000/admin/。管理员演示账号为 admin / admin123;小程序本地演示绑定码为 IOT-GW-0002。

变更规则

  1. 产品范围调整先更新 MODIFIED_FILE.md,并为验收条件编号。
  2. 当前接口调整必须同步 11.API接口文档.md 和自动验证。
  3. 物模型变更必须升级版本,已有设备保持兼容策略。
  4. 生产架构决策记录在概要/详细设计中,MVP 临时取舍记录在 10.MVP实现说明.md。
  5. 每次可交付版本同时保留测试证据、原始哈希、差异和回滚脚本。

IoT 平台项目规划

1. 项目定位

平台以本司硬件设备的数字化交付和持续服务为起点,而不是单纯的技术练习。通过一个可运营的设备平台沉淀产品、物模型、设备身份、用户关系、控制链路和服务数据,逐步形成面向个人用户和企业客户的物联网能力底座。

2. 成功标准

  • 本司设备能够在出厂后统一注册、鉴权、观测、绑定和控制。
  • 用户可以在小程序内完成从绑定到日常控制的自助闭环。
  • 运维能定位设备、链路、指令和告警问题,关键动作可审计。
  • 新产品主要通过配置物模型接入,而不是复制整套业务代码。
  • 开放客户设备和企业 SaaS 前具备可靠的租户隔离与 API 治理。

3. 分期规划

阶段目标核心交付退出门槛
MVP(当前)验证完整业务闭环管理后台、Node.js API、原生微信小程序、自动验证AC-01~AC-10 关键场景通过
生产一期承载本司设备PostgreSQL/TimescaleDB、Redis、EMQX、真实 ACK、监控告警、备份恢复试点设备稳定运行 30 天,SLO 达标
二期开放客户设备和应用多租户、产品自助、接入规范/SDK、白标应用模板租户隔离测试和开放 API 安全评审通过
三期企业 SaaS 与履约企业后台、设备组、工单/派单、Webhook、OEM/ERP OpenAPI首个企业客户验收并形成可复制方案

4. MVP 工作分解

4.1 管理后台

运营总览、产品列表/创建、设备列表/注册、用户与绑定关系、指令记录/下发、告警处置、管理员审计。后台适配桌面与移动浏览器。

4.2 后端

提供 HMAC 令牌认证、PBKDF2 密码校验、RBAC、产品/设备 API、设备遥测鉴权、用户绑定、设备归属校验、指令状态、告警和审计。MVP 采用原子替换的 JSON 文件持久化以免引入部署依赖。

4.3 用户小程序

提供微信登录适配入口、本地演示身份、设备总览、扫码/绑定码绑定、详情、属性展示、在线设备开关控制和个人中心。

5. 生产技术路线

设备 ──MQTT/TLS──> EMQX ──规则/桥接──> Kafka ──> 设备服务/规则引擎
 │                                      │
 └──HTTP/TCP 适配器─────────────────────┘
                                         ├─ PostgreSQL(业务/租户)
管理后台/小程序 ── HTTPS ──> API 网关 ──┼─ TimescaleDB(遥测)
                                         ├─ Redis(在线态/缓存/幂等)
                                         └─ 对象存储(固件/导出/归档)
  • EMQX 负责 MQTT 会话、鉴权、订阅和连接事件;不承载核心业务规则。
  • Kafka 解耦高频设备消息与业务处理,消费者以 deviceId + messageId 幂等。
  • PostgreSQL 保存租户、用户、产品、设备、绑定和指令元数据;TimescaleDB 保存时序遥测。
  • 控制服务经 Outbox 发布指令,设备 ACK 经消息链路回写状态,避免数据库和 Broker 双写不一致。

6. 项目节奏与责任

每两周一个迭代;产品负责人维护范围和验收条件,硬件负责人维护身份烧录/Topic/ACK 协议,后端负责人维护领域模型和消息可靠性,前端/小程序负责人维护交互与权限反馈,测试负责人维护自动化和真机矩阵,运维负责人维护环境、监控与恢复演练。

所有迭代必须满足:代码检查通过、API 回归通过、变更文档同步、敏感配置不入库、提供升级与回滚说明。

7. 当前交付入口

可运行工程位于 iot-platform-mvp;需求基线、API 契约和演进顺序分别见 交付记录/MODIFIED_FILE.md、11.API接口文档.md 和 12.迭代路线图.md。

本司设备联网管理平台——整理后实施基线

本文件是 本司设备联网管理.md 的实施副本。原文件未修改,SHA-256 为 85D8501A9A36C2F04B1A168DC23F4A70259B3F95B6C6AE5DFEF6BC307D6AEC8E。

CHANGED_BRANCH=stack-migration-spring-monolith
CHANGED_FIELD=technology_stack: vanilla_js_node_json -> vue3_typescript_springboot_mysql_redis_kafka_emqx

1. 本期目标

先交付本司设备联网管理 MVP,打通以下闭环:

  1. 管理员维护产品、设备、用户、指令、告警和审计记录。
  2. 设备通过 MQTT 连接 EMQX;EMQX 与 Kafka 桥接设备上下行消息。
  3. Spring Boot 后端只订阅和发布 Kafka Topic,不直接连接 MQTT。
  4. 用户通过微信小程序登录、绑定设备、查看本人设备和下发控制指令。
  5. MySQL 保存业务数据和当前阶段遥测,Redis 保存登录会话并承担热点缓存。

2. 技术基线

层次当前选择实施说明
管理后台Vue 3、TypeScript、Vite、Pinia、Vue Router响应式单页应用,统一调用 /api
后端Java 17、Spring Boot 3先做模块化单体,按领域边界组织代码
数据访问Spring Data JPA、FlywayMySQL 表结构版本化,启动时校验迁移
业务数据库MySQL用户、产品、设备、遥测、指令、告警、审计
登录与缓存Redisiot:session:{token} 会话校验;产品等热点查询缓存
设备消息Kafka后端只消费上行/ACK Topic,只发布下行 Topic
MQTT 服务EMQX负责设备 MQTT 连接、认证/ACL及 MQTT↔Kafka 桥接
用户端原生微信小程序登录、设备绑定、设备列表、详情和控制

所有中间件地址、凭证、TLS/SASL 参数和 Topic 名均由环境变量注入。收到实际地址文档后,只补部署配置,不修改业务代码中的连接逻辑。

3. 消息拓扑

设备 --MQTT--> EMQX --规则/桥接--> Kafka 上行主题 --消费--> Spring Boot 单体
设备 <--MQTT-- EMQX <--规则/桥接-- Kafka 下行主题 <--发布-- Spring Boot 单体
Kafka Topic方向消息用途
iot.device.telemetry.upEMQX → 后端属性/遥测上报
iot.device.event.upEMQX → 后端故障、告警等设备事件
iot.device.command.down后端 → EMQX设备控制指令
iot.device.command.ackEMQX → 后端指令执行回执

Kafka 按至少一次投递设计。遥测以 (deviceId, messageId) 幂等,指令以 commandId 关联 ACK;HTTP 请求成功只表示指令已进入下行链路,设备 ACK 才决定最终成功或失败。

4. 单体模块边界

模块当前职责后续拆分触发点
IAM后台/小程序登录、Redis 会话、角色权限多租户或统一身份中心接入
产品与物模型ProductKey、品类、协议、模型版本物模型版本数量和协作团队增长
设备注册、状态、归属、属性快照、绑定设备规模需要独立伸缩
消息接入Kafka 消费、校验、幂等、落库吞吐量或消费组需独立扩缩容
控制指令创建、发布、ACK 状态机下行可靠性策略独立演进
告警设备事件转告警、处置状态规则引擎和通知渠道增加
审计关键操作留痕集中日志/合规平台接入

单体阶段共享一个部署单元和 MySQL 实例,但禁止跨模块直接依赖控制器或数据库实现细节。微服务化时优先拆消息接入与遥测,再拆控制和告警;API、Kafka 事件、表归属和版本必须明确。

5. 当前功能范围

5.1 管理后台

  • 账号登录、退出和路由/角色守卫。
  • 仪表盘:设备、在线数、用户、待处理告警、当日指令。
  • 产品、设备、用户列表及产品/设备新增。
  • 设备详情、属性、指令下发。
  • 指令、告警和审计查询;告警处置。
  • 桌面与窄屏响应式布局。

5.2 Spring Boot 后端

  • Bearer Token 认证、管理员/运维/个人用户权限边界。
  • Redis 会话写入、读取、续期边界和退出删除。
  • Redis 产品查询缓存。
  • MySQL/JPA 实体、Repository 和 Flyway 初始化脚本。
  • 产品、设备、用户、指令、告警、审计 REST API。
  • Kafka 遥测/事件/ACK 消费与命令发布。
  • Actuator 健康、信息和指标端点。
  • 测试 profile 使用 H2、内存 SessionStore 和 Noop Kafka Publisher,不访问已部署中间件。

5.3 微信小程序

  • 微信登录接口封装和 Token 保存。
  • 扫码或输入绑定码绑定设备。
  • 仅查询当前用户拥有的设备。
  • 查看状态与属性,下发本人在线设备指令。
  • 登录失效统一清理会话并返回登录页。

6. 数据和状态约束

  • 产品 product_key、设备序列号和绑定码唯一。
  • 设备所有权查询必须同时校验当前用户和 owner_id。
  • 设备状态使用 ONLINE / OFFLINE。
  • 指令状态使用 PENDING → SENT → SUCCEEDED | FAILED | TIMEOUT | CANCELED。
  • 告警状态使用 OPEN / PROCESSING / RESOLVED。
  • 当前遥测写入 MySQL device_telemetry;后续可保持 Kafka 契约不变,将遥测模块迁往专用存储。

7. 配置待补项

实际中间件地址文档到位后,填写 backend/.env.example 对应部署环境变量:

  • MYSQL_URL / MYSQL_USERNAME / MYSQL_PASSWORD
  • REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_DATABASE
  • KAFKA_BOOTSTRAP_SERVERS / KAFKA_CONSUMER_GROUP
  • KAFKA_TOPIC_TELEMETRY_UP / EVENT_UP / COMMAND_DOWN / COMMAND_ACK
  • Kafka SASL/TLS 参数和证书挂载路径(若集群启用)
  • EMQX Bridge、规则名称、MQTT Topic 映射、设备认证和 ACL

EMQX_DASHBOARD_URL 仅用于运维信息。后端不得新增 MQTT 客户端来绕过 Kafka 桥接。

8. 目录与验证入口

物联网项目文档/
├─ 00~12、物联网项目文档.md、项目规划.md   整理后的文档集
└─ iot-platform-mvp/
   ├─ admin-web/      Vue 3 + TypeScript 管理后台
   ├─ backend/        Spring Boot 模块化单体
   └─ mini-program/   原生微信小程序
cd .\物联网项目文档\iot-platform-mvp\backend
.\mvnw.cmd clean test

cd ..\admin-web
npm run build

小程序使用微信开发者工具导入 mini-program。开发环境 API 默认指向 http://127.0.0.1:8080;实际联调时通过配置改为可访问的 HTTPS 地址。

9. 文档整理结果

  • 核心文档已统一为 00~12 编号和一致命名。
  • 已合并零散概念、重复需求、提问草稿和已过时的数据库选型内容。
  • 已删除 9 个不再单独维护的冗余文档/图;物模型关系图继续保留。
  • 架构、详细设计、测试、部署、用户、验收、API 和路线图均已改为当前 Vue/Spring/MySQL/Redis/Kafka/EMQX 基线。

需求分析与文档总览

1. 对《本司设备联网管理》的分析结论

原始文档已经明确了正确的产品主线:自有设备先行 → 用户设备控制 → 开放客户设备 → 企业 SaaS 与 OpenAPI。其价值不是单纯“做一个 MQTT 管理页面”,而是建立设备产品化、交付和持续服务能力。

已明确的内容

  • 管理对象包含 MQTT 透传设备、网关、智能开关等多类本司硬件。
  • 第一阶段优先做设备录入、注册、接入、功能配置、指令下发、用户和设备绑定。
  • 终端用户通过小程序或 App 扫码/输入唯一编号绑定并控制设备。
  • 后续允许客户按平台协议接入自有设备,并提供企业 SaaS、定制 OpenAPI 和现场安装管理。
  • 第二阶段的应用个性化和第三阶段的企业服务可以部分并行。

原文需要补齐的关键点

缺口直接风险本次处理
“录入、注册、接入、绑定”边界不清数据状态混乱、设备容易重复归属定义产品、设备身份、联网鉴权、用户绑定四个不同过程
设备控制只有“下发”没有 ACK 状态机页面显示成功但设备未执行定义 pending/sent/succeeded/failed/timeout 状态机
未定义个人、运维、平台、企业角色越权访问其他用户或企业设备建立 RBAC + 设备归属 + 后续 tenant_id 边界
物模型和设备影子没有落到业务每类硬件都写一套页面和协议以属性、服务、事件统一表达能力,以影子承载期望/上报状态
第二/三阶段范围过大首期同时建设低代码、ERP、SaaS 导致失焦以纵向 MVP 验证首期闭环,再按能力门槛迭代
安全、幂等和密钥生命周期未描述重放、冒用、重复指令和数据泄漏补充设备密钥、Token、幂等键、审计与生产门禁
指标缺少测量口径无法验收增加 AC-01~AC-10 和非功能基线

2. 关键产品决策

  1. 第一阶段只闭环,不铺摊子:优先交付一条可测试路径——创建产品 → 注册设备 → 上报 → 用户绑定 → 查看 → 控制 → 审计。
  2. 物模型是产品级契约:产品定义能力;设备保存身份、归属和运行态,不把展示字段直接硬编码成协议。
  3. 设备影子不是历史库:影子保存当前期望值/上报值及版本;遥测历史进入时序数据存储。
  4. 绑定关系必须原子且可追溯:扫码只是输入方式,真正安全边界是绑定码状态、设备当前归属和用户身份。
  5. HTTP 成功不代表控制成功:生产环境以设备 ACK 更新最终状态,超时应明确显示。
  6. 多租户在开放前建设:任何客户自有设备、白标应用或 OpenAPI 都必须先具有 tenant_id 隔离。

3. 文档导航与权威性

文档用途状态
交付记录/MODIFIED_FILE.md从原文整理的产品/交付需求基线当前范围权威来源
03.需求分析.md全量软硬件需求框架与需求基线联合阅读
04.概要设计.mdSpring Boot 单体架构和模块划分当前技术基线
05.详细设计.md生产目标的详细设计当前代码映射见 05.1
05.1.关键系统设计.md状态机、鉴权、Kafka 消息和一致性当前关键设计
06.测试方案.md测试策略后端由 Maven、前端由 Vite 构建验证
07.部署与运维.md本地与生产部署、备份和观测当前部署基线
08.用户手册.md管理后台与小程序操作当前操作说明
09.验收方案.md阶段验收以 AC-01~AC-10 作为一期门槛
10.MVP实现说明.md本次实现范围、运行和技术债本次新增
11.API接口文档.md当前可执行 API 契约本次新增
12.迭代路线图.md从 MVP 到生产一期及 SaaS 的演进本次新增

若旧文档中的示例技术栈、接口路径或性能数字与当前实现冲突:当前代码以 11.API接口文档.md 为准,产品范围以 MODIFIED_FILE.md 为准,生产目标以概要/详细设计为准。

4. 本次实现覆盖矩阵

能力管理后台后端小程序自动验证
管理员/运维登录✓✓—✓
产品查看/创建✓✓—✓
设备查看/注册✓✓—✓
设备遥测与在线状态展示✓展示✓
用户设备绑定查看✓✓✓
设备指令✓✓✓✓
告警查看与处置✓✓—✓
用户与角色边界✓✓✓✓
审计日志✓✓—✓
真实 MQTT/ACK—接口语义预留—后续
企业多租户/OpenAPI———二/三期

5. 评审建议

产品评审先确认首期边界、绑定/解绑规则和离线控制体验;硬件评审确认设备身份烧录、绑定码标签和 ACK 格式;后端评审确认模块化单体边界、Kafka 消息幂等与未来拆分条件;测试评审直接以 AC-01~AC-10 转成环境化用例。收到中间件地址文档后,按现有环境变量完成 MySQL、Redis、Kafka 和 EMQX 双向链路联调。

  1. 前言

    本文档为物联网设备管理平台(软硬件一体化)项目的全套工程文档,涵盖项目从立项调研到测试验收的全生命周期,包含软件工程与硬件设备开发工程相关内容。结合项目定位(软硬件一体化提供商,提供多场景物联网服务),文档采用“核心内容整合、软硬件模块分离”的结构——整体项目框架、立项、可行性分析等核心内容统一整合,软件、硬件的需求分析、设计、测试等专项内容独立分节,既保证项目整体性,又兼顾软硬件开发的专业性和独立性,解决“整合与分离”的核心需求。软件技术选型、硬件设备选型可根据自身规划,直接填充至对应章节的指定位置。

    项目核心定位:作为软硬件一体化物联网服务提供商,搭建支持MQTT、TCP、HTTP等多协议设备接入的物联网设备管理平台,面向智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,为客户提供全流程软件+硬件一体化服务,实现设备接入、数据采集、远程控制、数据分析等核心功能。

    第一部分:立项文档

    1.1 项目立项报告

    1.1.1 项目名称

    物联网设备管理平台(软硬件一体化)建设项目

    1.1.2 项目发起单位/负责人

    发起单位:__________

    项目负责人:__________

    项目团队:__________(可补充开发、测试、硬件集成等核心成员)

    1.1.3 项目背景与意义

    1.1.3.1 研究背景

    随着物联网技术的快速普及,各行业设备联网规模呈现指数级增长,从工业生产线上的传感器、智能工厂的PLC控制器,到消费领域的智能家居终端,设备类型日益复杂,连接需求愈发多样。当前市场中,多数物联网服务存在“软件与硬件脱节”“设备接入协议单一”“场景适配性差”等痛点,传统设备管理模式依赖人工巡检,故障响应滞后,数据分散存储难以形成有效价值,且不同厂商设备协议差异大,系统集成难度高,常出现“哑设备”现象。同时,智能家居、智慧农业、智能工厂等领域对物联网服务的需求持续升级,客户亟需“一站式”软硬件一体化解决方案,而非单独采购软件平台与硬件设备后自行整合,这为软硬件一体化物联网服务提供商提供了广阔的市场空间。

    在此背景下,我们计划搭建物联网设备管理平台,支持MQTT、TCP、HTTP等多协议设备接入,整合自主研发/选型的硬件设备,为各行业客户提供从设备部署、数据采集到远程控制、数据分析的全流程服务,解决行业痛点,满足市场需求。

    1.1.3.2 项目意义

    1. 商业意义:立足软硬件一体化定位,填补市场“一站式”物联网服务空白,拓展智能家居、智慧农业、智能工厂等多场景客户群体,打造差异化竞争优势,实现商业价值变现;

    2. 技术意义:整合多协议接入技术、软硬件协同技术,形成可复用、可扩展的物联网设备管理体系,提升自身技术积累,为后续场景拓展奠定基础;

    3. 行业意义:助力各行业客户实现设备智能化管理,降低运维成本、提升数据利用价值,推动传统行业数字化转型,契合国家数字经济与物联网产业发展战略。

    1.1.4 项目目标

    1.1.4.1 总体目标

    搭建一套稳定、高效、可扩展的物联网设备管理平台,实现软硬件深度协同,支持多协议设备接入、多场景服务落地,成为专业的物联网软硬件一体化服务提供商,满足客户在设备管理、数据采集、远程控制等方面的核心需求,提升客户满意度与市场占有率。

    1.1.4.2 阶段性目标

    1. 立项调研阶段(1-2周):完成市场调研、技术调研,确定软硬件选型方案,完善可行性分析;

    2. 需求分析与设计阶段(3-4周):完成软件、硬件的需求分析,完成概要设计、详细设计,输出设计文档;

    3. 开发实现阶段(8-10周):完成软件平台开发、硬件设备选型与集成,实现软硬件协同联调;

    4. 测试验收阶段(2-3周):完成软件、硬件及系统集成测试,修复问题,通过验收,输出测试报告;

    5. 上线部署阶段(1-2周):完成平台上线、硬件部署,提供客户培训与技术支持,进入运维阶段。

    1.1.5 项目范围

    1. 软件范围:物联网设备管理平台开发,包括设备接入模块、数据采集与存储模块、远程控制模块、数据分析模块、用户管理模块、权限管理模块等,支持MQTT、TCP、HTTP等多协议接入;

    2. 硬件范围:硬件设备选型、集成与调试,包括温湿度传感器、控制终端、通信模块等,适配多场景部署,与软件平台实现无缝对接;

    3. 服务范围:为客户提供软硬件一体化部署、调试、培训、售后技术支持,覆盖智能家居、智慧农业、智能工厂等核心场景;

    4. 排除范围:不涉及硬件设备的核心芯片自主研发(仅做选型与集成),不涉及第三方平台的二次开发(除非客户特殊需求)。

    1.1.6 项目资源需求

    1. 人力资源:软件开发工程师、硬件工程师、测试工程师、产品经理、项目管理人员、运维工程师;

    2. 硬件资源:测试用硬件设备(传感器、控制终端、通信模块等)、服务器、网络设备;

    3. 软件资源:开发工具、测试工具、数据库、操作系统、协议调试工具等;

    4. 资金资源:研发资金、硬件采购资金、测试资金、培训资金等。

    1.1.7 立项审批意见

    审批人:__________

    审批意见:__________

    审批日期:__________

第二部分:可行性分析报告

2.1 概述

本报告针对物联网设备管理平台(软硬件一体化)项目,从市场、技术、经济、操作、风险五个维度进行可行性分析,判断项目是否具备实施条件,为项目决策提供科学依据。本次分析基于当前市场环境、技术水平及自身资源,结合项目目标与范围,确保分析结果真实、可靠、具有指导性。

2.2 市场可行性分析

  1. 市场需求:随着物联网技术在各行业的渗透,智能家居、智慧农业、智能工厂等领域对设备管理平台的需求持续增长,客户对“软硬件一体化”服务的需求日益迫切,避免了单独采购软硬件的整合成本与技术壁垒,市场空间广阔;

  2. 市场竞争力:当前市场中,多数物联网服务提供商要么只做软件平台,要么只做硬件设备,软硬件一体化提供商较少,项目凭借“多协议接入”“多场景适配”“一站式服务”的优势,可形成差异化竞争,契合市场需求;

  3. 市场前景:物联网产业处于快速发展阶段,政策支持力度大,各行业数字化转型加速,未来对物联网设备管理平台及软硬件一体化服务的需求将持续提升,项目具有良好的市场前景和可持续性。

2.3 技术可行性分析

  1. 技术成熟度:MQTT、TCP、HTTP等设备接入协议已成为物联网领域的主流协议,技术成熟、应用广泛;软件平台开发(如设备管理、数据存储、远程控制)、硬件设备选型与集成技术均已成熟,不存在难以突破的技术壁垒;

  2. 技术储备:项目团队已明确软件技术选型与硬件设备选型方案,具备相关的开发、集成、测试技术能力,可支撑项目顺利实施;

  3. 软硬件协同:采用成熟的软硬件协同技术,通过标准化接口实现软件平台与硬件设备的无缝对接,可确保数据传输稳定、控制指令高效执行,解决软硬件脱节问题;

  4. 可扩展性:软件平台采用模块化设计,硬件设备支持灵活替换与扩展,可根据后续市场需求,快速适配新的场景、新的设备类型,技术扩展性强。

2.4 经济可行性分析

  1. 成本估算:项目成本主要包括硬件采购成本、研发成本、人力成本、测试成本、培训成本、运维成本等,结合项目规模与阶段性目标,成本可控,可通过合理规划优化成本;

  2. 收益预测:项目收益主要来自软硬件一体化服务收费、设备销售、售后运维服务等,随着客户群体的拓展,收益将逐步提升,预计在项目上线后1-2年内实现盈利;

  3. 投资回报:综合成本与收益分析,项目投资回报率合理,风险可控,具备良好的经济可行性,符合企业长期发展战略。

2.5 操作可行性分析

  1. 团队能力:项目团队具备软件开发、硬件集成、测试、项目管理等相关能力,可熟练完成项目各阶段工作,确保项目顺利推进;

  2. 操作难度:软件平台操作界面简洁、易用,客户可快速掌握设备接入、数据查看、远程控制等操作;硬件设备部署简单、调试便捷,可适应不同场景的部署需求;

  3. 运维保障:制定完善的运维方案,配备专业的运维工程师,可及时处理软件故障、硬件故障,保障平台与设备的稳定运行,降低操作与运维难度。

2.6 风险可行性分析

2.6.1 潜在风险

  1. 技术风险:软硬件协同过程中可能出现兼容性问题,设备接入过程中可能出现协议适配问题,影响平台稳定性;

  2. 市场风险:市场需求变化过快,或竞争对手推出同类产品,影响项目市场占有率;

  3. 成本风险:硬件采购价格波动、研发成本超支,导致项目成本增加;

  4. 进度风险:项目各阶段工作推进滞后,影响项目上线时间。

2.6.2 风险应对措施

  1. 技术风险:提前进行技术调研与测试,完善软硬件选型方案,加强软硬件协同联调,建立技术问题应急处理机制,及时解决兼容性、协议适配等问题;

  2. 市场风险:持续关注市场需求变化,加强市场调研,及时优化产品与服务,打造差异化优势,加强客户维护,提升客户粘性;

  3. 成本风险:合理规划采购计划,与供应商签订长期合作协议,控制硬件采购成本;优化研发流程,避免研发成本超支,建立成本监控机制;

  4. 进度风险:制定详细的项目进度计划,明确各阶段工作任务与时间节点,加强项目进度监控,及时调整工作安排,确保项目按时推进。

2.7 可行性结论

综合市场、技术、经济、操作、风险五个维度的分析,本项目市场需求明确、技术成熟、成本可控、风险可应对,具备完全的可行性,建议立项实施。

第三部分:需求分析文档

本章节采用“整体需求+软件需求+硬件需求”的结构,整体需求明确项目核心诉求,软件、硬件需求分别独立阐述,兼顾整合性与独立性,确保需求清晰、可落地、可验证。

3.1 整体需求

  1. 多协议接入需求:支持MQTT、TCP、HTTP等多种设备接入协议,实现不同类型、不同厂商硬件设备的快速接入,解决设备接入标准化问题;

  2. 软硬件协同需求:软件平台与硬件设备无缝对接,实现数据实时采集、远程控制指令高效下发,确保软硬件协同稳定运行;

  3. 多场景适配需求:适配智能家居、温湿度数据上报、智慧农业、智能工厂等多场景,提供针对性的软硬件解决方案;

  4. 核心功能需求:实现设备管理、数据采集与存储、远程控制、数据分析、用户管理、权限管理等核心功能;

  5. 性能需求:平台运行稳定,数据传输延迟低,设备接入容量可扩展,硬件设备运行可靠、功耗合理;

  6. 易用性需求:软件平台操作简洁,硬件设备部署、调试便捷,客户可快速上手,降低使用成本;

  7. 安全性需求:保障设备接入安全、数据传输安全、数据存储安全,防止非法接入、数据泄露、指令篡改。

3.2 软件需求(独立模块)

3.2.1 功能需求

3.2.1.1 设备接入模块

  1. 支持MQTT、TCP、HTTP三种核心协议接入,可扩展其他物联网协议;

  2. 支持设备批量接入与单个接入,提供设备接入指引与配置工具;

  3. 实现设备接入验证(设备ID、密钥验证),防止非法设备接入;

  4. 支持设备在线状态监测,实时显示设备接入状态(在线、离线、异常),异常状态及时提醒;

  5. 支持设备接入日志记录,可查询设备接入时间、接入协议、接入状态等信息。

3.2.1.2 数据采集与存储模块

  1. 实时采集硬件设备上传的数据(如温湿度、设备运行参数、状态数据等),采集频率可配置;

  2. 支持数据格式解析与转换,确保不同设备的数据统一格式存储;

  3. 采用可靠的数据库存储数据,支持历史数据查询、导出,数据存储期限可配置;

  4. 支持数据异常检测,当数据超出预设阈值时,触发异常提醒;

  5. 保障数据传输过程中的完整性,防止数据丢失、篡改。

3.2.1.3 远程控制模块

  1. 支持通过软件平台向硬件设备下发控制指令(如开关控制、参数调节等);

  2. 实时反馈指令执行结果,显示设备执行状态;

  3. 支持控制指令日志记录,可查询指令下发时间、指令内容、执行结果;

  4. 支持批量控制多个设备,提高操作效率;

  5. 当指令执行失败时,提供失败原因提示,并支持重试功能。

3.2.1.4 数据分析模块

  1. 支持对采集的数据进行统计分析(如平均值、最大值、最小值、趋势分析等);

  2. 提供数据可视化展示(图表、报表等),便于客户直观查看数据变化;

  3. 支持自定义分析规则,根据客户需求生成针对性的分析报告;

  4. 针对不同场景(如智慧农业的土壤湿度分析、智能工厂的设备运行效率分析)提供专属分析功能。

3.2.1.5 用户管理与权限管理模块

  1. 支持用户注册、登录、密码重置、账号注销等功能;

  2. 支持用户信息管理(修改个人信息、绑定联系方式等);

  3. 支持权限分级管理,不同角色(管理员、普通用户、运维人员)拥有不同的操作权限;

  4. 管理员可管理所有用户、设备、数据,普通用户仅可查看自身绑定的设备与数据,运维人员可进行设备调试与故障处理。

3.2.1.6 系统管理模块

  1. 支持系统参数配置(如数据采集频率、异常阈值、存储期限等);

  2. 支持系统日志记录,可查询操作日志、设备日志、异常日志等;

  3. 支持系统升级与维护,确保系统稳定运行;

  4. 支持数据备份与恢复,防止数据丢失。

3.2.2 非功能需求

  1. 性能需求:平台响应时间≤1s,数据传输延迟≤500ms;支持至少1000台设备同时在线接入,可扩展至10000台以上;系统可用性≥99.9%;

  2. 安全性需求:采用加密技术(如SSL/TLS)保障数据传输安全;设备接入采用密钥验证,防止非法接入;数据存储加密,防止数据泄露;定期进行安全检测,及时修复安全漏洞;

  3. 可扩展性需求:采用模块化设计,支持功能模块扩展(如新增协议接入、新增分析功能);支持设备类型扩展,可适配新的硬件设备;

  4. 易用性需求:操作界面简洁、直观,导航清晰,客户可快速掌握操作方法;提供操作指引与帮助文档;

  5. 兼容性需求:支持Windows、Linux等主流操作系统,支持Chrome、Edge等主流浏览器;

  6. 可维护性需求:系统日志清晰,便于问题排查;模块之间低耦合,便于维护与升级。

3.2.3 接口需求

  1. 设备接入接口:支持MQTT、TCP、HTTP协议接口,用于设备与平台的数据交互;

  2. 硬件对接接口:提供标准化接口,用于软件平台与硬件设备的协同对接,支持数据采集与指令下发;

  3. 内部接口:各模块之间的接口,确保模块之间的数据交互顺畅;

  4. 外部接口(可选):提供API接口,支持与第三方平台对接,实现数据共享。

3.3 硬件需求(独立模块)

3.3.1 硬件设备类型及需求

3.3.1.1 感知设备

  1. 温湿度传感器:用于采集环境温湿度数据,精度≥±0.5℃(温度)、±5%RH(湿度);支持低功耗运行;支持MQTT/TCP/HTTP协议;适配室内外多种场景;

  2. 其他感知设备(可选):根据场景需求,配置土壤湿度传感器、光照传感器、压力传感器等,要求精度达标、运行稳定、支持对应协议。

3.3.1.2 控制终端

  1. 用于接收软件平台下发的控制指令,执行相应操作(如开关控制、参数调节);

  2. 支持与感知设备、通信模块对接,实现数据采集与指令执行;

  3. 运行稳定,响应迅速,指令执行延迟≤300ms;

  4. 支持低功耗模式,适配不同供电场景(市电、电池)。

3.3.1.3 通信模块

  1. 支持MQTT、TCP、HTTP三种核心协议,可实现设备与软件平台的数据传输;

  2. 通信稳定,信号强度强,支持远距离传输(根据场景需求,可选择Wi-Fi、4G/5G、LoRa等通信方式);

  3. 低功耗、小体积,便于部署;

  4. 支持自动重连功能,当网络中断后,可自动重新连接平台。

3.3.1.4 辅助设备

  1. 电源设备:为感知设备、控制终端、通信模块提供稳定供电,支持市电、太阳能、电池等多种供电方式;

  2. 部署支架:用于设备固定,适配室内外部署场景,防水、防尘、抗干扰。

3.3.2 硬件性能需求

  1. 运行可靠性:硬件设备平均无故障运行时间(MTBF)≥10000小时;

  2. 环境适应性:适应温度范围-20℃~60℃,湿度范围10%~90%RH;防水、防尘、抗电磁干扰;

  3. 功耗需求:感知设备、通信模块采用低功耗设计,电池供电模式下,续航时间≥6个月;

  4. 数据采集精度:各类传感器的数据采集精度符合行业标准,确保数据准确性;

  5. 兼容性:硬件设备之间可无缝对接,与软件平台通过标准化接口对接,支持协议适配。

3.3.3 硬件接口需求

  1. 通信接口:支持UART、SPI、I2C等常用接口,用于与通信模块、感知设备对接;

  2. 供电接口:标准化供电接口,支持不同供电方式接入;

  3. 扩展接口:预留扩展接口,便于后续新增设备或功能扩展。

3.3.4 硬件部署需求

  1. 部署便捷:设备体积小、重量轻,便于安装与部署,无需复杂施工;

  2. 维护便捷:设备支持远程调试、固件升级,减少现场维护工作量;

  3. 场景适配:根据智能家居、智慧农业、智能工厂等不同场景,提供对应的部署方案,确保设备运行稳定。

3.4 需求确认

需求提出人:__________

需求确认人:__________

确认日期:__________

第四部分:概要设计文档

本章节延续“整体概要设计+软件概要设计+硬件概要设计”的结构,整体设计明确项目架构框架,软件、硬件概要设计分别独立阐述,明确各模块的核心设计思路、接口设计、模块间交互关系,为详细设计奠定基础。

4.1 整体概要设计

4.1.1 项目架构设计

项目采用“云-边-端”协同的四层架构,实现软硬件一体化协同运行,具体架构如下:

  1. 感知层:由各类硬件设备组成(感知设备、控制终端、通信模块等),负责数据采集与指令执行,是项目的“终端入口”;

  2. 边缘层:部署在设备侧的边缘计算节点,承担协议转换、数据预处理、本地规则计算等功能,减少网络带宽占用,实现本地故障快速响应;

  3. 平台层:即物联网设备管理软件平台,包含各类核心功能模块,负责设备管理、数据处理、远程控制、数据分析等,是项目的“核心大脑”;

  4. 应用层:面向不同场景的可视化界面与服务,为客户提供设备操作、数据查看、分析报告等服务,适配智能家居、智慧农业、智能工厂等多场景。

4.1.2 软硬件协同架构

软件平台与硬件设备通过标准化接口实现协同,具体交互流程如下:

  1. 一期硬件设备通过 MQTT 接入 EMQX 并完成设备身份验证;TCP/HTTP 作为后续协议适配器,不直接进入业务单体;

  2. 感知设备采集数据后上传 EMQX,EMQX 规则/Bridge 转发到 Kafka 上行 Topic,Spring Boot 只消费 Kafka 并完成解析、存储与分析;

  3. Spring Boot 将控制指令发布到 Kafka 下行 Topic,由 EMQX Bridge 转换为 MQTT 指令;设备 ACK 经 EMQX/Kafka 回到单体并更新状态;

  4. 边缘层负责数据预处理与本地决策,当出现紧急情况时,可直接控制硬件设备,同时将相关信息上报至软件平台。

4.1.3 设计原则

  1. 模块化设计:软件、硬件均采用模块化设计,便于功能扩展、维护与升级;

  2. 标准化设计:接口、协议采用行业标准,确保软硬件兼容性与可扩展性;

  3. 稳定性设计:优先选择成熟技术与设备,确保系统与设备运行稳定,降低故障发生率;

  4. 安全性设计:融入安全设计理念,保障设备接入、数据传输、存储的安全性;

  5. 易用性设计:兼顾软件操作与硬件部署的易用性,降低客户使用与维护成本。

4.2 软件概要设计(独立模块)

4.2.1 软件架构设计

软件平台当前采用 Spring Boot 模块化单体,从上至下分为应用层、业务逻辑层、数据访问层、数据存储层,各层同进程部署、按领域模块隔离,具体如下:

  1. 应用层:面向用户的操作界面,包括设备管理界面、数据查看界面、远程控制界面、数据分析界面等,负责用户交互;

  2. 业务逻辑层:核心业务处理层,包含设备接入、数据采集、远程控制、数据分析、用户管理等功能模块,负责业务逻辑处理;

  3. 数据访问层:负责与数据存储层交互,实现数据的查询、新增、修改、删除等操作,为业务逻辑层提供数据支持;

  4. 数据存储层:采用数据库存储各类数据(设备数据、用户数据、日志数据等),确保数据安全、可靠。

4.2.2 核心模块设计

4.2.2.1 设备接入模块

  1. 模块功能:负责设备接入验证、协议解析、在线状态监测、接入日志记录;

  2. 核心逻辑:设备发起接入请求→模块验证设备身份(设备ID、密钥)→验证通过后,根据接入协议解析数据→记录接入日志,更新设备在线状态;

  3. 依赖模块:数据访问层(存储设备信息、接入日志)、数据采集模块(接收设备上传的数据)。

4.2.2.2 数据采集与存储模块

  1. 模块功能:接收设备上传的数据、解析数据格式、存储数据、异常数据检测、数据备份与恢复;

  2. 核心逻辑:接收设备数据→解析数据格式(统一为标准格式)→检测数据是否异常→将正常数据存储至数据库,异常数据触发提醒并记录→定期进行数据备份;

  3. 依赖模块:设备接入模块(获取设备数据)、数据访问层(存储数据)、数据分析模块(提供数据支持)。

4.2.2.3 远程控制模块

  1. 模块功能:下发控制指令、接收指令执行结果、记录指令日志、指令重试;

  2. 核心逻辑:用户发起控制指令→模块生成标准化指令→通过通信接口下发至设备→接收设备执行结果→记录指令日志,若执行失败则提醒并支持重试;

  3. 依赖模块:设备接入模块(获取设备在线状态)、数据访问层(存储指令日志)。

4.2.2.4 数据分析模块

  1. 模块功能:数据统计分析、数据可视化、自定义分析规则、生成分析报告;

  2. 核心逻辑:从数据存储层获取数据→根据预设规则或自定义规则进行统计分析→生成可视化图表与分析报告→提供数据查询与导出功能;

  3. 依赖模块:数据采集与存储模块(获取数据)、数据访问层(存储分析结果)。

4.2.2.5 用户管理与权限管理模块

  1. 模块功能:用户注册、登录、信息管理、权限分配、角色管理;

  2. 核心逻辑:用户注册/登录→验证用户信息→根据角色分配权限→用户可修改个人信息,管理员可管理用户与权限;

  3. 依赖模块:数据访问层(存储用户信息、权限信息)。

4.2.2.6 系统管理模块

  1. 模块功能:系统参数配置、日志管理、系统升级、数据备份与恢复;

  2. 核心逻辑:管理员配置系统参数→模块记录各类日志→支持系统在线升级→定期进行数据备份,出现异常时可恢复数据;

  3. 依赖模块:数据访问层(存储系统参数、日志数据、备份数据)。

4.2.3 接口设计

4.2.3.1 设备接入接口

  1. MQTT 接口:设备只连接 EMQX,生产使用 TLS 端口和设备级 ACL;具体端口以中间件地址文档为准;

  2. Kafka 接口:后端订阅 telemetry/event/command.ack,发布 command.down;Key 为 deviceId,Value 为版本化 JSON;

  3. TCP/HTTP 设备接入属于后续独立适配层,适配后仍写入同一 Kafka 上行 Topic,业务单体不维护设备长连接。

4.2.3.2 内部模块接口

  1. 设备接入模块→数据采集模块:提供设备数据接口,传递设备上传的数据;

  2. 远程控制模块→设备接入模块:提供指令下发接口,传递控制指令;

  3. 各模块→数据访问层:提供数据查询、新增、修改、删除接口,实现数据交互。

4.2.3.3 外部接口(可选)

提供RESTful API接口,支持与第三方平台对接,数据格式:JSON,采用API密钥验证,确保接口安全。

4.2.4 数据存储设计

  1. 数据库选型:MySQL,使用 Flyway 管理结构、Spring Data JPA 访问;初期遥测同库,达到容量阈值后独立时序存储;

  2. 数据分类存储:

(1)设备数据:存储设备ID、设备类型、接入协议、在线状态、部署位置等信息;

(2)采集数据:存储设备上传的温湿度、运行参数等数据,按时间戳排序;

(3)用户数据:存储用户账号、密码(加密存储)、个人信息、角色权限等信息;

(4)日志数据:存储设备接入日志、操作日志、指令日志、异常日志等信息;

(5)系统数据:存储系统参数、配置信息、备份数据等信息。

4.2.5 技术选型补充

  1. 开发语言:后端 Java 17;管理后台 TypeScript;小程序 JavaScript;

  2. 前端框架:Vue 3 + TypeScript + Vite + Pinia + Vue Router;

  3. 后端形态:Spring Boot 3 模块化单体,后续按真实边界拆分微服务;

  4. 中间件:MySQL、Redis(登录会话/缓存)、Kafka(设备消息边界)、EMQX(MQTT Broker 与 Kafka Bridge)。

4.3 硬件概要设计(独立模块)

4.3.1 硬件整体架构设计

硬件系统由感知层设备、控制终端、通信模块、辅助设备组成,各设备通过标准化接口对接,形成完整的硬件体系,具体架构如下:

  1. 感知层:由温湿度传感器、其他场景化传感器组成,负责采集环境与设备数据;

  2. 控制层:由控制终端组成,负责接收软件平台指令,控制感知设备与执行器;

  3. 通信层:由通信模块组成,负责实现硬件设备与软件平台的数据传输与指令交互;

  4. 辅助层:由电源设备、部署支架等组成,为硬件系统提供供电与部署支持。

4.3.2 核心硬件设备设计

4.3.2.1 感知设备设计

  1. 温湿度传感器:

(1)核心组件:传感器芯片、数据处理单元、接口单元;

(2)工作原理:传感器芯片采集温湿度数据,经数据处理单元转换为标准格式,通过接口单元传输至控制终端;

(3)选型补充:__________(根据自身硬件选型填写传感器型号、厂商等)。

4.3.2.2 控制终端设计

  1. 核心组件:主控芯片、接口单元、执行单元、电源单元;

  2. 工作原理:主控芯片接收通信模块传输的控制指令,控制执行单元执行相应操作,同时通过接口单元获取感知设备的数据,上传至通信模块;

  3. 选型补充:__________(根据自身硬件选型填写主控芯片型号、厂商等)。

4.3.2.3 通信模块设计

  1. 核心组件:通信芯片、天线、接口单元、电源单元;

  2. 工作原理:通过通信芯片实现与软件平台的协议对接,接收平台指令并传输至控制终端,同时将控制终端上传的数据传输至平台;支持自动重连功能;

  3. 选型补充:__________(根据自身硬件选型填写通信模块型号、厂商、通信方式等)。

4.3.2.4 辅助设备设计

  1. 电源设备:采用市电+电池双供电模式,确保供电稳定;电池采用锂电池,支持充电与低功耗保护;

  2. 部署支架:采用防水、防尘、抗干扰设计,适配室内外部署,便于安装与固定。

4.3.3 硬件接口设计

  1. 感知设备与控制终端接口:采用UART接口,用于数据传输,波特率:9600bps;

  2. 控制终端与通信模块接口:采用SPI接口,用于指令与数据传输;

  3. 电源接口:采用DC 5V接口,支持市电与电池接入;

  4. 扩展接口:预留I2C接口,用于后续新增设备扩展。

4.3.4 硬件协同设计

  1. 数据传输流程:感知设备采集数据→通过UART接口传输至控制终端→控制终端处理数据→通过SPI接口传输至通信模块→通信模块通过MQTT/TCP/HTTP协议上传至软件平台;

  2. 指令执行流程:软件平台下发指令→通信模块接收指令→通过SPI接口传输至控制终端→控制终端解析指令→控制执行单元执行操作→将执行结果反馈至软件平台;

  3. 异常处理:当硬件设备出现故障时,控制终端触发异常提醒,通过通信模块上传至软件平台,同时启动备用机制(如备用电源、本地控制),确保业务连续性。

4.4 概要设计评审

评审人:__________

评审意见:__________

评审日期:__________

第五部分补充:关键系统设计细节

1. 领域边界

领域实体/聚合负责不负责
产品Product、ThingModelVersion产品能力定义和版本设备当前值
设备Device、Credential、DeviceShadow身份、归属、运行态用户会话
接入Connection、Telemetry、DeviceEvent协议适配、校验、标准化业务页面
控制Command、CommandAck指令生命周期、超时、重试直接修改历史遥测
身份User、Role、Tenant、Binding认证、授权、归属MQTT 会话
运维Alert、AuditLog、WorkOrder告警、审计、履约产品物模型定义

一期将这些领域放在同一 Spring Boot 单体中,通过 package、应用服务和消息 DTO 保持边界;当吞吐、发布或团队边界稳定后再拆为微服务。

2. 设备生命周期

planned → registered → activated → online/offline → disabled → retired
              │            │
              └─ unbound ──┴─ bound → transfer_pending → bound
  • registered:平台已经生成设备身份,但设备可能从未联网。
  • activated:设备首次通过身份验证;该动作不能与用户绑定混为一谈。
  • online/offline:瞬时连接状态,应结合 Broker 连接事件和心跳 TTL 判断。
  • bound:设备拥有终端用户归属;后台运维权限不等于所有权。
  • disabled/retired:禁止新连接或指令,但历史数据和审计保留。

当前 MVP 用 status + ownerId + lastSeenAt 表达核心子集,生产库应拆成生命周期状态、连接状态和绑定状态三个字段。

3. 设备身份与绑定码

设备密钥和绑定码用途不同:设备密钥证明“这台设备是谁”,绑定码授权“一次用户归属操作”。生产实现要求:

  1. 设备密钥由安全随机数生成,烧录或产线注入,密文/哈希保存,支持轮换和吊销。
  2. MQTT 鉴权至少校验 tenant/product/device,主题 ACL 限制设备只能访问自身 Topic。
  3. 绑定码不复用设备密钥;可一次性、可过期、可由管理员重置,并对连续失败限流。
  4. 绑定事务以设备记录加锁或条件更新:owner_id IS NULL → owner_id = current_user;受影响行数为零则返回冲突。
  5. 用户侧读取设备时始终追加 owner_id = current_user,对越权对象返回统一的不存在响应。

4. MQTT 主题和消息信封

上行属性: iot/v1/{tenantId}/{productKey}/{deviceId}/property/post
上行事件: iot/v1/{tenantId}/{productKey}/{deviceId}/event/{eventKey}/post
下行指令: iot/v1/{tenantId}/{productKey}/{deviceId}/command/get
指令回执: iot/v1/{tenantId}/{productKey}/{deviceId}/command/reply

统一信封:

{
  "messageId": "01J...",
  "deviceId": "d-123",
  "timestamp": 1789142400000,
  "version": "1.0",
  "data": { "power": true }
}

服务端以 (device_id, message_id) 去重;时间戳只用于事件时间,不用于认证的唯一依据;无效物模型字段进入死信/错误流,不直接污染设备影子。

后端不直接处理上述 MQTT Topic。EMQX Bridge 将其映射到 Kafka:

Kafka Topic方向后端动作
iot.device.telemetry.upEMQX → Spring Boot订阅、幂等保存遥测、更新在线态
iot.device.event.upEMQX → Spring Boot订阅并转换为告警
iot.device.command.downSpring Boot → EMQX发布设备控制指令
iot.device.command.ackEMQX → Spring Boot订阅并更新指令最终状态

Kafka Key 固定为 deviceId;同设备消息进入同一分区,Consumer Group 为 iot-platform-monolith(可配置)。

5. 指令状态机和一致性

pending ──publish──> sent ──ACK(success)──> succeeded
   │                   ├──ACK(error)─────> failed
   │                   └──deadline───────> timeout
   └──cancel─────────────────────────────> canceled

当前单体先写入 device_command(PENDING),随后由 KafkaCommandPublisher 发布 command.down 并更新为 SENT;KafkaInboundListener 消费 command.ack 后更新为 SUCCEEDED/FAILED。HTTP 请求成功只代表指令进入 Kafka 链路,不代表设备执行成功。

生产增强采用 Transactional Outbox:在同一 MySQL 事务写入 device_command 与 outbox_event;发布器发送成功后标记 Outbox。重复发布由设备/服务端按 commandId 幂等;状态条件更新防止迟到 ACK 覆盖超时状态。

6. 设备影子合并规则

影子至少包含:

{
  "desired": { "power": true },
  "reported": { "power": false },
  "delta": { "power": true },
  "desiredVersion": 12,
  "reportedVersion": 11,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}
  • 用户控制先写 desired 和新版本;设备 ACK/属性上报更新 reported。
  • delta 是 desired 与 reported 的差异,不作为独立事实源。
  • 遥测历史只追加,不随影子覆盖;影子更新必须校验版本,防止乱序消息回退状态。

7. 授权规则

接口类型身份附加约束
管理员 APIADMIN/OPERATOR产品创建和审计仅 ADMIN
小程序设备 APIUSERdevice.ownerId === user.id
设备 MQTTdevice由 EMQX 验证设备凭据和 Topic ACL;后端仅信任授权 Kafka Topic
企业 APItenant member/service accounttenant_id 强制过滤 + scope

前端菜单隐藏只改善体验,后端仍对每个接口授权。审计记录操作主体、动作、对象、结果、来源 IP/客户端和关联 ID;MVP 已实现主体、动作、对象、详情和时间。

8. 当前 MySQL 表与后续归属

当前表后续模块/存储
iam_userIAM 服务;二期增加 tenant/member/role_binding
iot_product产品/物模型模块
iot_device设备资产;后续拆 credential/binding/shadow
device_telemetry初期 MySQL;达到容量阈值后迁移时序存储
device_command控制模块;增加 attempt/outbox
device_alert告警模块;增加 transition/notification
audit_log只追加审计存储

Flyway 管理结构版本,JPA 运行时使用 ddl-auto=validate,禁止在共享环境自动改表。未来拆服务时按表所有权迁移,不允许多个微服务直接写同一业务表。

第五部分:详细设计文档

本章节在概要设计基础上,对软件各模块、硬件各部件、数据库、接口等进行细化设计,明确具体实现逻辑、数据结构、流程细节、硬件电路与固件设计,为编码与硬件集成提供直接依据。

5.1 软件详细设计(独立模块)

5.1.1 设备接入模块详细设计

5.1.1.1 模块类设计(以Java/Spring为例)

类名职责关键方法
KafkaInboundListener消费 EMQX Bridge 写入的上行 Kafka Topictelemetry(payload), event(payload), commandAck(payload)
KafkaCommandPublisher向 Kafka 下行 Topic 发布指令publish(CommandDown)
DeviceService设备、绑定、遥测、指令和告警领域用例ingestTelemetry, sendCommand, handleCommandAck
TokenAuthenticationFilter从 Redis Token 恢复登录身份doFilterInternal
RedisSessionStore登录会话 TTL 管理save, find, delete

5.1.1.2 设备接入流程

  1. MQTT 接入:设备连接 EMQX → EMQX 校验设备凭据和 Topic ACL → 设备属性/事件触发 Rule/Bridge → 写入 Kafka 上行 Topic → Spring Boot Consumer 处理。
  2. 指令下行:Spring Boot 写 device_command → 发布 Kafka command.down → EMQX Bridge 发布设备 MQTT Topic → 设备执行并回 ACK → ACK 经 Kafka 回写指令状态。
  3. TCP/HTTP 扩展:后续独立适配器负责连接和鉴权,标准化后写入同一 Kafka Topic;业务单体不维护 TCP/MQTT 长连接。

5.1.1.3 状态管理机制

  • 使用Redis存储设备在线状态,key:device:status:{deviceId},value:online/offline,TTL:90秒(心跳超时)。
  • EMQX 将连接/断开事件经 Kafka 转发,后端结合事件和 Redis TTL 更新在线态。
  • MySQL 保存最终设备状态与最后活跃时间,Redis 只保存可重建的短时在线缓存。

5.1.2 数据采集与存储模块详细设计

5.1.2.1 数据采集流程

  1. 设备上报 → EMQX → Kafka iot.device.telemetry.up → KafkaInboundListener → 格式校验 → 解析为标准 JSON:

    json

    {
      "deviceId": "xxx",
      "timestamp": 1700000000000,
      "data": {"temperature": 25.6, "humidity": 60}
    }
    
  2. 异常检测:根据预设阈值(如温度>50℃)触发告警,写入告警表。

  3. 存储策略:一期遥测、设备配置和业务元数据写入 MySQL;达到容量阈值后遥测迁移专用时序存储,业务表继续使用 MySQL。

5.1.2.2 数据清理与备份

  • 原始数据保存30天,自动转存到冷存储(对象存储)或删除。
  • 每日凌晨2点执行数据备份(全量+增量),备份保留7天。

5.1.3 远程控制模块详细设计

5.1.3.1 控制指令下发流程

  1. 用户在 Vue 管理后台或小程序发起控制 → 调用 POST /api/admin/commands 或用户设备指令接口。
  2. 后端生成 commandId → 写 device_command,状态为 PENDING。
  3. KafkaCommandPublisher 发布 iot.device.command.down,成功后状态为 SENT。
  4. EMQX Bridge 将 Kafka 消息发布到设备 MQTT Topic。
  5. 设备 ACK 经 iot.device.command.ack 返回,后端更新为 SUCCEEDED/FAILED。
  6. 一期增强加入 Outbox、10 秒超时、最多 3 次重试、失败告警和迟到 ACK 保护。

5.1.3.2 批量控制设计

  • 支持选择多个设备 → 后台并发调用单设备控制逻辑,使用线程池(最大10线程)。
  • 记录批量任务ID,可查询每个设备的执行结果。

5.1.4 用户管理与权限模块详细设计

5.1.4.1 权限模型(RBAC)

  • 表结构:user、role、permission、user_role、role_permission
  • 预置角色:
    • 管理员:所有权限
    • 普通用户:仅查看自己设备的实时数据及历史数据
    • 运维人员:设备调试、固件升级、故障日志查看

5.1.4.2 认证与授权

  • 登录生成不透明 Token,存入 Redis iot:session:{token},默认 TTL 24 小时。
  • 接口权限使用 Spring Security @PreAuthorize,当前角色为 ADMIN、OPERATOR、USER。
  • 设备级权限:用户与设备通过 iot_device.owner_id 关联,查询和控制时强制过滤。

5.1.5 接口详细设计(RESTful API示例)

5.1.5.1 设备注册接口

text

POST /api/admin/devices
Request Body: { "deviceName": "sensor_01", "serialNumber": "SN001", "productId": "p_xxx" }
Response: { "code": 0, "data": { "id": "d_xxx", "deviceSecret": "仅创建时返回", "bindCode": "IOT-SN001" } }

5.1.5.2 数据查询接口

text

GET /api/mini/devices/{deviceId}
Response: { "code": 0, "data": { "id": "d_xxx", "properties": {...}, "telemetry": [...] } }

5.1.5.3 控制指令接口

text

POST /api/admin/commands
Request: { "deviceId": "d_xxx", "name": "setPower", "params": { "power": false } }
Response: { "code": 0, "data": { "id": "cmd_xxx", "status": "SENT" } }

5.2 硬件详细设计(独立模块)

5.2.1 感知设备详细设计(以温湿度传感器为例)

5.2.1.1 硬件选型(示例)

组件型号/规格说明
传感器芯片SHT30精度:±0.3℃ / ±2%RH
主控MCUESP32-C3支持Wi-Fi/BLE,低功耗
通信模块内置Wi-Fi支持MQTT/TCP
电源3.7V锂电池 + 充电管理TP4056续航约6个月(每小时上报一次)

5.2.1.2 电路连接

  • SHT30的SCL→ESP32的IO22,SDA→IO21,VCC→3.3V,GND→GND
  • 电池正极→TP4056的BAT+,TP4056的OUT+→ESP32的VIN
  • 预留UART0作为调试口

5.2.1.3 固件设计

  • 采用Arduino/ESP-IDF开发
  • 主循环:读取传感器(每10秒一次)→ 平均值计算(每分钟)→ 通过MQTT上报 → 进入深度睡眠(剩余时间)
  • 上报频率可远程配置(默认60秒)
  • 支持OTA升级

5.2.2 控制终端详细设计

5.2.2.1 硬件组成

  • 主控:STM32F103C8T6
  • 继电器模块(控制220V设备)
  • 通信接口:SPI接ESP8266(透传MQTT)
  • 本地存储:AT24C02(保存设备配置)

5.2.2.2 控制逻辑

  • 监听通信模块转发的控制指令 → 解析指令类型(开关、PWM调光等)→ 驱动GPIO/继电器 → 读取传感器反馈(可选)→ 返回执行结果。

5.2.3 硬件协同时序

text

[传感器] --> UART --> [控制终端] --> SPI --> [通信模组] --> MQTT --> [平台]
[平台]   --> MQTT --> [通信模组] --> SPI --> [控制终端] --> GPIO --> [执行器]

5.3 数据库详细设计

5.3.1 关系型数据库表设计(MySQL)

5.3.1.1 设备表 device

字段类型说明
device_idVARCHAR(32) PK设备唯一标识
device_nameVARCHAR(64)设备名称
protocolENUM('MQTT','TCP','HTTP')接入协议
product_keyVARCHAR(32)产品型号
secretVARCHAR(64)设备密钥(加密存储)
statusTINYINT0-离线,1-在线
last_active_timeDATETIME最后心跳时间
created_timeDATETIME注册时间

5.3.1.2 用户表 user

字段类型说明
user_idINT AUTO PK
usernameVARCHAR(32) UNIQUE
passwordVARCHAR(128)bcrypt加密
role_idINT关联角色表
......

5.3.1.3 指令日志表 control_log

字段类型说明
command_idVARCHAR(36) PK
device_idVARCHAR(32)
commandTEXT指令内容
statusVARCHAR(16)pending/succeeded/failed
retry_countINT重试次数
create_timeDATETIME
finish_timeDATETIME

5.3.2 设备遥测表设计(MySQL)

当前单体阶段将遥测数据统一写入 MySQL 的 device_telemetry 表,避免在中间件地址和实际容量尚未确认前引入额外数据库。Kafka 上行消费者按 (device_id, message_id) 幂等落库。

字段类型说明
idVARCHAR(64) PK遥测记录 ID
message_idVARCHAR(80)消息唯一标识;与 device_id 组成唯一键
device_idVARCHAR(64)设备 ID
event_timeTIMESTAMP(6)设备事件时间
data_jsonLONGTEXT遥测属性 JSON

索引 idx_telemetry_device_time(device_id, event_time) 支持按设备和时间范围查询。数据量增长后,可保持 Kafka 消息契约和应用服务接口不变,将遥测存储模块独立拆分到专用时序存储。

第六部分:测试文档

6.1 测试计划

6.1.1 测试范围与策略

  • 单元测试:软件各模块方法级测试,覆盖率≥80%
  • 集成测试:模块间接口、软硬件协同通信
  • 系统测试:端到端功能、性能、安全性、兼容性
  • 硬件测试:传感器精度、通信距离、功耗、环境适应性

6.1.2 测试环境

  • 软件:测试服务器(4C8G)、MySQL、Redis、Kafka、EMQX、Chrome 浏览器
  • 硬件:温湿度传感器5,控制终端3,通信模组*3,可调温湿箱,直流电源

6.1.3 测试里程碑

阶段时间输出物
单元测试第9-10周单元测试报告
集成测试第11周集成测试报告
系统测试第12周系统测试报告、缺陷清单
硬件测试并行硬件测试报告
验收测试第13周验收测试报告

6.2 测试用例

6.2.1 功能测试用例(部分)

用例ID模块测试项前置条件输入/操作预期结果优先级
TC-SW-001设备接入MQTT设备正常接入平台已部署,MQTT Broker运行设备使用正确ID/Secret连接连接成功,设备状态变更为“在线”P0
TC-SW-002设备接入错误密钥拒绝同上使用错误Secret连接连接拒绝,日志记录失败P1
TC-SW-003数据采集接收并存储温湿度设备已在线设备经 EMQX→Kafka 上报温湿度数据数据在 MySQL 可查,前端显示正确P0
TC-SW-004数据采集异常数据告警设备上报温度>80℃同上系统产生告警记录,前端提示P1
TC-SW-005远程控制下发开关指令控制终端在线点击“关闭”按钮设备执行关闭,指令状态成功P0
TC-SW-006远程控制离线设备控制设备离线下发指令提示设备离线,指令状态失败P1
TC-SW-007权限管理普通用户访问其他设备用户A只绑定设备1用户A尝试查看设备2数据返回403或无权限提示P0
TC-SW-008数据分析历史数据曲线已有24小时数据选择设备、时间范围展示正确的折线图P1

6.2.2 性能测试用例

用例ID测试项负载条件指标预期结果
TC-PERF-001并发设备接入1000个MQTT设备同时连接连接成功率≥99.5%成功率达标,CPU≤70%
TC-PERF-002数据上报吞吐500设备同时每秒上报1条消息处理延迟≤500ms无积压,延迟达标
TC-PERF-003控制指令并发100个并发指令响应时间≤1s95%指令在1s内返回

6.2.3 硬件测试用例

用例ID测试项方法判定标准
TC-HW-001温度精度与标准温度计对比(0℃,25℃,50℃)误差≤±0.5℃
TC-HW-002湿度精度与标准湿度计对比(30%,60%,90%RH)误差≤±5%RH
TC-HW-003功耗测试电池供电,每小时上报一次续航≥6个月(实测计算)
TC-HW-004通信距离开阔场地测试Wi-Fi连接距离≥50米稳定连接
TC-HW-005高低温工作-20℃~60℃恒温箱运行2小时设备不宕机,数据正常

6.2.4 安全测试用例

用例ID测试项操作预期结果
TC-SEC-001未授权访问不带Token访问API返回401
TC-SEC-002SQL注入在设备ID参数中输入 ' OR '1'='1查询失败或转义,不泄露数据
TC-SEC-003通信加密抓包MQTT数据应看到TLS加密,不能明文看到密码

6.3 测试报告字段

每次发布记录版本/提交、环境、起止时间、用例总数、通过/失败/阻塞、缺陷列表、已知问题、测试负责人和结论。模板不预填虚构通过数量,实际结果写入 交付记录/VERIFICATION.txt。

6.4 当前 MVP 自动验证补充(2026-09-12)

当前结果只以 Maven、Vue 构建的实际输出及 交付记录/VERIFICATION.txt 为准。

6.4.1 自动场景

场景断言
健康检查服务为 up、结构版本正确
管理认证错误密码 401;正确密码取得令牌和管理员角色
角色授权运维读取审计日志返回 403
产品/设备产品列表可读;注册设备持久化且序列号唯一
设备遥测Kafka TelemetryUp 幂等保存并更新设备属性/在线态
设备事件Kafka EventUp 转换为待处理告警
小程序登录取得 user 令牌
绑定有效绑定码绑定成功;列表仅返回本人设备
越权用户不能读取未归属设备
控制本人设备控制创建指令并进入 Kafka SENT,越权设备拒绝
前端构建Vue TypeScript 类型检查和 Vite 生产构建通过

6.4.2 执行

cd .\物联网项目文档\iot-platform-mvp\backend
.\mvnw.cmd test

cd ..\admin-web
npm ci
npm run build

后端测试 profile 使用 H2、内存 SessionStore 和 Noop Kafka Publisher,不访问真实 MySQL/Redis/Kafka/EMQX。Spring Boot 集成测试覆盖 API、RBAC、设备归属、Kafka 消息处理和 JPA 持久化;任何断言或 TypeScript 检查失败时命令返回非零状态。

6.4.3 仍需在生产一期执行

真实微信登录、EMQX TLS/ACL、MQTT 断线重连、指令 ACK/超时/重试、乱序和重复消息、数据库故障、备份恢复、1,000 设备并发、弱网真机、固件/硬件精度、电气安全和租户隔离测试。

第七部分:部署与运维

7.1 部署单元

组件部署形式默认端口/输出
管理后台Vue/Vite 构建后由 Nginx 托管admin-web/dist
后端Spring Boot 单体 Jar8080
用户小程序微信小程序发布API 指向 HTTPS 域名
MySQL已部署中间件地址待配置文档
Redis已部署中间件地址待配置文档
Kafka已部署中间件bootstrap servers 待配置
EMQX已部署 MQTT BrokerMQTT/Dashboard 地址待配置

后端不连接 MQTT。生产网络只允许 Spring Boot 访问 MySQL、Redis、Kafka;设备访问 EMQX;EMQX Bridge 访问 Kafka。

7.2 环境变量

完整模板位于 iot-platform-mvp/backend/.env.example。收到中间件地址文档后,为开发、测试、预生产、生产分别配置,不把密码和证书提交到仓库。

MySQL

  • MYSQL_URL:JDBC URL,包含数据库名、字符集、时区和 TLS 参数。
  • MYSQL_USERNAME / MYSQL_PASSWORD:最小权限业务账号。
  • MYSQL_POOL_MAX / MYSQL_POOL_MIN:HikariCP 连接池。

Redis

  • REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_DATABASE。
  • IOT_SESSION_TTL:登录 Token TTL,默认 24 小时。
  • 若实际为 Sentinel/Cluster,在地址文档中提供节点、主节点名和 TLS/SASL 信息,再调整 Spring 配置结构。

Kafka

  • KAFKA_BOOTSTRAP_SERVERS / KAFKA_CONSUMER_GROUP。
  • KAFKA_TOPIC_TELEMETRY_UP / EVENT_UP / COMMAND_DOWN / COMMAND_ACK。
  • SASL 机制、账号、密码、TLS truststore、Topic 分区/副本和 ACL 在收到地址文档后补齐。

EMQX

EMQX_DASHBOARD_URL 仅作为运维元数据。Spring Boot 不需要 MQTT Host、ClientId 或 MQTT 用户密码。EMQX 连接器负责 Kafka 双向桥接。

7.3 构建与测试

后端

项目自带 Maven Wrapper,不要求系统安装 Maven:

cd .\iot-platform-mvp\backend
.\mvnw.cmd test
.\mvnw.cmd clean package

测试 profile 使用 H2、内存登录会话和 Noop Kafka Publisher,不连接外部中间件。Jar 输出到 target/iot-platform-2.0.0.jar。

管理后台

cd .\iot-platform-mvp\admin-web
npm ci
npm run build

Vite 生产产物输出到 dist。Nginx 对 SPA 未命中路径回退 index.html,并将 /api/ 反向代理至 Spring Boot。

7.4 启动

java -jar .\target\iot-platform-2.0.0.jar

健康检查:

Invoke-RestMethod http://127.0.0.1:8080/api/health
Invoke-RestMethod http://127.0.0.1:8080/actuator/health

生产使用独立低权限系统账户和进程管理器,配置优雅停机、启动超时、异常重启退避和日志采集。

7.5 EMQX/Kafka 联调清单

  1. 创建四个 Kafka Topic,并确认分区、副本、保留策略和后端/EMQX ACL。
  2. 配置 EMQX 上行 Rule:属性 → telemetry.up,事件 → event.up,Key=deviceId。
  3. 配置 EMQX 下行 Bridge:消费 command.down 并发布设备指令 MQTT Topic。
  4. 配置 ACK Rule:设备回执 MQTT Topic → command.ack。
  5. 使用同一 messageId 重放上行消息,MySQL 只能产生一条遥测。
  6. 下发指令后依次看到 PENDING → SENT → SUCCEEDED/FAILED。
  7. 暂停后端消费者,确认 Kafka lag 可观测;恢复后顺序消费且不重复产生副作用。
  8. 暂停 EMQX Bridge,确认指令保持 SENT 并最终进入超时/告警流程。

7.6 监控指标

链路指标
Spring BootAPI P95/P99、5xx、线程池、JVM、数据库连接池
Redis命中率、内存、连接、过期、拒绝连接、主从延迟
MySQL慢查询、锁等待、连接、磁盘、复制延迟
KafkaProducer error、Consumer lag、重平衡、ISR、磁盘
EMQX连接数、认证失败、上下行消息、丢弃、Bridge success/error
业务在线率、遥测延迟、指令成功/超时率、告警积压

7.7 备份恢复

  • MySQL 使用全量备份 + binlog,目标 RPO≤15 分钟、RTO≤60 分钟。
  • Redis 登录会话和缓存允许重建;配置持久化/副本以降低大规模用户重新登录。
  • Kafka 保留期必须覆盖最长故障恢复窗口,禁止把 Kafka 当永久业务备份。
  • EMQX 配置、规则、连接器和 ACL 导出版本化保存,凭据单独托管。
  • 每季度恢复到隔离环境,验证用户、设备、绑定、指令和随机遥测,不只检查备份文件存在。

7.8 微服务部署演进

当前只发布一个 Spring Boot Jar。后续拆分时优先将 Kafka 上行消费者/遥测处理独立扩容,再拆指令服务。拆分前完成事件版本、Outbox、分布式追踪、配置中心、服务 SLO 和独立数据库所有权。

第八部分:用户手册(概要)

8.1 平台操作指南

8.1.1 登录与注册

  • 访问 https://iot.xxx.com,首次使用需注册企业账号
  • 登录后进入仪表盘

8.1.2 设备管理

  • 添加设备:点击“设备管理”→“添加设备”→输入设备ID和密钥(设备外壳标签上)→选择协议→完成
  • 查看设备:列表显示设备状态,点击可查看实时数据与历史曲线

8.1.3 远程控制

  • 在设备详情页,点击“控制”选项卡 → 选择控制命令(开关、调节等)→ 确认发送 → 显示执行结果

8.1.4 数据分析

  • “数据报表”菜单 → 选择设备、时间范围、数据类型 → 生成图表,可导出Excel

8.2 硬件安装指南(以温湿度传感器为例)

  1. 打开包装,取出传感器主体和支架
  2. 使用附赠的Micro-USB线充电2小时(红灯充电,绿灯满电)
  3. 下载配网App或通过微信小程序,长按设备按键5秒进入配网模式
  4. 输入Wi-Fi密码,等待提示“配网成功”
  5. 登录平台查看设备是否在线

8.3 当前 MVP 管理后台操作(2026-09-12 补充)

8.1~8.2 是目标产品概要;本节与当前可运行工程一致。

8.3.1 登录与总览

  1. 启动 Spring Boot(8080)和 Vue 开发服务后,打开 http://127.0.0.1:5173/。
  2. 管理员使用 admin / admin123,运维使用 operator / operator123。
  3. 总览显示设备总数、在线数、终端用户数、待处理告警、今日指令、消息趋势和最近告警。
  4. 右上角刷新按钮重新请求当前页面;退出按钮清除本地登录令牌。

8.3.2 产品与设备

  • 进入“产品管理”查看 ProductKey、品类、协议、物模型版本和设备数量。
  • 管理员点击“新建产品”,ProductKey 不能重复;运维角色只能查看产品。
  • 进入“设备管理”可搜索名称/编号。点击“注册设备”,填写设备名称、唯一序列号、产品和固件版本。
  • 新设备默认为离线、未绑定;创建结果含绑定码和设备凭据语义,生产系统只应一次显示设备密钥。

8.3.3 指令、告警和审计

  • 在设备行点击“下发指令”。页面提交后状态为 SENT;只有设备 ACK 经 EMQX/Kafka 返回后才显示 SUCCEEDED/FAILED。
  • “告警中心”显示待处理/已恢复状态;点击“标记已处理”更新告警并记录审计。
  • 只有管理员能打开“审计日志”,运维访问相应 API 会被后端拒绝。

8.4 当前 MVP 小程序操作

  1. 微信开发者工具导入 iot-platform-mvp/mini-program。
  2. 检查 config.js 中的 API 地址。开发工具默认使用 http://127.0.0.1:8080,真机改为开发机局域网 HTTPS 地址。
  3. 点击“使用本地演示身份”,或走微信一键登录适配入口。
  4. 设备页右上角点击“+”,扫码或输入演示绑定码 IOT-GW-0002。
  5. 绑定成功后返回设备页并下拉刷新;进入设备详情查看当前属性、产品、协议、固件和最后活跃时间。
  6. 在线智能开关可以切换电源;离线设备禁用开关,避免向用户伪报即时成功。
  7. “我的”页可查看用户身份并退出登录。

8.5 常见问题

现象处理
后台提示账号或密码错误检查大小写;演示管理员为 admin/admin123
页面提示登录过期重新登录;检查 Redis 连通性、iot:session:{token} 和 TTL
小程序请求失败确认后端已启动、config.js 地址可从当前设备访问、合法域名设置符合环境
绑定码无效去除空格并核对标签;演示码为 IOT-GW-0002
设备已被绑定当前所有者先申请解绑/转移;管理员不能直接把同一设备重复分配
设备离线检查供电、网络、设备凭据、EMQX 连接、Kafka Bridge 和最后活跃时间
指令等待中设备离线或尚未 ACK;不要重复快速点击,使用 commandId 查询结果

8.6 数据与账号注意事项

演示数据和默认密码只用于本地验证。生产使用独立账号、强密码/单点登录和 HTTPS;不要在截图、工单或聊天中发送设备密钥。用户只能查看本人设备,发现归属错误应停止控制并发起转移流程,由平台保留审计。

第九部分:项目验收文档

9.1 验收计划

  • 验收时间:测试阶段结束后3个工作日内
  • 参与人员:客户代表、项目负责人、测试经理
  • 验收标准:需求文档中的所有功能均已实现且通过测试;性能指标达标;硬件精度符合规格;文档齐全

9.2 验收清单

编号验收项是否满足(是/否)备注
1软件平台所有功能模块可正常使用
2支持MQTT/TCP/HTTP三种协议设备接入
3设备在线状态实时更新
4数据采集延迟≤500ms
5远程控制响应时间≤1s
6并发1000设备在线,系统稳定
7硬件传感器精度达标
8硬件通信距离≥50米
9提供完整文档(需求、设计、测试、部署、用户手册)
10提供源代码与固件代码

9.3 验收结论

  • 验收通过 □ 不通过 □
  • 遗留问题及处理计划:__________
  • 验收签字:
    • 客户代表:__________
    • 项目负责人:__________
    • 日期:__________

9.4 MVP 验收门槛(2026-09-12 补充)

9.2 的全量软硬件条目用于生产阶段,不代表当前单体一期已满足 1,000 设备、三协议和硬件指标。当前采用下列可执行门槛。

编号验收项方法通过标准
MVP-01后端可构建在 backend 目录执行 .\mvnw.cmd testSpring Boot 集成测试通过、退出 0
MVP-02前端可构建admin-web: npm run buildVue TypeScript/Vite 构建通过、退出 0
MVP-03后台闭环登录、注册设备、指令、告警操作实际写入 API 数据并刷新页面
MVP-04设备消息调用 Kafka 上行处理与指令发布遥测/事件消费正确,指令进入 SENT
MVP-05用户绑定小程序演示登录并绑定绑定后仅当前用户可见
MVP-06用户控制控制本人在线设备指令成功且属性更新;他人设备拒绝
MVP-07角色边界运维请求审计接口后端返回 403
MVP-08文档检查总览、API、部署、用户和路线图路径有效,内容与实现边界一致
MVP-09回滚在隔离副本执行回滚脚本哈希恢复且变更文件消失

生产上线验收须另行执行 12.迭代路线图.md 中 M1~M3 的质量门禁,不能用 MVP 结果替代。

第十部分:当前实现说明

10.1 技术基线

层当前实现职责
管理后台Vue 3、TypeScript、Vite、Pinia、Vue Router管理员和运维操作界面
用户端原生微信小程序用户登录、设备绑定、查看和控制
后端Java 17、Spring Boot 3.3、Spring Security、Spring Data JPA模块化单体业务 API
业务数据库MySQL + Flyway用户、产品、设备、绑定、指令、告警和审计
登录与缓存Redis不透明登录 Token 验证、产品等热点缓存
设备消息Kafka后端唯一的设备消息订阅/发布入口
MQTTEMQX设备连接、ACL、MQTT Topic 与 Kafka 双向桥接

后端不引入 MQTT Client,也不直接订阅 EMQX。EMQX 负责 MQTT 会话,EMQX 规则/Bridge 将上行消息写入 Kafka;后端消费 Kafka。下行控制由后端写 Kafka,再由 EMQX Bridge 投递 MQTT。

10.2 工程结构

iot-platform-mvp/
├─ admin-web/                 Vue 3 + TypeScript 管理后台
│  ├─ src/api                API 封装
│  ├─ src/stores             登录状态
│  ├─ src/router             路由与角色守卫
│  ├─ src/layouts            后台布局
│  └─ src/views              总览、产品、设备、用户、指令、告警、审计
├─ backend/                   Spring Boot 单体
│  ├─ src/main/java/.../api  REST Controller 与统一响应
│  ├─ config                 Security、Redis Cache、种子数据
│  ├─ domain/repository      JPA 实体与仓储
│  ├─ service                认证和设备业务用例
│  ├─ messaging              Kafka 生产者/消费者及消息 DTO
│  └─ resources/db/migration Flyway MySQL 结构
└─ mini-program/              原生微信小程序

10.3 单体模块边界

当前一个 Jar 内保留以下逻辑模块:

  • IAM:后台账号、小程序账号、Redis 登录会话、RBAC。
  • 产品/物模型:产品、协议和物模型版本。
  • 设备:设备身份、绑定、在线状态、当前属性和遥测。
  • 控制:指令入库、Kafka 下行、ACK 状态回写。
  • 告警:设备事件转告警、告警处置。
  • 审计:关键管理与用户操作留痕。

模块通过服务接口和 Kafka 事件 DTO 组织,不在首期过早拆分独立进程。

10.4 Kafka 与 EMQX 数据流

设备属性/事件
  → MQTT Topic
  → EMQX Rule/Bridge
  → iot.device.telemetry.up / iot.device.event.up
  → KafkaInboundListener
  → DeviceService
  → MySQL + Redis Cache

管理后台/小程序控制
  → Spring Boot API
  → device_command(PENDING)
  → iot.device.command.down
  → EMQX Bridge
  → MQTT 指令 Topic
  → 设备 ACK
  → iot.device.command.ack
  → device_command(SUCCEEDED/FAILED)

Topic 的 Key 统一为 deviceId,以保证单设备消息分区内有序。上行使用 messageId 幂等;下行使用 commandId 关联 ACK。

10.5 Redis 使用

  1. 后台或小程序登录成功后生成高熵不透明 Token。
  2. 会话写入 iot:session:{token},Value 保存用户 ID、用户名和角色,TTL 默认 24 小时。
  3. 每次 Bearer 请求由 TokenAuthenticationFilter 查询 Redis 并恢复 Spring Security 身份。
  4. 产品列表使用 Spring Cache + Redis,默认 TTL 10 分钟;产品创建时清除缓存。
  5. 测试 profile 使用内存 SessionStore 和 Simple Cache,不访问真实 Redis。

后续可将设备在线 TTL、验证码限流、接口幂等键和短时设备影子热点加入 Redis,但业务事实仍以 MySQL/Kafka 为准。

10.6 MySQL 使用

当前 Flyway V1__init_schema.sql 创建:iam_user、iot_product、iot_device、device_telemetry、device_command、device_alert、audit_log。设备表使用乐观锁版本字段;遥测以 (device_id,message_id) 唯一约束去重。

初期遥测可保存在 MySQL 便于完成闭环。数据量达到容量阈值后,把遥测存储模块替换为专用时序方案,其他业务表仍留在 MySQL。

10.7 配置与启动

中间件地址不写入源码。字段清单见 backend/.env.example;收到实际地址文档后映射为部署环境变量。

# 后端测试与启动
cd iot-platform-mvp\backend
.\mvnw.cmd test
.\mvnw.cmd spring-boot:run

# 管理后台
cd ..\admin-web
npm install
npm run dev
  • Spring Boot:http://127.0.0.1:8080
  • Vue 开发服务:http://127.0.0.1:5173
  • 小程序开发地址:http://127.0.0.1:8080

10.8 测试与部署边界

自动测试使用 H2、内存登录会话和 Noop Kafka Publisher,验证单体业务、权限、绑定、遥测事件和指令状态,不依赖已部署中间件。收到地址与认证信息后还需执行 MySQL 迁移验证、Redis TTL/故障验证、Kafka Topic ACL 与收发验证、EMQX 双向 Bridge 验证和真实设备 ACK 联调。

10.9 微服务演进

满足以下任一条件再拆分:模块需要独立扩缩容、发布节奏明显不同、数据所有权稳定、单体发布影响无法接受。建议顺序:

  1. 先拆 device-ingestion(Kafka 上行、遥测、在线态),因为吞吐模型最不同。
  2. 再拆 device-command(指令、ACK、超时、Outbox)。
  3. 再按业务量拆 alert-service 和 iam-service。
  4. 产品/物模型与核心设备资产在边界稳定前保留一起。

拆分前先补 Transactional Outbox、契约测试、事件版本、可观测性和数据库归属,不以“类多了”作为拆服务理由。

第十一部分:MVP API 接口文档

11.1 通用约定

  • Base URL:http://127.0.0.1:8080
  • 编码:UTF-8 JSON。
  • 管理员和小程序接口通过 Authorization: Bearer <token> 认证。
  • 成功响应:{"code":0,"message":"ok","data":...}。
  • 失败响应:{"code":"ERROR_CODE","message":"可读说明"},HTTP 状态码表达错误类型。
  • 时间为 ISO 8601 UTC 字符串;调用方按本地时区显示。
  • 当前 API 为 MVP v0 契约。生产版建议统一增加 /api/v1 前缀、requestId 和分页结构。

11.2 公共与认证接口

GET /api/health

无需认证。返回服务、数据结构版本和服务器时间。

POST /api/auth/login

管理后台登录。

{ "username": "admin", "password": "admin123" }

返回 token 和去除密码字段的 user。错误账号返回 HTTP 401 / INVALID_CREDENTIALS。

GET /api/auth/me

返回当前令牌对应用户,用于恢复后台会话。

11.3 管理后台接口

方法与路径角色说明
GET /api/admin/summaryadmin/operator汇总指标、趋势、最近告警和指令
GET /api/admin/productsadmin/operator产品列表,支持 keyword
POST /api/admin/productsadmin创建产品
GET /api/admin/devicesadmin/operator设备列表,支持 keyword、status
POST /api/admin/devicesadmin/operator注册设备
PATCH /api/admin/devices/{id}admin/operator修改名称、固件或演示状态
GET /api/admin/usersadmin/operator用户及绑定设备数量
GET /api/admin/commandsadmin/operator指令列表
POST /api/admin/commandsadmin/operator下发指令
GET /api/admin/alertsadmin/operator告警列表,支持 status
PATCH /api/admin/alerts/{id}admin/operator更新告警状态
GET /api/admin/auditsadmin最近 100 条审计日志

创建产品:

{
  "name": "四路智能开关",
  "productKey": "SWITCH-4CH",
  "category": "智能开关",
  "protocol": "MQTT",
  "modelVersion": "1.0.0"
}

注册设备:

{
  "deviceName": "客户展厅开关",
  "serialNumber": "SW20260912001",
  "productId": "p-switch",
  "firmware": "1.0.0"
}

服务端生成 id、bindCode 和 deviceSecret。生产 API 应只在创建响应展示一次 deviceSecret,列表永不返回。

下发指令:

{
  "deviceId": "d-switch-001",
  "name": "setPower",
  "params": { "power": false }
}

后端成功发布 Kafka 后返回 SENT。设备 ACK 经 iot.device.command.ack 消费后更新为 SUCCEEDED/FAILED;HTTP 成功不等于设备执行成功。

处置告警:

{ "status": "resolved" }

11.4 设备消息接口

后端不提供设备直连 HTTP/MQTT 接口,只订阅/发布 Kafka。EMQX 完成设备鉴权和 MQTT/Kafka 桥接。

Kafka Topic方向JSON 类型
iot.device.telemetry.up订阅{messageId,deviceId,timestamp,data}
iot.device.event.up订阅{messageId,deviceId,eventKey,level,message,timestamp,data}
iot.device.command.down发布{messageId,commandId,deviceId,name,params,timestamp}
iot.device.command.ack订阅{messageId,commandId,deviceId,success,reason,reported,timestamp}

Topic Key 均为 deviceId。详细 MQTT Topic 与映射见 05.1.关键系统设计.md。

11.5 小程序接口

POST /api/mini/auth/wechat

{ "code": "wx.login 返回的 code", "nickname": "微信用户" }

MVP 将 code 映射为演示 openid;生产服务端必须调用微信 code2Session,不得信任客户端上传的 openid。

GET /api/mini/devices

只返回当前用户绑定设备,响应中不包含 deviceSecret。

POST /api/mini/devices/bind

{ "bindCode": "IOT-GW-0002" }

无效码返回 404;被其他用户绑定返回 409;属于同一用户时幂等返回设备。

GET /api/mini/devices/{id}

返回本人设备详情以及最近 20 条遥测。请求其他用户或未绑定设备统一返回 404。

POST /api/mini/devices/{id}/commands

{ "name": "setPower", "params": { "power": true } }

只有设备所有者可以操作。

11.6 错误码

HTTPcode场景
400INTERNAL_ERROR(带格式说明)JSON 解析失败
401UNAUTHORIZED令牌缺失、错误或过期
401INVALID_CREDENTIALS后台账号或密码错误
403FORBIDDEN角色权限不足
404NOT_FOUND / DEVICE_NOT_FOUND路由或授权范围内对象不存在
409PRODUCT_KEY_EXISTS / SERIAL_EXISTS唯一键冲突
409DEVICE_ALREADY_BOUND设备已归属其他用户
422VALIDATION_ERROR必填字段或数据类型不满足

11.7 调用示例(PowerShell)

$login = Invoke-RestMethod -Method Post `
  -Uri http://127.0.0.1:8080/api/auth/login `
  -ContentType 'application/json' `
  -Body '{"username":"admin","password":"admin123"}'

$headers = @{ Authorization = "Bearer $($login.data.token)" }
Invoke-RestMethod -Uri http://127.0.0.1:8080/api/admin/devices -Headers $headers

第十二部分:迭代路线图

12.1 演进原则

先证明单设备纵向闭环,再建设稳定消息链路和数据底座;先建设租户隔离,再开放客户接入;先提供受控模板,再考虑通用低代码。每个阶段都以可观测、可回滚和可验收为完成条件。

12.2 里程碑

M0:模块化单体基线(已交付)

  • Vue 3 + TypeScript 管理后台、Spring Boot 单体 API、微信小程序三端契约贯通。
  • MySQL/Flyway、Redis 登录与缓存、Kafka 上下行 Consumer/Producer 已实现可配置适配。
  • 产品、设备、用户绑定、遥测、指令、告警、审计核心用例。
  • 确定性自动测试和本地启动文档。

退出条件:Maven 集成测试和 Vue TypeScript 生产构建通过,小程序 API/状态契约同步。

M1:真实中间件联调与可靠性(建议 2~3 个迭代)

  • 使用地址文档完成 MySQL、Redis、Kafka、EMQX 各环境连接与认证。
  • 验证 Flyway、连接池、Redis 会话/缓存 TTL、Kafka ACL/lag 和 EMQX 双向 Bridge。
  • 增加 Transactional Outbox、指令超时重试和死信/重放工具。
  • 完成真实微信 code2Session、设备凭据加密与轮换。
  • OpenAPI 规范生成、统一分页、错误码、requestId、结构化日志。

退出条件:数据迁移演练通过;备份恢复满足 RPO/RTO;越权、重放和密钥泄漏测试通过。

M2:真实设备规模化闭环(建议 2~4 个迭代)

  • EMQX TLS、设备鉴权、Topic ACL、连接事件。
  • Kafka 消息总线、Schema 版本、死信、消费幂等和重放工具。
  • Transactional Outbox 指令发布、ACK 状态机、超时重试与取消。
  • 完整 desired/reported 设备影子、乱序保护和离线补偿策略。
  • 设备模拟器、固件联调环境和 100/1,000 台阶梯压测。

退出条件:真实开关/网关完成连续 7 天稳定联调;不丢业务数据;重复消息不产生重复副作用。

M3:本司设备试点上线(建议 2 个迭代)

  • 设备批量导入、产线烧录/凭据交付、二维码标签。
  • OTA 任务、灰度、版本分组和失败回退。
  • 告警规则、通知、运维工单和设备诊断包。
  • 管理后台工程化、浏览器 E2E;小程序隐私合规、体验优化和发布流程。
  • 监控面板、SLO、值班手册、容量模型和故障演练。

退出条件:试点设备运行 30 天;重大故障为零;核心 SLO 达标;用户和运维验收通过。

M4:企业多租户与开放接入

  • Tenant、组织、成员、角色、设备组、站点和数据授权。
  • 企业自助产品/物模型/设备注册,接入 SDK 和在线调试。
  • 租户级 API Key/OAuth Client、scope、限流、调用日志、Webhook 重试。
  • 租户隔离自动化测试、账单计量预留、数据导出/删除。
  • 白标小程序先采用主题与页面模板,不开放任意代码执行。

退出条件:租户交叉访问测试 100% 阻断;首个企业客户完成沙箱联调和数据授权确认。

M5:企业服务履约与低代码

  • 安装工单、派单、签到、物料、图片、工时、远程协助和验收。
  • OEM/ERP 连接器、字段映射和客户专属 OpenAPI 套餐。
  • 受约束的页面 DSL、组件白名单、预览、审批、版本和回滚。
  • 多品牌构建和发布流水线、配置审计和运行分析。

退出条件:现场服务全流程线上化;低代码产物通过安全沙箱和发布审核;至少两类企业方案可复制。

12.3 优先级 Backlog

优先级工作项价值前置
P0MySQL 表结构演进与在线迁移支撑数据增长并保持平滑升级Flyway 基线
P0EMQX 设备鉴权/ACL 与真实 ACK控制结果可信设备协议定稿
P0绑定码一次性/过期/重置交付安全数据库事务
P0设备/消息/指令幂等避免重复副作用唯一键、messageId
P0监控、日志、备份恢复可运营生产环境
P1批量注册与二维码标签提升出厂效率产品/设备模型稳定
P1OTA 灰度和回退降低固件升级风险真实设备链路
P1用户解绑/转移/家庭分享完善用户生命周期绑定审计
P1告警规则和通知提升运维效率遥测流
P2多租户与企业门户开放企业客户M1/M2 完成
P2OpenAPI/Webhook系统集成租户和 API 治理
P3白标模板/低代码个性化交付安全沙箱和发布治理

12.4 不应提前建设的内容

  • 未有稳定物模型前建设通用拖拽页面,会把不稳定协议扩散到 UI DSL。
  • 未有租户隔离前开放客户设备,会造成数据边界返工和安全风险。
  • 未有指令 ACK 与幂等前批量控制,会放大不可确认和重复执行问题。
  • 未有真实容量数据前上复杂微服务/Kubernetes,不利于定位产品闭环问题。

12.5 每阶段质量门禁

需求和验收条件已编号;API 契约和数据库迁移向后兼容;单元/集成/端到端/设备联调测试通过;权限与租户隔离有反向用例;监控和容量预算更新;发布包含升级、验证、回滚步骤;故障复盘形成下一迭代工作项。

物联网物模型与设备影子

1. 两个概念的职责

**物模型(Thing Model)**描述某一产品“能做什么”,是设备能力的版本化契约;**设备影子(Device Shadow)**描述某一具体设备“现在/期望是什么状态”,是云端的当前态缓存。物模型属于产品,影子属于设备。

类型含义示例
属性 Property可读取或设置的持续状态power:boolean、temperature:float
服务 Service一次可调用的动作reboot()、setSchedule()
事件 Event设备主动产生的离散事实overheat、tamper

2. 智能开关物模型示例

{
  "productKey": "SWITCH-1CH",
  "version": "1.0.0",
  "properties": [
    { "key": "power", "name": "电源", "type": "boolean", "access": "readWrite", "required": true },
    { "key": "voltage", "name": "电压", "type": "float", "unit": "V", "min": 0, "max": 260, "access": "readOnly" },
    { "key": "current", "name": "电流", "type": "float", "unit": "A", "min": 0, "max": 20, "access": "readOnly" }
  ],
  "services": [
    { "key": "reboot", "name": "重启", "input": [], "output": [{ "key": "accepted", "type": "boolean" }] }
  ],
  "events": [
    { "key": "overload", "name": "过载", "level": "warning", "output": [{ "key": "current", "type": "float", "unit": "A" }] }
  ]
}

3. 设备影子示例

{
  "deviceId": "d-switch-001",
  "desired": { "power": false },
  "reported": { "power": true, "voltage": 220.3, "current": 0.18 },
  "delta": { "power": false },
  "desiredVersion": 8,
  "reportedVersion": 7,
  "updatedAt": "2026-09-12T12:00:00+08:00"
}

当用户发出“关闭”时,云端先把 desired.power 写为 false 并递增版本。设备收到指令并执行后上报 reported.power=false;两侧一致时 delta 清空。离线设备恢复连接后可以拉取较新的 desired 版本,但必须结合指令有效期和业务规则决定是否补执行。

4. 版本与兼容

  • 物模型采用语义化版本。增加可选字段为次版本,删除字段或改变数据类型为主版本。
  • 已发布版本不可原地修改;设备注册时记录兼容的模型版本。
  • 服务端先按消息声明版本校验,再转换成内部规范结构。
  • 前端根据元数据渲染只是增强能力,关键控制仍需产品化交互和权限确认。

5. 与遥测、配置、数字孪生的区别

  • 遥测是按时间追加的事实序列,适合趋势和统计;影子是可覆盖的当前快照。
  • 设备配置是期望状态的一种来源,可进入 desired;配置模板本身属于产品/设备组策略。
  • 数字孪生还包含关系、行为仿真和生命周期,范围大于物模型加影子。

6. 当前实现

当前 Spring Boot 单体以 iot_device.properties_json 保存简化的 reported 快照,Kafka 遥测写入 device_telemetry,指令 ACK 的 reported 值合并到当前属性。生产化按 05.1.关键系统设计.md 拆分 desired/reported/delta 和版本,并在容量达到阈值后接入时序数据库。

本司物联网设备管理平台文档中心

版本:2.0 · 当前架构:Vue 3 + TypeScript / Spring Boot 单体 / MySQL / Redis / Kafka / EMQX

阅读顺序

  1. 需求分析与文档总览
  2. 项目规划
  3. 需求分析
  4. 概要设计
  5. 详细设计与关键系统设计
  6. 当前实现说明
  7. API 接口文档
  8. 部署与运维

核心文档

编号文档用途
0000.需求分析与文档总览.md原始需求分析、决策和范围导航
0101.研究背景.md背景与项目价值
0202.技术与可行性分析.md技术、资源和实施可行性
0303.需求分析.md功能、非功能、软硬件需求
0404.概要设计.md单体架构、模块和中间件关系
0505.详细设计.md业务流程、数据和接口详细设计
05.105.1.关键系统设计.md状态机、身份、Kafka 消息和一致性
0606.测试方案.md自动化、集成、性能和真机测试
0707.部署与运维.md环境变量、部署、监控和恢复
0808.用户手册.md管理后台与小程序操作
0909.验收方案.md阶段验收门槛
1010.MVP实现说明.md当前代码、配置和微服务演进
1111.API接口文档.md当前 REST API 契约
1212.迭代路线图.md一期增强、SaaS 和微服务路线

专题资料只保留 物联网-物模型设备影子概念.md 与 物模型关系图.drawio。过程提示、空白图、重复物模型说明、个人成长路线和零散占位文档已从主目录移除,原件保存在交付记录的清理前备份中。

代码目录

iot-platform-mvp/
├─ admin-web/       Vue 3 + TypeScript + Vite
├─ backend/         Spring Boot 模块化单体
└─ mini-program/    原生微信小程序

验证命令

cd .\iot-platform-mvp\admin-web
npm run build

cd ..\backend
.\mvnw.cmd test

实际中间件地址到位后,再按 backend/.env.example 完成联调环境配置和 EMQX/Kafka 双向链路验收。

IoT 平台项目规划

1. 项目定位

以本司硬件设备数字化交付为起点,建立产品、物模型、设备身份、用户绑定、设备消息、远程控制、告警和审计的统一平台。第一阶段采用模块化单体快速完成业务闭环,业务和吞吐边界稳定后再演进微服务。

2. 已确定技术路线

范围选型
管理后台Vue 3 + TypeScript + Vite
后端Java 17 + Spring Boot 3 模块化单体
用户端原生微信小程序
数据库MySQL + Flyway
登录/缓存Redis
消息总线Kafka
MQTT BrokerEMQX
设备消息边界后端只消费和发布 Kafka Topic,不直连 MQTT

3. 当前数据链路

上行:设备 → MQTT → EMQX → Kafka → Spring Boot → MySQL/Redis
下行:管理后台/小程序 → Spring Boot → Kafka → EMQX → MQTT → 设备
回执:设备 → EMQX → Kafka command.ack → Spring Boot → MySQL

EMQX 管理设备连接和 MQTT ACL;Kafka 作为后端与 EMQX 的唯一设备消息边界。设备消息 Topic 名称、Key、JSON Schema 和版本由双方共同维护。

4. 分阶段交付

阶段目标交付
当前单体一期本司设备闭环Vue 管理后台、Spring Boot API、小程序、MySQL/Redis/Kafka/EMQX 集成
一期增强稳定生产运行ACK 超时重试、Outbox、批量注册、OTA、监控、备份恢复
二期客户设备开放多租户、物模型自助、接入 SDK、API Key/Webhook
三期企业 SaaS企业后台、设备组、工单履约、OEM/ERP OpenAPI
微服务阶段独立扩缩容和发布优先拆消息接入、控制和告警服务

5. 单体优先原则

  • 所有业务模块先在一个 Spring Boot 工程和一个发布单元内实现。
  • 包结构按领域分层,禁止 Controller 直接操作 Repository。
  • Kafka 消息 DTO 独立版本化,未来拆服务不改变设备侧协议。
  • MySQL 表明确模块所有权;跨模块修改通过应用服务完成。
  • 微服务拆分必须有容量、故障隔离或团队协作的真实驱动。

6. 待补配置

中间件均已部署,等待地址文档后补充:

  • MySQL JDBC URL、库名、账号和 TLS 参数。
  • Redis 地址、密码、DB、Sentinel/Cluster 模式。
  • Kafka bootstrap servers、SASL/TLS、Topic 分区、副本和 ACL。
  • EMQX Dashboard/API 地址、Kafka Connector/Bridge 名称、MQTT Topic 映射。
  • 各环境域名、证书、网络访问策略和密钥托管方式。

源码已经通过环境变量预留全部地址,不需要再次改代码。

7. 当前完成标准

Vue TypeScript 生产构建通过;Spring Boot H2/内存适配测试通过;真实配置不入库;后端不存在 MQTT 客户端依赖;Kafka 上行、事件、下行和 ACK 主题契约明确;小程序继续复用同一 API。

像素大世界

主要功能需求概括

  • 管理公司的物联网设备,公司所有的设备都需要接入到平台进管理
  • 为用户提供物联服务
  • 公司设备包括mqtt透传设备,网关设备,智能开关等一系列可以通过物联的方式接入到平台,用户购买我司的设备之后可以通过我司提供的小程序,app控制设备。
  • 后续功能,对接企业,saas系统,企业购买我司的设备,或者通过我司平台的协议接入自己的设备到我平台。
  • 平台前期最主要的功能是完成我司物联设备的管理,包括物联设备的录入,物联设备的注册,物联设备的管理,完成平台能力。

第一阶段:

完成设备管理,设备注册,设备接入,设备功能,指令下发,设备功能配置。用户管理,设备接入时要绑定用户,一般是小程序用户直接通过扫码或者输入设备唯一编号进行设备绑定。

第二阶段:

允许用户接入自己的设备,规定协议之后,用户可以直接接入自己的设备。运行用户自定义自己的小程序,支持用户生成自己的小程序,接入到我们平台。甚至可以帮用户直接配置自己的小程序,用户只需要拖动界面规划自己的小程序界面就能完成自己的个性化定制。

第三阶段:可以和第二阶段同步进行

这个阶段针对的是企业用户,支持帮助企业定制化开发设备,支持平台客服批量帮助用户完成设备注册,支持远程协调帮助,支持派遣人员到现场帮忙安装,那么派遣的员工,以及时长平台能管理,类似内部erp系统。saas模式,支持企业进入saas后台管理自己的设备,也支持企业做设备的绑定,支持对外提供设备状态和数据给到企业的一些oem系统erp系统,也就是说支持个性化接口提供,为每个企业提供适合企业的openapi。

不止是一个各种游戏mod供应平台,更是一个自动化构建mod的平台。

接入AI,你只需要提需求,即可完成mod的开发。

平台优秀MOD:MC-xxxx,

主要功能:

  • 提供各种游戏mod便捷开发,用户可接入自己的大模型,
  • 给用户一个开放的游戏mod下载接口
  • 帮助用户快捷安装游戏mod,接入ai主动帮调用处理mod错误信息。

一切皆插件

按照游戏划分,一个游戏是一个插件,热插拔的方式更换,允许多个游戏的插件。

1. 背景

现在的md笔记工具,例如语雀等。他们的web版本快捷方式和typora区别很大。对于习惯了typora的使用者很不友好。所以现在现在打算做一个md笔记编辑管理项目。以方便使用。

2. 可行性分析

  • 可行!
  • 主打开源免费。
  • 付费功能,内网穿透。

3. 需求

3.1 技术要求

  • 前端后端不分离。
  • 要求必须轻量级直接部署。
  • 支持跨平台,win,linux都支持。
    • win直接exe启动。
    • linux直接命令行启动。

3.2 功能要求

  • 登录功能:后端地址需要填写,默认本机,为空为本机。密码需要填写。密码在配置文件中,可以在部署的机器上的配置文件做配置。后端地址配置之后应该要写入配置文件,做保存。应该要记录以前配置过的后端地址。

  • 编辑功能:首先编辑器必须要完成类似typora快捷键。

  • 穿透(前期先不做):

    • 后端穿透:只将后端接口穿透出来
    • 全部穿透:将前端,后端所有功能穿透出来。
  • 穿透/付费功能(前期先不做):

    • 使用我官方前端地址,只需要配置后端已经完成穿透的后端域名和端口。
    • 自己配置穿透,直接走自己的穿透地址。

主要是虚拟发货的用户使用我们平台

开启店铺上架各种工具,时不时店铺有折扣。

店铺支持定制各种脚本。

2026.03.23:

  • jenkins-pipeline使用
  • spring-security框架
  • OTA升级流程,java后端设计,设备端功能设计

03.24

  • 回调函数和状态机
  • kafka
  • 单片机烧录bin文件hex文件解析。

03.31

Ai+语言+工具控制+硬件设备,智能化。

核心概念:值传递(Pass by Value) vs. 引用/指针传递(Pass by Reference/Pointer)

在分析具体语言之前,必须理解这个根本区别:

  • 值传递 (Pass by Value): 函数接收到的是实参的一个副本。在函数内部修改这个副本,不会影响原始的实参。
  • 引用/指针传递 (Pass by Reference/Pointer): 函数接收到的是实参的内存地址(引用可以理解为语法糖形式的指针)。通过这个地址直接操作原始数据,在函数内部的修改会影响原始的实参。

1. Java 的情况

Java的语言规范规定,所有方法的参数传递都是“值传递”。

java

class MyClass {
    public int value;
}

public class Test {
    public static void modifyObject(MyClass obj) { // obj是传入引用的一个副本
        obj.value = 10; // 通过副本找到原始对象,修改其属性
        obj = new MyClass(); //** 这里让副本指向一个新对象,但原始实参的指向不变
        obj.value = 20;
    }

    public static void main(String[] args) {
        MyClass myObj = new MyClass();
        myObj.value = 5;
        System.out.println("Before: " + myObj.value); // Output: Before: 5

        modifyObject(myObj); // **将myObj保存的引用(地址)复制一份传给obj

        System.out.println("After: " + myObj.value); // Output: After: 10
    }
}

分析:

  1. myObj 是一个引用变量,它的值是一个指向堆中MyClass对象的地址。
  2. 调用modifyObject(myObj)时,传递的是myObj这个引用变量里存储的地址值的副本。所以形参obj和实参myObj指向同一个对象。
  3. obj.value = 10; 通过副本地址找到了原始对象,并修改了它的属性。所以外部的myObj.value也变成了10。
  4. obj = new MyClass(); 这一步是让形参obj这个副本指向了一个全新的对象。这与外部的myObj已经完全无关了。myObj仍然指向原来的对象。
  5. 结论: Java通过值传递引用的副本。你可以通过这个副本修改它所指向的对象的内容,但你不能通过修改副本本身(让它指向新对象)来影响实参的指向。

简单说:Java中,对于对象,你可以修改对象的“内容”,但不能修改外部变量的“指向”。

2. C++ 的情况

C++提供了更灵活的参数传递方式,这也是它比Java更复杂但也更强大的地方。

a) 值传递一个对象

cpp

#include <iostream>
using namespace std;

class MyClass {
public:
    int value;
};

void modifyByValue(MyClass obj) { // 值传递,调用拷贝构造函数生成obj副本
    obj.value = 10; // 修改的是副本的属性
} // 函数结束,副本obj被销毁

int main() {
    MyClass myObj;
    myObj.value = 5;
    cout << "Before: " << myObj.value << endl; // Output: Before: 5

    modifyByValue(myObj);

    cout << "After: " << myObj.value << endl; // Output: After: 5 (未改变)
    return 0;
}

分析: 和内置类型(如int)的值传递完全一样。myObj被完整地复制了一份给形参obj。函数里修改的是副本,原对象myObj丝毫未受影响。效率较低,尤其是对于大型对象,因为涉及拷贝构造。

b) 传递一个对象指针

cpp

void modifyByPointer(MyClass *objPtr) { // 传递的是对象地址的副本
    objPtr->value = 10; // 通过地址修改原始对象的属性
    // objPtr = new MyClass(); // 如果这样做,和Java一样,只修改了地址副本,不影响实参指针
    // delete ...; // 需要手动管理新对象的内存,容易出错
}

int main() {
    MyClass myObj;
    myObj.value = 5;
    cout << "Before: " << myObj.value << endl; // Output: Before: 5

    modifyByPointer(&myObj); // 传递myObj的地址

    cout << "After: " << myObj.value << endl; // Output: After: 10 (已改变)
    return 0;
}

分析: 传递的是指针(地址)的值。objPtr是&myObj这个地址值的一个副本。通过这个地址副本,可以找到并修改原始对象myObj。效果上是“引用传递”的效果,但机制上仍然是“值传递”(传递的是指针这个值本身)。

c) 引用传递一个对象

cpp

void modifyByReference(MyClass &objRef) { // objRef是myObj的别名(引用)
    objRef.value = 10; // 直接操作原始对象
    // objRef = MyClass(...); // 这里赋值会直接改变原始对象!
}

int main() {
    MyClass myObj;
    myObj.value = 5;
    cout << "Before: " << myObj.value << endl; // Output: Before: 5

    modifyByReference(myObj); // 直接传递对象本身,语法更简洁

    cout << "After: " << myObj.value << endl; // Output: After: 10 (已改变)
    return 0;
}

分析: 这是真正的“引用传递”。形参objRef不是副本,而是实参myObj的一个别名。它们根本就是同一个东西。函数内对objRef的任何操作都等价于直接对myObj操作。语法简洁,效率高(无拷贝开销),是C++中修改入参的推荐方式。


3. C 语言的情况

C语言没有引用和类的概念,只有结构体(struct)和指针。

a) 将结构体作为参数值传递

c

#include <stdio.h>

typedef struct {
    int value;
} MyStruct;

void modifyStruct(MyStruct s) { // 值传递,发生结构体拷贝
    s.value = 10; // 修改的是副本
}

int main() {
    MyStruct myStruct;
    myStruct.value = 5;
    printf("Before: %d\n", myStruct.value); // Output: Before: 5

    modifyStruct(myStruct);

    printf("After: %d\n", myStruct.value); // Output: After: 5 (未改变)
    return 0;
}

分析: 和C++的值传递对象完全一样。整个结构体的内容被复制到形参s中,修改副本不影响原始结构体。对于大型结构体,性能开销很大。

b) 将结构体指针传递

c

void modifyStructByPointer(MyStruct *sPtr) { // 传递结构体指针(地址)
    sPtr->value = 10; // 通过指针(地址)修改原始结构体
}

int main() {
    MyStruct myStruct;
    myStruct.value = 5;
    printf("Before: %d\n", myStruct.value); // Output: Before: 5

    modifyStructByPointer(&myStruct); // 传递myStruct的地址

    printf("After: %d\n", myStruct.value); // Output: After: 10 (已改变)
    return 0;
}

传参为结构体数组

int delete_contact(struct person contacts[], int count){
	//传入的是数组的首地址。也就是指针指向这个地址
}

分析: 这是C语言中实现“函数内部修改外部结构体”的标准做法。机制和效果与C++的指针传递完全相同。传递的是地址值的副本,通过这个地址副本可以找到并修改原始数据。


总结与对比

语言传递方式语法示例是否修改原对象效率说明
Java对象引用的值传递modify(Object obj)可以修改对象内容高传递引用的副本。可修改指向的对象,但不能让实参指向新对象。
C++对象值传递modify(Class obj)不能低创建完整副本,修改不影响原对象。
C++对象指针的值传递modify(Class *objPtr)可以高传递指针副本。通过指针修改原对象。机制同C。
C++对象引用传递modify(Class &objRef)可以高推荐方式。形参是实参的别名,操作的是原对象。
C结构体值传递modify(Struct s)不能低创建完整副本,修改不影响原结构体。
C结构体指针传递modify(Struct *sPtr)可以高标准做法。传递指针副本,通过指针修改原结构体。

核心记忆点:

  1. Java: 只有一种方式(值传递引用),效果是“半修改”(能改内容,不能改指向)。
  2. C++: 有三种方式。想要在函数内修改外部对象,优先使用引用传递(&)。指针传递也可以,但语法更繁琐。值传递通常不用于需要修改的场景。
  3. C: 只有两种方式。必须使用指针传递(*)来修改外部结构体。值传递只用于不需要修改且结构体很小的场景。

​ 针对java:传参可以理解成指针的副本,myClass clazz = new myClass(), 将clazz参数传入方法。可以类比成,clazz是一个指针指向一个对象(new myClass()),传入时类比成新建了一个指针也只向new myClass()。所以就能修改属性。

​ 针对java:基本数据类型,int,long,char之类的8种基本数据类型。采用的是真正的值传递

创建

  1. 检查现状 确认当前无Swap,并确保有足够磁盘空间。 swapon --show df -h /

  2. 创建Swap文件 创建大小为2GB的Swap文件。 fallocate -l 2G /swap

  3. 有些文件系统不支持fallocate指令使用以下命令 2048为2g dd if=/dev/zero of=/swap bs=1M count=2048 status=progress

  4. 设置权限 锁定文件权限,防止被随意读取。 chmod 600 /swap

  5. 格式化 将文件标记为交换空间格式。 mkswap /swap

  6. 立即启用 激活Swap。 swapon /swap

  7. 验证启用 检查Swap是否成功启用并查看大小。 swapon --show free -h

  8. 永久生效 配置系统启动时自动挂载此Swap。 echo '/swap none swap sw 0 0' | sudo tee -a /etc/fstab

  9. 设置交换积极性 echo 'vm.swappiness=60' | sudo tee -a /etc/sysctl.conf

  10. 确定交互积极性 sysctl -p

移除Swap文件步骤

  1. 查看当前状态 确认正在使用的Swap文件路径。 sudo swapon --show

  2. 停用Swap 关闭所有Swap交换空间。 sudo swapoff /swap

  3. 验证已停用 确认Swap已完全关闭。 sudo swapon --show free -h

  4. 删除自动挂载 从系统配置中移除Swap,使其开机不启动。 打开文件 vim /etc/fstab
    删除这行 /swap none swap sw 0 0

  5. 删除Swap文件 永久删除磁盘上的文件以释放空间。 rm /swap

  6. (可选)清理内核参数 移除之前调整的swappiness优化设置。 打开文件 vim /etc/sysctl.conf 修改vm.swappiness=0

  7. 重启生效 sysctl -p

服务端

  • 安装
apt-get install wireguard
  • 开启ipv4流量转发

    /etc/sysctl.conf

net.ipv4.ip_forward = 1 #设置为1
  • 进入到wireguard目录

    /etc/wireguard

chmod -R 777 /etc/wireguard
  • 生成服务端密钥对(私钥和公钥)
wg genkey | sudo tee /etc/wireguard/privatekey | wg pubkey | sudo tee /etc/wireguard/publickey
  • 创建 /etc/wireguard/wg0.conf
touch /etc/wireguard/wg0.conf
[Interface]
# 服务器的虚拟IP地址(选择一个不冲突的内网段)
Address = 10.1.1.1/24
# 监听端口(默认51820)
ListenPort = 51820
# 服务器的私钥(填入刚才生成的私钥内容)
PrivateKey = <服务器的私钥>

# 开启IP转发(为了能让客户端之间互相通信)  eth0记得换
PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

# --- 下面开始添加客户端(对等点) ---

# 客户端1:你的电脑
[Peer]
# 客户端1的公钥
PublicKey = <客户端1的公钥>
# 允许客户端1使用的IP地址,相当于给它分配了固定IP 10.1.1.2
AllowedIPs = 10.1.1.2/32

# 客户端2:你的另一台设备
[Peer]
PublicKey = <客户端2的公钥>
AllowedIPs = 10.1.1.3/32
  • 启动服务端
systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0

客户端

Windows

  • 下载安装
https://www.wireguard.com/install/

界面操作->在隧道列表 新增隧道

写入以下

名称

[Interface]
PrivateKey = SHd3t5Tz038WY8yQWjZxisL4LzNRQRxvPlcRC56B/Gc=
Address = 10.1.1.2/24

[Peer]
PublicKey = /mw8vj3YqmzNxcKlKFdaIw9b/uB5B9+Zpy+IRE6umF4=
AllowedIPs = 10.1.1.0/24
Endpoint = 120.25.167.228:51820
PersistentKeepalive = 25

[Interface]

PrivateKey:这是新建时自动生成的。

Address: 这个是配置当前客户端的ip地址

DNS: dns服务器地址

[Peer]

PublicKey:服务端公钥,在服务端/etc/wireguard/publickey 文件下

AllowedIPs:允许的IP,也就是说访问什么ip时走wireguard。0.0.0.0/0 代表所有流量都走VPN(全隧道)

Endpoint:服务器ip和端口

PersistentKeepalive:保持连接活跃(对于NAT很重要)

Linux

  • 安装
apt-get install wireguard
  • 配置
cd /etc/wireguard
# 生成私钥并设置权限
wg genkey | tee privatekey && chmod 600 privatekey
# 根据私钥生成公钥
wg pubkey < privatekey > publickey
  • 配置到服务端,服务端配置文件/etc/wireguard/wg0.conf添加下面内容
  • 重启服务端
[Peer]
PublicKey = mOeMOFPG7wsa/KkTuXlZkQnlFYZFwcur8xVCNVusGFw=  #客户端公钥
AllowedIPs = 10.1.1.5/32
  • 客户端新增/etc/wireguard/wg0.conf文件
touch /etc/wireguard/wg0.conf
[Interface]
# 客户端虚拟 IP(必须与服务端分配的 IP 一致,注意 /32 表示单个 IP)
Address = 10.1.1.5/32
# 客户端私钥(从刚才生成的 privatekey 文件中复制)
PrivateKey = mLs1eUCJXhb9J+aiMm50PfmD21F2RYiMotIkZt3fHkE=
# DNS 服务器(可选,例如使用公共 DNS 或服务端内网 DNS)
#DNS = 8.8.8.8

[Peer]
# 服务端的公钥(从服务端获取)
PublicKey = /mw8vj3YqmzNxcKlKFdaIw9b/uB5B9+Zpy+IRE6umF4=
# 服务端的公网 IP 和端口
Endpoint = 120.25.167.228:51820
# 允许的 IP:这里设置 10.1.1.0/24 表示只有访问 VPN 网段的流量才走隧道
# 如果想所有流量都走 VPN,可以改为 0.0.0.0/0
AllowedIPs = 10.1.1.0/24
# 保持连接的存活时间(对于 NAT 后面的客户端很重要)
PersistentKeepalive = 25
  • 重启服务
systemctl restart wg-quick@wg0
或者
wg-quick up wg0
  • 启动连接测试**(记得重启或重新加载服务端文件**)
ping 10.1.1.1 或者其他
  • 设置开机自启
systemctl enable wg-quick@wg0

Android

下载

https://www.wireguard.com/install/

界面配置

本地
 - 名称:
 - 私钥:自己生成
 - 公钥:自己生成,要复制到服务端
 - 局域网ip:自己的ip 注意x.x.x.x/24  这个24
 - dns服务器:可以不填
 
添加节点
远程
 - 公钥:服务器公钥
 - 保活间隔:25s一次心跳
 - 对端:Endpoint就这个配置,120.25.167.228:51820
 - 路由的ip地址段:10.1.1.0/24 注意x.x.x.x/24  这个24

moon 服务器配置

  • 进入 ZeroTier 配置文件目录。

    cd /var/lib/zerotier-one
    
  • 生成 moon.json 签名文件。

    sudo -s zerotier-idtool initmoon identity.public >>moon.json
    
  • 编辑

    vim moon.json
    

    可以看到如下信息:

    moon.json

    
      {
       "id": "0123456789",
       "objtype": "world",
       "roots": [
        {
         "identity": "xxxxxxxx:0:xxxxxxxx",
         "stableEndpoints": []
        }
       ],
       "signingKey": "xxxxxxxx",
       "signingKey_SECRET": "xxxxxxxx",
       "updatesMustBeSignedBy": "xxxxxxxx",
       "worldType": "moon"
      }
        
    

    注意:记录下 moon.json 文件中的 id 。

  • 修改信息。

    找到 "stableEndpoints": [] 。

    添加 "IPv4地址/9993" 或者 "IPv4地址/9993","IPv6地址/9993" 。

    示例:

    "stableEndpoints": ["1.2.3.4/9993","2001:abcd:abcd::1/9993" ]
    
  • 生成 .moon 签名文件

    zerotier-idtool genmoon moon.json
    

    显示下面内容成功:

    wrote 0000006xxxxxxxxx.moon (signed world with timestamp 15xxxxxxxxxx7)
    
  • 创建 moon 结点文件夹。

    mkdir /var/lib/zerotier-one/moons.d
    
  • 将签名文件复制到 moons.d 文件夹中

    cp 0000006xxxxxxxxx.moon moons.d/
    
  • 重启 ZeroTier 服务

    /etc/init.d/zerotier-one restart
    或者
    systemctl restart zerotier-one.service
    

设备连入 moon 服务器

自动配置

  • 输入命令:

    sudo zerotier-cli orbit [moon.json 文件中的 id] [moon.json 文件中的 id]
    
    示例
    zerotier-cli orbit f63861d692 f63861d692  //这里的f63861d692是前面moon.json中的id
    200 orbit OK
    
  • Windows 有一点不同,需要使用管理员权限的 PowerShell 输入:

    zerotier-cli orbit [moon.json 文件中的 id] [moon.json 文件中的 id]
    

手动配置

各个系统平台下 ZeroTier 目录位置 :

Windows: C:\ProgramData\ZeroTier\One
Macintosh: /Library/Application Support/ZeroTier/One (在 Terminal 中应为 /Library/Application\ Support/ZeroTier/One)
 Linux: /var/lib/zerotier-one
 FreeBSD/OpenBSD: /var/db/zerotier-one
  • 在对应系统平台下的 ZeroTier 目录位置,创建 moons.d 文件夹。

    sudo mkdir /var/lib/zerotier-one/moons.d
    
  • 将 moon 服务器生成的 0000006xxxxxxxxx.moon 拷贝到 moons.d 文件夹下。

  • 重启 zerotier-one 服务。

    • Linux :/etc/init.d/zerotier-one restart
    • Windows :
      • 按下 windows键+r ,打开 “运行” 窗口。
      • 输入 services.msc 回车。
      • 找到 ZeroTier One 服务,右键选择 “重新启动” 。

检测生效

  • 在非 moon 的客户端,输入命令:

    zerotier-cli listpeers
    
  • Windows 有一点不同,需要使用管理员权限的 PowerShell 输入:

    zerotier-cli listpeers
    

如果出现如下情况:

  • moon 服务器的 ID 、IP 地址出现在列表中,证明联通 moon 服务器。
200 listpeers <ztaddr> <path> <latency> <version> <role>
...................
200 listpeers 6xxxxxxxxx [moon IPv4地址]/60723;11450;11405 -1 1.4.6 MOON
...................

//这个不对,当其他的节点进行互相访问时,会尝试使用f63861d692 120.25.167.228/9993 ,成功后LEAF变成MOON
200 listpeers f63861d692 120.25.167.228/9993;16760;17744 -984 1.14.2 LEAF 
//完成后的
200 listpeers f63861d692 120.25.167.228/9993;4101;4101 15 1.14.2 MOON

📒 笔记文档使用指南

这是什么

这是一个基于 mdBook 构建的静态笔记站点。所有笔记以 Markdown 格式保存在 Git 仓库中,通过 mdBook 生成漂亮的静态 HTML 页面。

🤖 给 AI 机器人(QQ 机器人)的操作说明

一键更新笔记

/usr/share/nginx/html/blog/update.sh

这个脚本会自动执行:

  1. git pull — 从 Gitee 拉取最新笔记
  2. 编译 s.c — 生成侧边栏文件 SUMMARY.md
  3. mdbook build — 构建静态站点

服务器文件结构

/usr/share/nginx/html/
├── blog/                  ← mdBook 项目根目录
│   ├── book/              ← 构建输出 HTML(nginx 指向这里)
│   │   ├── index.html     ← 首页
│   │   ├── notes/         ← 生成的笔记页面
│   │   ├── css/           ← 样式文件
│   │   ├── FontAwesome/   ← 图标字体(本地化)
│   │   └── searchindex.js ← 全文搜索索引
│   ├── src/
│   │   ├── notes/         ← Markdown 笔记源文件(Gitee 仓库)
│   │   └── SUMMARY.md     ← 侧边栏目录(由 s.c 自动生成)
│   ├── theme/
│   │   ├── custom.css     ← 自定义样式
│   │   └── custom.js      ← 侧边栏折叠功能
│   ├── s.c                ← 侧边栏生成器源代码(C语言)
│   ├── s                  ← 编译后的二进制
│   ├── book.toml          ← mdBook 配置文件
│   └── update.sh          ← 一键更新脚本
└── blog.backup/           ← 备份

Nginx 配置

  • 根目录: /usr/share/nginx/html/blog/book
  • 域名: www.openso.top
  • 反向代理路径: /sc, /test/, /shuiChan, /mqtt

📝 给人类用户的说明

浏览笔记

  • 侧边栏: 左侧显示目录结构,点击 📁 文件夹标题展开/收起,点击 📄 文件标题查看内容
  • 搜索: 点击顶部搜索图标 🔍 或按 s 键,可全文搜索
  • 主题切换: 点击顶部 🖌️ 图标,可选择 Light / Rust / Coal / Navy / Ayu 五种风格
  • 侧边栏开关: 点击 ☰ 图标可收起/展开侧边栏

笔记格式

所有笔记使用 Markdown 编写,支持:

  • 标题(# ~ ######)
  • 代码块(```)
  • 表格、列表、引用
  • 图片(![]())
  • 链接([]())

关于 mdBook

mdBook 是一个由 Rust 编写的静态站点生成器,专门用于创建文档/笔记网站。

特点:

  • 纯静态 HTML,无需后端服务
  • 内置全文搜索
  • 支持多主题切换
  • 响应式设计,支持手机端
  • 侧边栏自动从目录结构生成

配置文件: blog/book.toml

[book]
title = "📒 笔记文档"
src = "src"

[output.html]
default-theme = "ayu"
print-enable = false
prev-next-buttons = false

侧边栏生成器(s.c)

s.c 是一个用 C 语言编写的工具,用于从笔记目录结构自动生成 SUMMARY.md(mdBook 使用的侧边栏定义文件)。

功能:

  • 深度优先遍历目录,正确维护父子层级
  • 目录优先于文件排序,各自按字母序
  • 自动生成 README.md 作为目录的入口页
  • URL 中的空格自动编码为 %20
  • 无 .md 文件的目录自动跳过(不会出现在侧边栏)

最后更新: 2026-05-27

开闭原则

对扩展开放,对修改关闭。 当需求发生变化时,应尽量不修改原有代码,而是通过新增功能来满足需求。 实现方式:对业务进行抽象。例如支付功能,初期可能只支持微信支付,后续可能扩展其他支付方式,此时应将支付行为抽象为接口或抽象类,以支持灵活扩展。

里氏替换原则

用于指导何时使用继承。 子类可以扩展父类的功能,但不能改变父类原有的行为。任何父类出现的地方,都可以用子类替换,且程序行为不会发生错误。

依赖倒置原则

高层模块不应依赖低层模块,两者都应依赖其抽象。 例如在常见的分层架构中,Controller 层引入的是 Service 接口,而非具体的实现类。

单一职责原则

一个类只应承担一个职责。 例如员工类可以有打卡方法,但不应包含计算工资的方法,后者应交由专门的薪资类处理。

接口隔离原则

接口应尽量做到“最小化”,即一个接口中只包含与其职责相关的抽象方法。 如果某个接口中存在与自身无关的方法,则应将这部分方法拆分到另一个接口中。

迪米特法则(最少知识原则)

一个对象应尽可能少地与其他对象发生交互,只与直接的朋友通信。 例如明星与经纪人之间的关系,外界只需与经纪人沟通,无需直接接触明星,从而降低耦合。

合成复用原则

优先使用组合(合成)或聚合,而不是继承来实现复用。 例如人有姓名、年龄等属性,同时拥有汽车。不应将汽车属性直接写入人类,而应将汽车独立为一个类,在人类中通过组合的方式引用汽车对象,使结构更清晰、复用性更强。