茅胜荣

📞 18862141982 📬 18862141982@163.com ✒️ blog 📷 vlog

🛈 个人信息

  • 男,1992 年出生
  • 求职意向:高级嵌入式软件工程师
  • 工作经验:6 年
  • 现居地:江苏省苏州市吴中区

🎓️ 教育经历

  • 硕士,苏州大学,电子科学与技术专业,2015.9~2018.7,GPA(3.9/4.0)
  • 学士,苏州大学,电子信息工程专业,2011.9~2015.7,GPA(3.8/4.0)

💼 工作经历

  • 乐鑫信息科技,芯片支持部门,嵌入式驱动工程师,2018.7 月至今

    • 芯片 bring up
    • SOC 外设驱动框架

🚵 卓越贡献

  • 设计实现通用序列收发器驱动框架

    event-driven, OOP, IR, encoder

    • 工厂模式分配 RMT 通道,发送和接收的接口隔离
    • 事务模型,每个发送通道拥有一个事务队列,不同事务按序执行
    • 应用适配器模式定义 RMT encoder,将应用层数据表示转译为 RMT 硬件符号,定义一套原生编码器接口
    • 组合设计模式,可自由组合原生编码器,为特定应用创建新的编码器
    • 传输层抽象,支持 DMA 与非 DMA 传输,接口层统一
    • RMT 驱动详见 GitHub 项目地址
  • 设计实现嵌入式以太网驱动框架

    mediator pattern, OOP, Ethernet

    • 中介器设计模式,分隔用户、MAC 层和 PHY 层
    • 多态设计,支持 ESP32 内部 MAC 控制器以及其他 SPI 接口的以太网模块
    • 驱动分层设计,LL + HAL + driver,降低驱动在不同芯片平台上移植的难度
    • 详见 GitHub 项目地址
  • 设计实现嵌入式LCD驱动框架

    RGB/YUV, I80, SPI, I2C, MIPI DSI, OOP, LVGL

    • 抽象出 panel_io 接口,统一 SPI/I2C/I80/MIPI_DBI 对 LCD 模组发送命令与显存数据的操作
    • 抽象出 panel 对象,统一应用层对显存设备的操作,支持 RGB/MIPI_DPI 接口的 LCD 和 panel_io 抽象接口的 LCD 设备
    • IO 接口与显存操作分离,LCD 控制器驱动可复用程度最大化
    • 适配 LVGL 库,对接异步接口
    • 详见 GitHub 项目地址
  • BSP 软件包

    BSP, Interface Segregation Principle

    • 定义并实现智能灯带 led-strip 设备的操作接口
      • 本质上一种 RMT 编码器实现,同时也预留了 SPI 外设的实现层
    • 定义并实现直流有刷电机 bdc-motor 设备的操作接口
      • 本质上 MCPWM 外设驱动的再封装,同时也预留了其他外设的实现层
    • 模拟 1-Wire 总线驱动和设备驱动 onewire_bus
      • 利用 RMT 硬件的独特性,实现全硬件化的单总线协议(非 GPIO bit-banging)
      • 使用迭代器模式实现总线上设备的扫描功能

🛠 技能清单

  • ★★★ C 语言面向对象建模与实现
  • ★★☆ Python、Bash
  • ★★☆ 计算机网络

🏆️ 职业认证

  • 职称: 江苏省中级专业技术资格
  • 网络: CCNA(920分), 全国计算机等级4级(网络工程师)
  • 语言: CET-6(511分)
  • 编程: 江苏省计算机等级3级(软件)
  • 竞赛: 全国大学生电子设计竞赛二等奖

⛵️ 业余爱好

  • 媒体: 用博客、视频记录工作与生活
  • 创客: 接触最新最好玩的创客玩具(树莓派, OpenWRT, XMOS, PSoC)

RISC-V 基础

指令集划分

名称类别说明
RV32I基础指令整数指令,包含算术、分支、逻辑、访存指令,有32个32位寄存器,能寻址32位地址空间
RV32E基础指令与RV32I一样,只不过只能使用前16个32位寄存器
RV64I基础指令整数指令,包含算术、分支、逻辑、访存指令,有32个64位寄存器,能寻址64位地址空间
RV128I基础指令整数指令,包含算术、分支、逻辑、访存指令,有32个128位寄存器,能寻址128位地址空间
M扩展指令包含乘法、除法、求模取余指令
F扩展指令单精度(32bit)浮点指令
D扩展指令双精度(32bit)浮点指令,必须要同时支持F扩展指令
Q扩展指令四倍精度浮点指令
A扩展指令存储器原子操作指令,比如比较并交换,读-改-写等指令
C扩展指令压缩指令,指令长度为16位,主要用于改善程序大小
P扩展指令单指令多数据(Packed-SIMD)指令
B扩展指令位操作指令
H扩展指令支持 Hypervisor 管理指令
J扩展指令动态翻译语言的指令
L扩展指令十进制浮点指令
N扩展指令用户中断指令
G通用指令包含 I、M、A、F、D指令

RV32I 基础指令集

RV32I

RV32I 通用寄存器

寄存器ABI 名字描述Saver
x0zero0值寄存器,硬编码为0,写入数据忽略,读取永远为0-
x1ra返回地址Caller
x2sp栈指针Callee
x3gp全局指针-
x4tp线程指针-
x5t0临时寄存器或者备用的链接寄存器Caller
x6-x7t1-t2临时寄存器Caller
x8s0/fp需要保存的寄存器或者帧指针寄存器Callee
x9s1需要保存的寄存器,保存原进程中的关键数据,避免在函数调用过程中被破坏Callee
x10-x11a0-a1函数参数/返回值Caller
x12-x17a2-a7函数参数Caller
x18-x27s2-s11需要保存的寄存器Callee
x28-x31t3-t6临时寄存器Caller

函数调用时保留的寄存器

被调用函数一般不会使用这些寄存器,即便使用也会提前保存好原值,可以信任。这些寄存器有:sp, gp, tp 和 s0-s11 寄存器。

函数调用时不保存的寄存器

有可能被调用函数使用更改,需要caller在调用前对自己用到的寄存器进行保存。这些寄存器有:参数与返回地址寄存器 a0-a7,返回地址寄存器 ra,临时寄存器 t0-t6

RV32I 基础指令格式

RV32I 指令编码格式

  • 源寄存器和目标寄存器都设计固定在所有 RISC-V 指令同样的位置上,指令译码相对简单,所以指令在 CPU 流水线中执行时,可以先开始访问寄存器,然后再完成指令解码。
  • 所有立即数的符号位总是在指令的最高位。这么做的好处是,有可能成为关键路径的立即数符号扩展可以在指令解码前进行,有利于 CPU 流水线的时序优化。

寄存器-寄存器的算术指令

RV32I 寄存器-寄存器的算术指令

指令汇编格式

加法
add rd, rs1, rs2
减法
sub rd, rs1, rs2
逻辑与
and rd, rs1, rs2
逻辑或
or rd, rs1, rs2
逻辑异或
xor rd, rs1, rs2
有符号小于比较
slt rd, rs1, rs2
无符号小于比较
sltu rd, rs1, rs2
逻辑左移
sll rd, rs1, rs2
逻辑右移
srl rd, rs1, rs2
算数右移
sra rd, rs1, rs2

立即数的算术指令

RV32I 立即数的算术指令

注意,在立即数算术指令中,没有减法运算。

指令汇编格式

立即数加法
addi rd, rs1, imm[11:0]
立即数逻辑与
andi rd, rs1, imm[11:0]
立即数逻辑或
ori rd, rs1, imm[11:0]
立即数逻辑异或
xori rd, rs1, imm[11:0]
立即数有符号小于比较
slti rd, rs1, imm[11:0]
立即数无符号小于比较
sltiu rd, rs1, imm[11:0]
立即数逻辑左移
slli rd, rs1, shamt[4:0]
立即数逻辑右移
srli rd, rs1, shamt[4:0]
立即数算数右移
srai rd, rs1, shamt[4:0]

Load/Store 指令

RV32I Load/Store 指令

Load 和 Store 的寻址模式只能是符号扩展 12 位的立即数,加上基地址寄存器得到访问的存储器地址。因为没有了复杂的内存寻址方式,这让 CPU 流水线可以对数据冲突提前做出判断,并通过流水线各级之间的转送加以处理,而不需要插入空操作(NOP),极大提高了代码的执行效率。

注意,Load指令属于 I 型指令,而 Store 指令属于 S 型指令。

指令汇编格式

字加载
lw rd, offset[11:0](rs1)
半字加载
lh rd, offset[11:0](rs1)
无符号半字加载
lhu rd, offset[11:0](rs1)
字节加载
lb rd, offset[11:0](rs1)
无符号字节加载
lbu rd, offset[11:0](rs1)
字存储
sw rs2, offset[11:0](rs1)
半字存储
sh rs2, offset[11:0](rs1)
字节存储
sb rs2, offset[11:0](rs1)

有条件分支跳转指令

有条件分支跳转指令

指令汇编格式

相等跳转
beq rs1, rs2, label
不等跳转
bne rs1, rs2, label
小于跳转
blt rs1, rs2, label
无符号小于跳转
bltu rs1, rs2, label
大于等于跳转
bge rs1, rs2, label
无符号大于等于跳转
bgeu rs1, rs2, label

无条件跳转指令

注意,直接跳转是 J 型指令,而相对跳转是 I 型指令。

直接跳转指令

无条件直接跳转

JAL 指令的执行过程:

  1. 首先,把 20 位的立即数做符号扩展,并左移一位,产生一个 32 位的符号数
  2. 然后,将该 32 位符号数和 PC 相加来产生目标地址(这样 JAL 可以作为短跳转指令,跳转至 PC±1MB 的地址范围内)
  3. 同时,JAL 会把紧随其后的那条指令的地址,存入目标寄存器中。这样,如果目标寄存器是0,则 JAL 就等同于 goto 指令;如果目标寄存器不为零,JAL 可以实现函数调用的功能

相对跳转指令

无条件相对跳转

JALR 指令会把 12 位立即数和源寄存器相加,并把相加的结果末位清零,作为新的跳转地址。和 JAL 指令一样,JALR 也会把紧随其后的那条指令的地址,存入目标寄存器中。

指令汇编格式

无条件直接跳转
jal rd, label # 将 PC+4 的值保存到 rd 寄存器中,然后设置 PC = PC + offset

伪指令 j 实际上就是jal指令的变体,此时 rd 会被设置为 x0,表示丢弃返回地址

无条件相对跳转
jalr rd, rs1, imm # 将 PC+4 保存到 rd 寄存器中,然后设置 PC = rs1  + imm

跳转到任意 32 位绝对地址处

lui x1, <hi20bits>
jalr ra, x1, <lo12bits>

相对PC地址32位偏移量的相对跳转

auipc x1, <hi20bits>
jalr x0, x1, <lo12bits>

U(Upper immediate)型指令

U型指令

指令汇编格式

lui 指令 (Load Upper Immediate)
lui rd, imm # 将 20 位的立即数左移12位,低 12 位补零,并写回寄存器 rd 中

配合 addi 指令(设置低 12 比特)可实现讲寄存器设置为任意 32 比特的立即数,例如:

lui x10, 0x87654     # x10 = 0x87654000
addi x10, x10, 0x321 # x10 = 0x87654321

但是,当这个 12 位的立即数为负数(即最高比特位是1)时,得到的结果是高 20 位减 1 再和低 12 位拼接,比如:

lui x10, 0xDEADB     # x10 = 0xDEADB000
addi x10, x10, 0xEEF # x10 = 0xDEADBEEF

解决这个问题的一种方法是,如果低 12 位的立即数的符号位是 1 ,那就预先给高 20 位的数加 1。li 伪指令可以替我们处理好这种特殊情况。

auipc 指令 (Add Upper Immediate to PC)
auipc rd, imm # 将 20 位的立即数左移12位,低 12 位补零,将得到的 32 位数与 pc 的值相加,最后写回寄存器 rd 中

具体应用有:

Label: auipc x10, 0 # 将 Label 的地址保存在 x10 寄存器中

指令编码空间的可扩展性

指令编码空间的扩展

  • custom-0、custom-1 用于 RV32 的自定义指令集扩展
  • custom-2、custom-3 预留给 RV128,也可以用于 RV32、RV64 的用户自定义指令集扩展

CSR 寄存器指令

除了内存地址空间和通用寄存器地址空间外,RISC-V 中还定义了一个独立的控制与状态寄存器(CSR)地址空间。

独立的 12 位地址编码空间

CSR寄存器访问指令的编码

专用的 CSR 指令

CSR 指令

其他指令

  • 系统调用 ecall 指令
  • 调试时用于将控制转移到调试环境的 ebreak 指令

常用汇编伪指令

赋值指令
mv rd, rs # 等效于 addi rd, rs, x0
加载立即数
li rd, 13 # 等效于 addi rd, x0, 13
函数调用和返回
jal my_foo # 函数调用
ret # 函数返回,等效于 jr ra,等效于 jalr x0, ra, 0

单核 CPU 组成结构

ALU

数据通路是处理器中执行处理器所需操作的硬件部分,就像是处理器的四肢。

控制器是对数据通路要做什么操作进行行为调度的硬件结构,就像是处理器的大脑。

流水线技术

五级流水线

五级流水线

流水线在不同阶段使用的资源

流水线资源使用

为了确保硬件共享的时候,前一阶段的数据不被丢失,需要在流水线之间插入“阶段寄存器”来保存中间值和控制信号。

数据通路

数据通路

  1. 取指阶段(Instruction Fetch):将指令从存储器中读取出来,PC 寄存器告诉当前指令在存储器中的位置。读取一条指令后,PC 寄存器会根据指令的长度自动递增,或者改写成指定的地址。
  2. 译码阶段(Instruction Decode):将存储器中取出的指令进行翻译,识别出指令的类别以及所需的各种操作数。
  3. 执行阶段(Instruction Execute):对指令进行真正的运算,期间最关键的模块是算术逻辑单元(ALU)。
  4. 访存阶段(Memory Access):存储器访问指令将数据从存储器中读出,或写入存储器。
  5. 写回阶段(Write Back):将指令执行的结果写回通用寄存器。

简易 CPU 内部组件框图

RV32I CPU 5级流水线设计框图

pre_if 模块设计

根据当前的指令和 PC 寄存器,预测下一条指令的地址。为了实现程序分支跳转的功能,就需要设计一个预读取模块,不管指令是否跳转(这个结果会在指令执行阶段结束才能知道),都提前把跳转之后的下一条指令从存储器中读取出来,以备流水线的下一个阶段使用,这能提到 CPU 的执行效率。

module pre_if (
    input [31:0] instr,
    input [31:0] pc,

    output [31:0] pre_pc
);

    wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); // 条件跳转指令的操作码
    wire is_jal = (instr[6:0] == `OPCODE_JAL) ;   // 无条件跳转指令的操作码

    // B型指令的立即数拼接
    wire [31:0] bimm  = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
    // J型指令的立即数拼接
    wire [31:0] jimm  = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};

    // 指令地址的偏移量
    // 这里实际上做了一个简单的分支预测
    wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4;
    // 根据当前 PC 和指令的偏移量相加,得到预测的 PC 值
    assign pre_pc = pc + adder;

endmodule

if_id 模块设计

预读取模块读出的指令并不是全部都能发送给后续的模块执行的,比如条件分支指令在执行后发现跳转条件不成立,这时预读取的指令就是无效的,需要对流水线进行冲刷(flush),把无效的指令都清除掉。

module if_id (
  input         clk,
  input         reset,
  input  [31:0] in_instr,
  input  [31:0] in_pc,
  input         flush,
  input         valid,
  output [31:0] out_instr,
  output [31:0] out_pc,
  output        out_noflush
);

  reg [31:0] reg_instr;
  reg [31:0] reg_pc;
  reg        reg_noflush;

  assign out_instr = reg_instr;
  assign out_pc = reg_pc;
  assign out_noflush = reg_noflush;

  //指令传递
  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_instr <= 32'h0;
    end else if (flush) begin
      reg_instr <= 32'h0;
    end else if (valid) begin
      reg_instr <= in_instr;
    end
  end

  //PC值转递
  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_pc <= 32'h0;
    end else if (flush) begin
      reg_pc <= 32'h0;
    end else if (valid) begin
      reg_pc <= in_pc;
    end
  end

  //流水线冲刷标志位
  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_noflush <= 1'h0;
    end else if (flush) begin
      reg_noflush <= 1'h0;
    end else if (valid) begin
      reg_noflush <= 1'h1;
    end

  end

endmodule

decode 模块设计

尽管指令格式不同,但是指令译码模块翻译指令的工作机制是统一的。首先会翻译出指令中携带的寄存器索引、立即数等信息,接着处理可能存在的数据冒险,再由译码数据通路负责把译码后的指令信息,发送给对应的执行单元去执行。

译码的过程:先识别指令的操作码(永远是低7位),根据操作码对应的代码标识,产生分支信号 branch、跳转信号 jump、读存储器信号 mem_read ......

module decode (
  input   [31:0] instr,

  output  [4:0] rs1_addr,
  output  [4:0] rs2_addr,
  output  [4:0] rd_addr,
  output  [2:0] funct3,
  output  [6:0] funct7,
  output        branch,
  output [1:0]  jump,
  output        mem_read,
  output        mem_write,
  output        reg_write,
  output        to_reg,
  output [1:0]  result_sel,
  output        alu_src,
  output        pc_add,
  output [6:0]  types,
  output [1:0]  alu_ctrlop,
  output        valid_inst,
  output [31:0] imm
);

localparam DEC_INVALID = 21'b0;

reg [20:0] dec_array;

//---------- decode rs1、rs2 -----------------
assign rs1_addr = instr[19:15];
assign rs2_addr = instr[24:20];

//---------- decode rd -----------------------
assign rd_addr = instr[11:7];

//---------- decode funct3、funct7 -----------
assign funct7 = instr[31:25];
assign funct3 = instr[14:12];

// ----------------------------- decode signals ---------------------------------

//                        20     19-18  17       16        15        14     13-12      11      10     9--------3  2---1      0
//                        branch jump   memRead  memWrite  regWrite  toReg  resultSel  aluSrc  pcAdd     RISBUJZ  aluctrlop  validInst
localparam DEC_LUI     = {1'b0,  2'b00, 1'b0,    1'b0,     1'b1,     1'b0,  2'b01,     1'b0,   1'b0,  7'b0000100, 2'b00,     1'b1};
localparam DEC_AUIPC   = {1'b0,  2'b00, 1'b0,    1'b0,     1'b1,     1'b0,  2'b00,     1'b1,   1'b1,  7'b0000100, 2'b00,     1'b1};
localparam DEC_JAL     = {1'b0,  2'b00, 1'b0,    1'b0,     1'b1,     1'b0,  2'b10,     1'b0,   1'b0,  7'b0000010, 2'b00,     1'b1};
localparam DEC_JALR    = {1'b0,  2'b11, 1'b0,    1'b0,     1'b1,     1'b0,  2'b10,     1'b1,   1'b0,  7'b0100000, 2'b00,     1'b1};
localparam DEC_BRANCH  = {1'b1,  2'b00, 1'b0,    1'b0,     1'b0,     1'b0,  2'b00,     1'b0,   1'b0,  7'b0001000, 2'b10,     1'b1};
localparam DEC_LOAD    = {1'b0,  2'b00, 1'b1,    1'b0,     1'b1,     1'b1,  2'b00,     1'b1,   1'b0,  7'b0100000, 2'b00,     1'b1};
localparam DEC_STORE   = {1'b0,  2'b00, 1'b0,    1'b1,     1'b0,     1'b0,  2'b00,     1'b1,   1'b0,  7'b0010000, 2'b00,     1'b1};
localparam DEC_ALUI    = {1'b0,  2'b00, 1'b0,    1'b0,     1'b1,     1'b0,  2'b00,     1'b1,   1'b0,  7'b0100000, 2'b01,     1'b1};
localparam DEC_ALUR    = {1'b0,  2'b00, 1'b0,    1'b0,     1'b1,     1'b0,  2'b00,     1'b0,   1'b0,  7'b1000000, 2'b01,     1'b1};

assign  {branch, jump, mem_read, mem_write, reg_write, to_reg, result_sel, alu_src, pc_add, types, alu_ctrlop, valid_inst} = dec_array;


always @(*) begin
  //$write("%x", instr);
  case(instr[6:0])
    `OPCODE_LUI    :   dec_array <= DEC_LUI;
    `OPCODE_AUIPC  :   dec_array <= DEC_AUIPC;
    `OPCODE_JAL    :   dec_array <= DEC_JAL;
    `OPCODE_JALR   :   dec_array <= DEC_JALR;
    `OPCODE_BRANCH :   dec_array <= DEC_BRANCH;
    `OPCODE_LOAD   :   dec_array <= DEC_LOAD;
    `OPCODE_STORE  :   dec_array <= DEC_STORE;
    `OPCODE_ALUI   :   dec_array <= DEC_ALUI;
    `OPCODE_ALUR   :   dec_array <= DEC_ALUR;
    default        :  begin
                 dec_array <= DEC_INVALID;
               //  $display("~~~decode error~~~%x", instr);
    end
  endcase
end

// -------------------- IMM -------------------------

wire [31:0] Iimm = {{21{instr[31]}}, instr[30:20]};
wire [31:0] Simm = {{21{instr[31]}}, instr[30:25], instr[11:7]};
wire [31:0] Bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
wire [31:0] Uimm = {instr[31:12], 12'b0};
wire [31:0] Jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};

assign imm = {32{types[5]}} & Iimm
           | {32{types[4]}} & Simm
           | {32{types[3]}} & Bimm
           | {32{types[2]}} & Uimm
           | {32{types[1]}} & Jimm;

endmodule

前面译码模块得到的指令信号可以分为两大类,一类是指令的操作码经过译码后产生的指令控制信号,另一类是从指令源码中提取出来的数据信息,如立即数、寄存器索引、功能码等。为了能对流水线更好地实施控制,我们把译码后的数据和控制信号分开处理。

译码控制模块

当指令发生冲突时,需要对流水线进行冲刷,译码阶段的指令信息也需要清除。

module id_ex_ctrl (
  input        clk,
  input        reset,
  input        in_ex_ctrl_itype,
  input  [1:0] in_ex_ctrl_alu_ctrlop,
  input  [1:0] in_ex_ctrl_result_sel,
  input        in_ex_ctrl_alu_src,
  input        in_ex_ctrl_pc_add,
  input        in_ex_ctrl_branch,
  input  [1:0] in_ex_ctrl_jump,
  input        in_mem_ctrl_mem_read,
  input        in_mem_ctrl_mem_write,
  input  [1:0] in_mem_ctrl_mask_mode,
  input        in_mem_ctrl_sext,
  input        in_wb_ctrl_to_reg,
  input        in_wb_ctrl_reg_write,
  input        in_noflush,
  input        flush,
  input        valid,
  output       out_ex_ctrl_itype,
  output [1:0] out_ex_ctrl_alu_ctrlop,
  output [1:0] out_ex_ctrl_result_sel,
  output       out_ex_ctrl_alu_src,
  output       out_ex_ctrl_pc_add,
  output       out_ex_ctrl_branch,
  output [1:0] out_ex_ctrl_jump,
  output       out_mem_ctrl_mem_read,
  output       out_mem_ctrl_mem_write,
  output [1:0] out_mem_ctrl_mask_mode,
  output       out_mem_ctrl_sext,
  output       out_wb_ctrl_to_reg,
  output       out_wb_ctrl_reg_write,
  output       out_noflush
);

  reg  reg_ex_ctrl_itype;
  reg [1:0] reg_ex_ctrl_alu_ctrlop;
  reg [1:0] reg_ex_ctrl_result_sel;
  reg  reg_ex_ctrl_alu_src;
  reg  reg_ex_ctrl_pc_add;
  reg  reg_ex_ctrl_branch;
  reg [1:0] reg_ex_ctrl_jump;
  reg  reg_mem_ctrl_mem_read;
  reg  reg_mem_ctrl_mem_write;
  reg [1:0] reg_mem_ctrl_mask_mode;
  reg  reg_mem_ctrl_sext;
  reg  reg_wb_ctrl_to_reg;
  reg  reg_wb_ctrl_reg_write;
  reg  reg_noflush;

  assign out_ex_ctrl_itype = reg_ex_ctrl_itype;
  assign out_ex_ctrl_alu_ctrlop = reg_ex_ctrl_alu_ctrlop;
  assign out_ex_ctrl_result_sel = reg_ex_ctrl_result_sel;
  assign out_ex_ctrl_alu_src = reg_ex_ctrl_alu_src;
  assign out_ex_ctrl_pc_add = reg_ex_ctrl_pc_add;
  assign out_ex_ctrl_branch = reg_ex_ctrl_branch;
  assign out_ex_ctrl_jump = reg_ex_ctrl_jump;
  assign out_mem_ctrl_mem_read = reg_mem_ctrl_mem_read;
  assign out_mem_ctrl_mem_write = reg_mem_ctrl_mem_write;
  assign out_mem_ctrl_mask_mode = reg_mem_ctrl_mask_mode;
  assign out_mem_ctrl_sext = reg_mem_ctrl_sext;
  assign out_wb_ctrl_to_reg = reg_wb_ctrl_to_reg;
  assign out_wb_ctrl_reg_write = reg_wb_ctrl_reg_write;
  assign out_noflush = reg_noflush;

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_itype <= 1'h0;
    end else if (flush) begin
      reg_ex_ctrl_itype <= 1'h0;
    end else if (valid) begin
      reg_ex_ctrl_itype <= in_ex_ctrl_itype;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_alu_ctrlop <= 2'h0;
    end else if (flush) begin
      reg_ex_ctrl_alu_ctrlop <= 2'h0;
    end else if (valid) begin
      reg_ex_ctrl_alu_ctrlop <= in_ex_ctrl_alu_ctrlop;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_result_sel <= 2'h0;
    end else if (flush) begin
      reg_ex_ctrl_result_sel <= 2'h0;
    end else if (valid) begin
      reg_ex_ctrl_result_sel <= in_ex_ctrl_result_sel;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_alu_src <= 1'h0;
    end else if (flush) begin
      reg_ex_ctrl_alu_src <= 1'h0;
    end else if (valid) begin
      reg_ex_ctrl_alu_src <= in_ex_ctrl_alu_src;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_pc_add <= 1'h0;
    end else if (flush) begin
      reg_ex_ctrl_pc_add <= 1'h0;
    end else if (valid) begin
      reg_ex_ctrl_pc_add <= in_ex_ctrl_pc_add;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_branch <= 1'h0;
    end else if (flush) begin
      reg_ex_ctrl_branch <= 1'h0;
    end else if (valid) begin
      reg_ex_ctrl_branch <= in_ex_ctrl_branch;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_ex_ctrl_jump <= 2'h0;
    end else if (flush) begin
      reg_ex_ctrl_jump <= 2'h0;
    end else if (valid) begin
      reg_ex_ctrl_jump <= in_ex_ctrl_jump;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_mem_ctrl_mem_read <= 1'h0;
    end else if (flush) begin
      reg_mem_ctrl_mem_read <= 1'h0;
    end else if (valid) begin
      reg_mem_ctrl_mem_read <= in_mem_ctrl_mem_read;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_mem_ctrl_mem_write <= 1'h0;
    end else if (flush) begin
      reg_mem_ctrl_mem_write <= 1'h0;
    end else if (valid) begin
      reg_mem_ctrl_mem_write <= in_mem_ctrl_mem_write;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_mem_ctrl_mask_mode <= 2'h0;
    end else if (flush) begin
      reg_mem_ctrl_mask_mode <= 2'h0;
    end else if (valid) begin
      reg_mem_ctrl_mask_mode <= in_mem_ctrl_mask_mode;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_mem_ctrl_sext <= 1'h0;
    end else if (flush) begin
      reg_mem_ctrl_sext <= 1'h0;
    end else if (valid) begin
      reg_mem_ctrl_sext <= in_mem_ctrl_sext;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_wb_ctrl_to_reg <= 1'h0;
    end else if (flush) begin
      reg_wb_ctrl_to_reg <= 1'h0;
    end else if (valid) begin
      reg_wb_ctrl_to_reg <= in_wb_ctrl_to_reg;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_wb_ctrl_reg_write <= 1'h0;
    end else if (flush) begin
      reg_wb_ctrl_reg_write <= 1'h0;
    end else if (valid) begin
      reg_wb_ctrl_reg_write <= in_wb_ctrl_reg_write;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_noflush <= 1'h0;
    end else if (flush) begin
      reg_noflush <= 1'h0;
    end else if (valid) begin
      reg_noflush <= in_noflush;
    end
  end


endmodule

译码数据通路模块

译码数据通路会根据 CPU 相关控制模块产生的流水线冲刷控制信号,决定要不要把这些数据发送给后续模块。

module id_ex (
  input         clk,
  input         reset,
  input  [4:0]  in_rd_addr,
  input  [6:0]  in_funct7,
  input  [2:0]  in_funct3,
  input  [31:0] in_imm,
  input  [31:0] in_rs2_data,
  input  [31:0] in_rs1_data,
  input  [31:0] in_pc,
  input  [4:0]  in_rs1_addr,
  input  [4:0]  in_rs2_addr,
  input         flush,
  input         valid,
  output [4:0]  out_rd_addr,
  output [6:0]  out_funct7,
  output [2:0]  out_funct3,
  output [31:0] out_imm,
  output [31:0] out_rs2_data,
  output [31:0] out_rs1_data,
  output [31:0] out_pc,
  output [4:0]  out_rs1_addr,
  output [4:0]  out_rs2_addr
);
  reg [4:0] reg_rd_addr;
  reg [6:0] reg_funct7;
  reg [2:0] reg_funct3;
  reg [31:0] reg_imm;
  reg [31:0] reg_rs2_data;
  reg [31:0] reg_rs1_data;
  reg [31:0] reg_pc;
  reg [4:0] reg_rs1_addr;
  reg [4:0] reg_rs2_addr;

  assign out_rd_addr = reg_rd_addr;
  assign out_funct7 = reg_funct7;
  assign out_funct3 = reg_funct3;
  assign out_imm = reg_imm;
  assign out_rs2_data = reg_rs2_data;
  assign out_rs1_data = reg_rs1_data;
  assign out_pc = reg_pc;
  assign out_rs1_addr = reg_rs1_addr;
  assign out_rs2_addr = reg_rs2_addr;

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_rd_addr <= 5'h0;
    end else if (flush) begin
      reg_rd_addr <= 5'h0;
    end else if (valid) begin
      reg_rd_addr <= in_rd_addr;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_funct7 <= 7'h0;
    end else if (flush) begin
      reg_funct7 <= 7'h0;
    end else if (valid) begin
      reg_funct7 <= in_funct7;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_funct3 <= 3'h0;
    end else if (flush) begin
      reg_funct3 <= 3'h0;
    end else if (valid) begin
      reg_funct3 <= in_funct3;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_imm <= 32'h0;
    end else if (flush) begin
      reg_imm <= 32'h0;
    end else if (valid) begin
      reg_imm <= in_imm;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_rs2_data <= 32'h0;
    end else if (flush) begin
      reg_rs2_data <= 32'h0;
    end else if (valid) begin
      reg_rs2_data <= in_rs2_data;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_rs1_data <= 32'h0;
    end else if (flush) begin
      reg_rs1_data <= 32'h0;
    end else if (valid) begin
      reg_rs1_data <= in_rs1_data;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_pc <= 32'h0;
    end else if (flush) begin
      reg_pc <= 32'h0;
    end else if (valid) begin
      reg_pc <= in_pc;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_rs1_addr <= 5'h0;
    end else if (flush) begin
      reg_rs1_addr <= 5'h0;
    end else if (valid) begin
      reg_rs1_addr <= in_rs1_addr;
    end
  end

  always @(posedge clk or posedge reset) begin
    if (reset) begin
      reg_rs2_addr <= 5'h0;
    end else if (flush) begin
      reg_rs2_addr <= 5'h0;
    end else if (valid) begin
      reg_rs2_addr <= in_rs2_addr;
    end
  end

endmodule

执行控制模块

在指令执行阶段,存储访问指令用 ALU 进行地址计算,条件分支跳转指令用 ALU 进行条件比较,算术逻辑指令用 ALU 进行逻辑运算。

module alu_ctrl (
    input [2:0]  funct3,
    input [6:0]  funct7,
    input [1:0]  aluCtrlOp,
    input        itype,
    output reg [3:0] aluOp
);
    always @(*) begin
      case(aluCtrlOp)
        2'b00:  aluOp <= `ALU_OP_ADD;           // Load or Store
        2'b01:  begin
          if(itype & funct3[1:0] != 2'b01)
            aluOp <= {1'b0, funct3};
          else
            aluOp <= {funct7[5], funct3};   // normal ALUI/ALUR
        end
        2'b10:  begin
         // $display("~~~aluCtrl bxx~~~%d", funct3);
          case(funct3)                    // bxx
            `BEQ_FUNCT3:  aluOp <= `ALU_OP_EQ;
            `BNE_FUNCT3:  aluOp <= `ALU_OP_NEQ;
            `BLT_FUNCT3:  aluOp <= `ALU_OP_SLT;
            `BGE_FUNCT3:  aluOp <= `ALU_OP_GE;
            `BLTU_FUNCT3: aluOp <= `ALU_OP_SLTU;
            `BGEU_FUNCT3: aluOp <= `ALU_OP_GEU;
            default:      aluOp <= `ALU_OP_XXX;
          endcase
          end
        default: aluOp <= `ALU_OP_XXX;
      endcase
    end
endmodule

通用寄存器模块

module gen_regs (
    input  clk,
    input  reset,
    input  wen,
    input  [4:0] regRAddr1, regRAddr2, regWAddr,
    input  [31:0] regWData,
    output [31:0] regRData1,
    output [31:0] regRData2
);
    integer ii;
    reg [31:0] regs[31:0];

    // write registers
    always @(posedge clk or posedge reset) begin
        if(reset) begin
            for(ii=0; ii<32; ii=ii+1)
                regs[ii] <= 32'b0;
        end
        else if(wen & (|regWAddr))
                regs[regWAddr] <= regWData;
    end

    // read registers
    assign regRData1 = wen & (regWAddr == regRAddr1) ? regWData
                    : ((regRAddr1 != 5'b0) ? regs[regRAddr1] : 32'b0);
    assign regRData2 = wen & (regWAddr == regRAddr2) ? regWData
                    : ((regRAddr2 != 5'b0) ? regs[regRAddr2] : 32'b0);

endmodule

写寄存器是边沿触发的,在一个时钟周期内写入的存储器数据,需要在写一个时钟周期才能把写入的数据读取出来。为了提高读写效率,在对同一个寄存器进行读写时,如果写使能 wen 有效,就直接把写入寄存器的数据送给读数据接口。

ALU 模块

module alu (
  input  [31:0] alu_data1_i,
  input  [31:0] alu_data2_i,
  input  [ 3:0] alu_op_i,
  output [31:0] alu_result_o
);

  reg  [31:0] result;

  // alu_op_i 的第3位和第1位为1时,做减法运算,这是为减法指令或者比较大小而准备的
  wire [31:0] sum    = alu_data1_i + ((alu_op_i[3] | alu_op_i[1]) ? -alu_data2_i : alu_data2_i);
  // 根据前面两个操作数相减的结果判断两个操作数是否相等
  wire        neq    = |sum;
  // 比较两个操作数的大小:
  // 如果操作数的符号位相同,则根据两个操作数相减的差值的符号位去判断
  // 如果操作数的符号位不同,先根据alu_op_i 的最低位判断是否是无符号数比较运算
  wire        cmp    = (alu_data1_i[31] == alu_data2_i[31]) ? sum[31]
                     : alu_op_i[0] ? alu_data2_i[31] : alu_data1_i[31];
  wire [ 4:0] shamt  = alu_data2_i[4:0];
  // 判断是左移还是右移,如果是左移,就先对源操作数做镜像处理
  wire [31:0] shin   = alu_op_i[2] ? alu_data1_i : reverse(alu_data1_i);
  // 判断是算术右移还是逻辑右移,如果是算术右移,需要在最高位补一个符号位
  wire [32:0] shift  = {alu_op_i[3] & shin[31], shin};
  // $signed() 函数会在右移操作前先把操作数的符号位扩位成跟结果相同的位宽
  wire [32:0] shiftt = ($signed(shift) >>> shamt);
  wire [31:0] shiftr = shiftt[31:0];
  // 左移的结果是右移后的结果再进行镜像处理
  wire [31:0] shiftl = reverse(shiftr);

  always @(*) begin
    case(alu_op_i)
      `ALU_OP_ADD:    result <= sum;
      `ALU_OP_SUB:    result <= sum;
      `ALU_OP_SLL:    result <= shiftl;
      `ALU_OP_SLT:    result <= cmp;
      `ALU_OP_SLTU:   result <= cmp;
      `ALU_OP_XOR:    result <= (alu_data1_i ^ alu_data2_i);
      `ALU_OP_SRL:    result <= shiftr;
      `ALU_OP_SRA:    result <= shiftr;
      `ALU_OP_OR:     result <= (alu_data1_i | alu_data2_i);
      `ALU_OP_AND:    result <= (alu_data1_i & alu_data2_i);

      `ALU_OP_EQ:     result <= {31'b0, ~neq};
      `ALU_OP_NEQ:    result <= {31'b0, neq};
      `ALU_OP_GE:     result <= {31'b0, ~cmp};
      `ALU_OP_GEU:    result <= {31'b0, ~cmp};
      default:        begin
                      result <= 32'b0;
                      //$display("*** alu error ! ***%x", alu_op_i);
        end
    endcase
  end

  function [31:0] reverse;
    input [31:0] in;
    integer i;
    for(i=0; i<32; i=i+1) begin
      reverse[i] = in[31-i];
    end
  endfunction

  assign alu_result_o = result;

endmodule
  • 左移运算复用了右移运算的电路,方便实现

完整的数据通路

完整的数据通路

  • 译码阶段,会将指令的功能码和操作码发送给控制器,来产生相应的控制信号
  • 立即数扩展信号:ImmSel
  • ALU 功能选择信号:ALUSel

控制器的设计

控制器的设计

R 型指令数据通路

R型指令数据通路

  • ALUSel 会根据指令的 funct3来取不同的值

I 型指令数据通路

I型指令数据通路

Load 指令数据通路

Load指令数据通路

Store 指令数据通路

Store指令数据通路

  • 立即数来自inst[31:25][11:7],这个和Load不同
  • Store指令没有写回阶段

B 型指令数据通路

B指令数据通路

  • 访存写回阶段

jalr 指令数据通路

jalr指令数据通路

  • PC+4 的值会保存到rd

jal 指令数据通路

jal指令数据通路

Cache

Cache

Cache 的结构

Cache的结构

  • 块(block):两级存储器层次结构中存储器信息交换的最小单元
  • 命中(hit):如果处理器需要的数据存放在高层存储器中的某个块中,称为一次命中
  • 缺失(miss):如果在高层存储器中没有找到所需的数据,这次数据请求称为一次缺失
    • 缺失代价(miss penalty):将相应的块从底层存储器替换到高层存储器的时间+将该信息块传送给处理器的时间

Cache 直接映射

直接映射:一种 cache 结构,其中每个存储器地址仅仅对应到 cache 中的一个位置

映射方法:(块地址)mod(cache 中的块数)

标记:表中的一个字段,包含了地址信息,这些地址信息可以用来判断cache中的字是否就是所请求的字

有效位:表中的一个字段,用来标识一个块是否包含有一个有效数据

Cache直接映射

Cache直接映射示例

缺点:利用率低,命中率低

Cache 全相联映射

全相联映射:一个块可以被放置在 cache 中的任何位置

Cache全相联映射

Cache全相联映射示例

缺点:硬件开销大(有多少cache块就配有相等数量的比较器)

Cache 组相联映射

在组相联映射中,每个块可被放置的位置数是固定的,每个块有 n 个位置可放的 cache 被称为 n 路组相联 Cache

Cache组相联映射

Cache组相联映射示例

四路组相联 Cache:

  • 4 个比较器
  • 1 个四选一多路选择器

Cache 的设计

  • 要考虑的维度
    • Cache 的容量
    • 块大小
    • 组织方式(Direct,Fully Associative,Set Associative)
    • 替换算法(FIFO,LRU)
    • 写策略(write-through, write-back)

虚拟地址

虚拟存储器管理

分段管理

分段管理

分段管理:将一个程序按照逻辑单元分成多个程序段,每一个段使用自己单独的虚拟地址空间。

  • 逻辑上相互独立
  • 容易实现共享和保护
  • 非常容易产生碎片(段长是不确定的)

分页管理

分页管理

  • 如果页表项为4字节,那么整张页表会占据4MB大小的内存空间

两级分页管理

两级分页管理

  • 4KB的页目录+4KB的页表

快速地址转换 TLB

块表

块表(Translation-Lookaside Buffer):用于记录最近使用地址的映射信息的高速缓存,从而可以避免每次都要访问页表

使用 TLB 进行地址转换

TLB的位置

TLB实现地址转换的原理

TLB虚实地址转换

特权级别

一个 RISC-V 的硬件线程在任一时刻只能运行在某一个特权级上,这个特权级由 CSR 指定和配置。

名称级别缩写编码说明
用户应用程序特权级0U00运行应用程序,同样也适用于嵌入式系统
管理员特权级1S01主要用于支持现代操作系统,如Linux
虚拟机监视特权级2H10支持虚拟机监视器
机器特权级3M11对内存、I/O和一些必要的底层功能(启动和系统配置)有着完全的控制权

标准寄存器列表

Machine Mode

名称地址属性备注
mvendorid0xF11RO商业供应商编号寄存器
marchid0xF12RO架构编号寄存器
mimpid0xF13RO硬件实现编号寄存器
mhartid0xF14ROHart编号寄存器 (Hart: Hardware Thread)
mstatus0x300RW异常处理状态寄存器
misa0x301RO指令集架构寄存器
mie0x304RW局部中断屏蔽控制寄存器
mtvec0x305RW异常入口基地址寄存器
mtvt0x307RW中断向量表的基地址,至少为 64byte 对齐
mscratch0x340RW暂存寄存器,比如进入异常处理模式后,将应用程序的用户的 sp 寄存器临时保存到这个寄存器中
mepc0x341RW异常PC寄存器
mcause0x342RW异常原因寄存器
mtval0x343RW异常值寄存器,保存进入异常之前出错指令的编码值或者存储器访问的地址值
mip0x344RW中断等待寄存器
mnxti0x345RW读操作返回值是下一个中断的handler地址,写回操作会更新中断使能的状态
mintstatus0x346RO用于保存当前中断 Level
mscratchcsw0x348RW用于在特权模式变化时交换mscratch与目的寄存器的值
mscratchcswl0x349RW用于在中断Level变化时交换mscratch与目的寄存器的值
mcycle0xB00RW周期计数器的低32位
mcycleh0xB80RW周期计数器的高32位
minstret0xB02RW完成指令计数器的低32位,该寄存器用于衡量处理器的性能
minstrech0xB82RW完成指令计数器的高32位

User Mode

名称地址属性备注
cycle0xC00ROmcycle寄存器的只读副本
time0xC01ROmtime寄存器的只读副本
instret0xC02ROminstret寄存器的只读副本
cycleh0xC80ROmcycleh寄存器的只读副本
timeh0xC81ROmtimeh寄存器的只读副本
instreth0xC82ROminstreth寄存器的只读副本

RISC-V 的中断

进入中断

退出中断

中断和异常相关的寄存器

异常相关的CSR寄存器

异常相关的CSR寄存器具体定义

mstatus

  • MIE:为1表示中断的全局开关打开,中断能够被正常响应
  • FS:维护浮点单元的状态。上电默认为0,表示Off,为了能够正常使用浮点单元,软件需要使用 CSR 写指令将 FS 的值改写为非 0 值以打开浮点单元的功能。操作系统在进行上下文切换的时候,需要通过该值来判断是否需要对浮点单元进行上下文的保存
  • XS:维护用户自定义的扩展指令单元状态,类似与 FS

mtvec

mtvec寄存器

异常代码

异常代码

中断返回

中断返回

中断屏蔽与中断等待

中断屏蔽和等待相关额寄存器

中断优先级

中断优先级

单指令数据通路的中断响应与退出

中断响应

中断退出

GCC 工具链基础

GCC 实质上不是一个单独的程序,而是多个程序的集合,因此通常称为 GCC 工具链。工具链软件包括 GCC、C 运行库、Binutils 和 GDB 等等。

  • GCC(GNU C Compiler)是编译工具,可以将 C/C++ 语言编写的程序转换成为处理器能够执行的二进制代码。
  • GDB(GNU Project Debugger)是调试工具,可以用于对程序进行调试。

Binutils

这是一组二进制程序的处理工具,包括:addr2linearobjcopyobjdumpasldlddreadelfsize等。

  • addr2line:用来将程序地址转换成其所对应的程序源文件及所对应的代码行,也可以得到所对应的函数。该工具将帮助调试器在调试的过程中定位对应的源代码位置。
  • as:主要用于汇编。
  • ld:主要用于链接。
  • ar:主要用于创建静态库
    • 如果要将多个 .o 目标文件生成一个库文件,则存在两种类型的库,一种是静态库,另一种是动态库。
    • 在 Windows 中静态库是以 .lib 为后缀的文件,共享库是以 .dll 为后缀的文件。在 Linux 中静态库是以 .a 为后缀的文件,共享库是以 .so 为后缀的文件。
    • 静态库和动态库的不同点在于代码被载入的时刻不同。静态库的代码在编译过程中已经被载入可执行程序,因此体积较大。共享库的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,因此代码体积较小。在 Linux 系统中,可以用 ldd 命令查看一个可执行程序依赖的共享库。
    • 如果一个系统中存在多个需要同时运行的程序且这些程序之间存在共享库,那么采用动态库的形式将更节省内存。但是对于嵌入式系统,大多数情况下都是整个软件就是一个可执行程序且不支持动态加载的方式,即以静态库为主。
  • ldd:可以用于查看一个可执行程序依赖的共享库
  • objcopy:将一种对象文件翻译成另一种格式,比如将.bin 转换成 .elf、或者将 .elf 转换成 .bin 等。
  • objdump:主要的作用是反汇编。
  • readelf:显示有关 ELF 文件的信息。
  • size:列出可执行文件每个部分的尺寸和总尺寸,代码段、数据段、总大小等。

C 运行库

C语言标准主要由两部分组成:一部分描述 C 的语法,另一部分描述 C 标准库。C 标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义,比如常见的 printf 函数便是一个 C 标准库函数,其原型定义在 stdio 头文件中。

C 语言标准仅仅定义了 C 标准库函数原型,并没有提供实现。因此,C 编译器需要一个 C 运行时库(C Run Time Libray,CRT)的支持。

glibc (GNU C Library) 是 Linux 下的 C 标准库的实现:

  • glibc 本身是 GNU 旗下的 C 标准库,后来逐渐成为了 Linux 的标准 C 库。glibc 的主体分布在 Linux 系统的 /lib 与 /usr/lib 目录中,包括 libc 标准 C 函数库、libm 数学函式库等,都以 .so 结尾。

    Linux 系统下的标准 C 库不只有 glibc,还存在 uclibc、klibc、musl 等等,但是 glibc 使用最为广泛。

    嵌入式系统中使用较多的 C 运行库是 newlib

  • Linux 系统通常将 libc 库作为操作系统的一部分,它被视为操作系统与用户程序的接口。比如:glibc 不仅实现标准 C 语言中的函数,还封装了操作系统提供的系统调用。

    • 通常情况,每个特定的系统调用对应了至少一个 glibc 封装的库函数,比如系统调用 sys_open 对应的是glibc 中的 open 函数;其次,glibc 一个单独的 API 可能会调用多个系统调用,比如 printf 函数会调用如 sys_opensys_mmapsys_writesys_close 等系统调用;另外,多个 glibc API 也可能对应同一个系统调用,如 glibc 下实现的 mallocfree 等函数用来分配和释放内存,都是基于内核的sys_brk 的系统调用。
  • 对于 C++ 语言,常用的 C++ 标准库为 libstdc++。通常 libstdc++ 与 GCC 捆绑在一起的,即安装 gcc 的时候会把 libstdc++ 装上。而 glibc 并没有和 GCC 捆绑于一起,这是因为 glibc 需要与操作系统内核打交道,因此其与具体的操作系统平台紧密耦合。而 libstdc++ 虽然提供了 c++ 程序的标准库,但其并不与内核打交道。对于系统级别的事件,libstdc++ 会与 glibc 交互,从而和内核通信。

编译过程

准备 Hello World 程序

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

预处理

预处理的过程主要包括以下过程:

  • 将所有的 #define 删除,并且展开所有的宏定义,并且处理所有的条件预编译指令,比如 #if #ifdef #elif #else #endif 等。
  • 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。
  • 删除所有注释 ///**/
  • 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
  • 保留所有的 #pragma 编译器指令,后续编译过程需要使用它们。

使用 gcc 进行预处理的命令如下:

gcc -E hello.c -o hello.i  # 将源文件 hello.c 文件预处理生成 hello.i

hello.i 文件可以作为普通文本文件打开进行查看,其代码片段如下所示:

extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 942 "/usr/include/stdio.h" 3 4

# 2 "hello.c" 2


# 3 "hello.c"
int
main(void)
{
  printf("Hello World!" "\n");
  return 0;
}

编译

编译过程就是对预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码。

使用 gcc 进行编译的命令如下:

gcc -S hello.i -o hello.s  # 将预处理生成的 hello.i 文件编译生成汇编程序 hello.s

上述命令生成的汇编程序 hello.s 的代码片段如下所示,其全部为汇编代码。

main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $.LC0, %edi
    call    puts
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc

汇编

汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为 .o 的目标文件中。由于每一个汇编语句几乎都对应一条处理器指令,因此,汇编相对于编译过程比较简单,通过调用 Binutils 中的汇编器 as 根据汇编指令和处理器指令的对照表一一翻译即可。

当程序由多个源代码文件构成时,每个文件都要先完成汇编工作,生成 .o 目标文件后,才能进入下一步的链接工作。注意:目标文件已经是最终程序的某一部分了,但是在链接之前还不能运行。

使用 gcc 进行汇编的命令如下:

gcc -c hello.s -o hello.o  # 将编译生成的 hello.s 文件汇编生成目标文件 hello.o
# 或者直接调用 as 进行汇编
as -c hello.s -o hello.o  # 使用 Binutils 中的 as 将 hello.s 文件汇编生成目标文件

:::warning

hello.o 目标文件为 ELF(Executable and Linkable Format) 格式的可重定向文件。

:::

链接

经过汇编以后的目标文件还不能直接运行,为了变成能够被加载的可执行文件,文件中必须包含固定格式的信息头,还必须与系统提供的启动代码链接起来才能正常运行,这些工作都是由链接器来完成的。

GCC 可以通过调用 Binutils 中的链接器 ld 来链接程序运行需要的所有目标文件,以及所依赖的其它库文件,最后生成一个 ELF 格式可执行文件。

如果直接调用 Binutils 中的ld 进行链接,命令如下,则会报出错误:

# 直接调用ld试图将hello.o文件链接成为最终的可执行文件hello
ld hello.o –o hello
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
hello.o: In function `main':
hello.c:(.text+0xa): undefined reference to `puts'

之所以直接用 ld 进行链接会报错是因为仅仅依靠一个 hello.o 目标文件还无法链接成为一个完整的可执行文件,需要明确的指明其需要的各种依赖库和引导程序以及链接脚本,此过程在嵌入式软件开发时是必不可少的。而在 Linux 系统中,可以直接使用 gcc 命令执行编译直至链接的过程,gcc 会自动将所需的依赖库以及引导程序链接在一起成为 Linux 系统可以加载的 ELF 格式可执行文件。使用 gcc 进行编译直至链接的命令如下:

gcc hello.c -o hello   # 将 hello.c 文件编译汇编链接生成可执行文件 hello
./hello                 # 成功执行该文件,在终端上会打印 Hello World!字符串 Hello World!

注意:hello 可执行文件为 ELF(Executable and Linkable Format)格式的可执行文件。

链接分为静态链接和动态链接:

  • 静态链接是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。

  • 而动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。

    • 在 Linux 系统中,gcc 编译链接时的动态库搜索路径的顺序通常为:首先从 gcc 命令的参数 -L 指定的路径寻找;再从环境变量 LIBRARY_PATH 指定的路径寻址;再从默认路径 /lib/usr/lib/usr/local/lib 中寻找。
    • 在 Linux 系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;再从环境变量 LD_LIBRARY_PATH 指定的路径寻址;再从配置文件 /etc/ld.so.conf 中指定的动态库搜索路径;再从默认路径 /lib/usr/lib 中寻找。
    • 在 Linux 系统中,可以用 ldd 命令查看一个可执行程序依赖的共享库。
  • 由于链接动态库和静态库的路径可能有重合,所以如果在路径中有同名的静态库文件和动态库文件,比如libtest.a 和 libtest.so,gcc 链接时默认优先选择动态库,会链接 libtest.so,如果要让 gcc 选择链接 libtest.a则可以指定 gcc 选项 -static,该选项会强制使用静态库进行链接。

    • 如果使用命令 gcc hello.c -o hello 则会使用动态库进行链接,生成的 ELF 可执行文件的大小(使用Binutils 的 size 命令查看)和链接的动态库(使用 Binutils 的 ldd 命令查看)如下所示:

      $ gcc hello.c -o hello
      $ size hello   # 使用size查看大小
         text    data     bss     dec     hex     filename
         1183     552       8    1743     6cf     hello
      $ ldd hello  # 可以看出该可执行文件链接了很多其他动态库,主要是 Linux 的 glibc 动态库
              linux-vdso.so.1 =>  (0x00007fffefd7c000)
              libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fadcdd82000)
              /lib64/ld-linux-x86-64.so.2 (0x00007fadce14c000)
      
    • 如果使用命令 gcc -static hello.c -o hello 则会使用静态库进行链接,生成的 ELF 可执行文件的大小和链接的动态库如下所示:

      $ gcc -static hello.c -o hello
      $ size hello  # 使用size查看大小
           text    data     bss     dec     hex   filename
       823726    7284    6360  837370   cc6fa     hello  # 可以看出 text 段的代码尺寸变大
      $ ldd hello
             not a dynamic executable  # 说明没有链接动态库
      

链接器链接后生成的最终文件为 ELF 格式可执行文件,一个 ELF 可执行文件通常被链接为不同的段,常见的段有 .text.data.rodata.bss 等。

一步到位的编译

从功能上分,预处理、编译、汇编、链接是四个不同的阶段,但 GCC 的实际操作上,它可以把这四个步骤合并为一个步骤来执行。如下例所示:

gcc –o test first.c second.c third.c
# 该命令将同时编译三个源文件,即 first.c、second.c 和 third.c,然后将它们链接成一个可执行文件

注意:

  • 一个程序无论有一个源文件还是多个源文件,所有被编译和链接的源文件中必须有且仅有一个 main 函数。
  • 但如果仅仅是把源文件编译成目标文件,因为不会进行链接,所以 main 函数不是必需的。

分析 ELF 文件

ELF 文件介绍

在介绍ELF文件之前,首先将其与另一种常见的二进制文件格式bin进行对比:

  • binary文件,其中只有机器码。
  • elf文件除了含有机器码之外还有其它信息,如:段加载地址,运行入口地址,数据段等。

ELF全称Executable and Linkable Format,可执行链接格式。ELF文件格式主要三种:

  • 可重定向(Relocatable)文件:
    • 文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。
  • 可执行(Executable)文件:
    • 文件保存着一个用来执行的程序(例如bash,gcc等)。
  • 共享(Shared)目标文件(Linux下后缀为.so的文件):
    • 即所谓共享库。

ELF文件的段

ELF文件格式如图1中所示,位于ELF Header和Section Header Table之间的都是段(Section)。一个典型的ELF文件包含下面几个段:

  • .text:已编译程序的指令代码段。
  • .rodata:ro代表read only,即只读数据(譬如常数const)。
  • .data:已初始化的C程序全局变量和静态局部变量。
    • 注意:C程序普通局部变量在运行时被保存在堆栈中,既不出现在.data段中,也不出现在.bss段中。此外,如果变量被初始化值为0,也可能会放到bss段。
  • .bss:未初始化的C程序全局变量和静态局部变量。
    • 注意:目标文件格式区分初始化和未初始化变量是为了空间效率,在ELF文件中.bss段不占据实际的存储器空间,它仅仅是一个占位符。
  • .debug:调试符号表,调试器用此段的信息帮助调试。
  • 上述仅讲解了最常见的节,ELF文件还包含很多其他类型的节,本文在此不做赘述,请感兴趣的读者自行查阅其他资料了解学习。

ELF文件格式

查看ELF文件

可以使用Binutils中readelf来查看ELF文件的信息,可以通过readelf --help来查看readelf的选项:

$ readelf --help
Usage: readelf <option(s)> elf-file(s)
 Display information about the contents of ELF format files
 Options are:
  -a --all               Equivalent to: -h -l -S -s -r -d -V -A -I
  -h --file-header       Display the ELF file header
  -l --program-headers   Display the program headers
     --segments          An alias for --program-headers
  -S --section-headers   Display the sections' header

以本文Hello World示例,使用readelf -S查看其各个section的信息如下:

$ readelf -S hello
There are 31 section headers, starting at offset 0x19d8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
……
  [11] .init             PROGBITS         00000000004003c8  000003c8
       000000000000001a  0000000000000000  AX       0     0     4
……
  [14] .text             PROGBITS         0000000000400430  00000430
       0000000000000182  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         00000000004005b4  000005b4
……

反汇编

由于ELF文件无法被当做普通文本文件打开,如果希望直接查看一个ELF文件包含的指令和数据,需要使用反汇编的方法。反汇编是用于调试和定位处理器问题时最常用的手段。 可以使用Binutils中objdump来对ELF文件进行反汇编,可以通过objdump --help来查看其选项:

$ objdump --help
Usage: objdump <option(s)> <file(s)>
 Display information from object <file(s)>.
 At least one of the following switches must be given:
……
  -D, --disassemble-all    Display assembler contents of all sections
  -S, --source             Intermix source code with disassembly
……

以本文Hello World示例,使用objdump -D对其进行反汇编如下:

$ objdump -D hello
……
0000000000400526 <main>:  # main标签的PC地址
# PC地址:    指令编码                 指令的汇编格式
  400526:    55                      push   %rbp
  400527:    48 89 e5                mov    %rsp,%rbp
  40052a:    bf c4 05 40 00          mov    $0x4005c4,%edi
  40052f:    e8 cc fe ff ff          callq  400400 <puts@plt>
  400534:    b8 00 00 00 00          mov    $0x0,%eax
  400539:    5d                      pop    %rbp
  40053a:    c3                      retq
  40053b:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
……

使用objdump -S将其反汇编并且将其C语言源代码混合显示出来:

$ gcc -o hello -g hello.c # 要加上-g选项
$ objdump -S hello
……
0000000000400526 <main>:
#include <stdio.h>

int
main(void)
{
  400526:    55                      push   %rbp
  400527:    48 89 e5                mov    %rsp,%rbp
  printf("Hello World!" "\n");
  40052a:    bf c4 05 40 00          mov    $0x4005c4,%edi
  40052f:    e8 cc fe ff ff          callq  400400 <puts@plt>
  return 0;
  400534:    b8 00 00 00 00          mov    $0x0,%eax
}
  400539:    5d                      pop    %rbp
  40053a:    c3                      retq
  40053b:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
……

嵌入式系统编译的特殊性

为了易于读者理解,本文以一个Hello World程序为例讲解了在Linux环境中的编译过程以帮助初学者入门,但是了解这些基础背景知识对于嵌入式开发还远远不够。 对于嵌入式开发,嵌入式系统的编译过程和开发有其特殊性,譬如:

  • 嵌入式系统需要使用交叉编译与远程调试的方法进行开发。
  • 需要自己定义引导程序。
  • 需要注意减少代码尺寸。
  • 需要移植printf从而使得嵌入式系统也能够打印输入。
  • 使用Newlib作为C运行库。
  • 每个特定的嵌入式系统都需要配套的板级支持包。

交叉编译和远程调试

嵌入式平台上往往资源有限,嵌入式系统(譬如常见ARM MCU或8051单片机)的存储器容量通常只在几KB到几MB之间,且只有闪存而没有硬盘这种大容量存储设备,在这种资源有限的环境中,不可能将编译器等开发工具安装在嵌入式设备中,所以无法直接在嵌入式设备中进行软件开发。因此,嵌入式平台的软件一般在主机PC上进行开发和编译,然后将编译好的二进制代码下载至目标嵌入式系统平台上运行,这种编译方式属于交叉编译。

交叉编译可以简单理解为,在当前编译平台下,编译出来的程序能运行在体系结构不同的另一种目标平台上,但是编译平台本身却不能运行该程序,譬如,在x86平台的PC电脑上编写程序并编译成能运行在ARM平台的程序,编译得到的程序在x86平台上不能运行,必须放到ARM平台上才能运行。

与交叉编译同理,在嵌入式平台上往往也无法运行完整的调试器,因此当运行于嵌入式平台上的程序出现问题时,需要借助主机PC平台上的调试器来对嵌入式平台进行调试。这种调试方式属于远程调试。

常见的交叉编译和远程调试工具是GCC和GDB。GCC不仅能作为本地编译器,还能作为交叉编译器;同理GDB不仅可以作为本地调试器,还可以作为远程调试器。

当作为交叉编译器之时,GCC通常有不同的命名,譬如:

  • arm-none-eabi-gcc和arm-none-eabi-gdb是面向裸机(Bare-Metal)ARM平台的交叉编译器和远程调试器。
    • 所谓裸机(Bare-Metal)是嵌入式领域的一个常见形态,表示不运行操作系统的系统
  • 而riscv-none-embed-gcc和riscv-none-embed-gdb是面向裸机RISC-V平台的交叉编译器和远程调试器。

移植newlib或newlib-nano作为C运行库

newlib是一个面向嵌入式系统的C运行库。相对于glibc,newlib实现了大部分的功能函数,但体积却小很多。newlib独特的体系结构将功能实现与具体的操作系统分层,使之能够很好地进行配置以满足嵌入式系统的要求。由于专为嵌入式系统设计,newlib具有可移植性强、轻量级、速度快、功能完备等特点,已广泛应用于各种嵌入式系统中。

由于嵌入式操作系统和底层硬件的多样性,为了能够将C/C++语言所需要的库函数实现与具体的操作系统和底层硬件进行分层,newlib的所有库函数都建立在20个桩函数的基础上,这20个桩函数完成具体操作系统和底层硬件相关的功能:

  • I/O和文件系统访问(open、close、read、write、lseek、stat、fstat、fcntl、link、unlink、rename);
  • 扩大内存堆的需求(sbrk);
  • 获得当前系统的日期和时间(gettimeofday、times);
  • 各种类型的任务管理函数(execve、fork、getpid、kill、wait、_exit);

这20个桩函数在语义、语法上与POSIX(Portable Operating System Interface of UNIX)标准下对应的20个同名系统调用完全兼容。

所以,如果需要移植newlib至某个目标嵌入式平台,成功移植的关键是在目标平台下找到能够与newlib桩函数衔接的功能函数或者实现这些桩函数。

注意:newlib的一个特殊版本newlib-nano版本进一步为嵌入式平台减少了代码体积(Code Size),因为newlib-nano提供了更加精简版本的malloc和printf函数的实现,并且对库函数使用GCC的-Os(侧重代码体积的优化)选项进行编译优化。

嵌入式引导程序和中断异常处理

前文介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,对其进行编译,然后运行在此电脑上。在这种方式下,程序员仅仅只需要关注Hello World程序本身,程序的主体由main函数组织而成,程序员可以无需关注Linux操作系统在运行该程序的main函数之前和之后需要做什么。事实上,在Linux操作系统中运行应用程序(譬如简单的Hello World)时,操作系统需要动态地创建一个进程、为其分配内存空间、创建并运行该进程的引导程序,然后才会开始执行该程序的main函数,待其运行结束之后,操作系统还要清除并释放其内存空间、注销该进程等。

从上述过程中可以看出,程序的引导和清除这些“脏活累活”都是由Linux这样的操作系统来负责进行。但是在嵌入式系统中,程序员除了开发以main函数为主体的功能程序之外,还需要关注如下两个方面:

  • 引导程序:
    • 嵌入式系统上电后需要对系统硬件和软件运行环境进行初始化,这些工作往往由用汇编语言编写的引导程序完成。
    • 引导程序是嵌入式系统上电后运行的第一段软件代码。引导程序对于嵌入式系统非常关键,引导程序所执行的操作依赖于所开发的嵌入式系统的软硬件特性,一般流程包括:初始化硬件、设置异常和中断向量表、把程序拷贝到片上SRAM中、完成代码的重映射等,最后跳转到main函数入口。
  • 中断异常处理
    • 中断和异常是嵌入式系统非常重要的一个环节,因此,嵌入式系统软件还必须正确地配置中断和异常处理函数。

嵌入式系统链接脚本

上文中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,对其进行编译,然后运行在此电脑上。在这种方式下,程序员也无需关心编译过程中的“链接”这一步骤所使用的链接脚本,无需为程序分配具体的内存空间。

但是在嵌入式系统中,程序员除了开发以main函数为主体的功能程序之外,还需要关注“链接脚本”为程序分配合适的存储器空间,譬如程序段放在什么区间、数据段放在什么区间等等。

减小代码体积

嵌入式平台上往往存储器资源有限,嵌入式系统(譬如常见的ARM MCU或8051单片机)的存储器容量通常只在几KB到几MB之间,且只有闪存而没有硬盘这种大容量存储设备,在这种资源有限的环境中,程序的代码体积(Code Size)显得尤其重要,因此,有效地降低降低代码体积(Code Size)是嵌入式软件开发必须要考虑的问题,常见的方法如:

  • 使用newlib-nano作为C运行库以取得较小代码体积(Code Size)的C库函数。
  • 尽量少使用C语言的大型库函数,譬如在正式发行版本的程序中避免使用printf和scanf等函数。
  • 如果在开发的过程中一定需要使用printf函数,可以使用某些自己实现的阉割版printf函数(而不是C运行库中提供的printf函数)以生成较小的代码体积。
  • 除此之外,在C/C++语言的语法和程序开发方面也有众多技巧以取得更小的代码体积(Code Size)。

支持printf函数

上文中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,程序中使用C语言的标准库函数printf打印了一个“Hello World”字符串。该程序在Linux系统里面运行的时候字符串被成功的输出到了Linux的终端界面上。在这个过程中,程序员无需关心Linux系统到底是如何将printf函数的字符串输出到Linux终端上的。事实上,在Linux本地编译的程序会链接使用Linux系统的C运行库glibc,而glibc充当了应用程序和Linux操作系统之间的接口,glibc提供的 printf 函数就会调用如sys_write等操作系统的底层系统调用函数,从而能够将“字符串”输出到Linux终端上。

从上述过程中可以看出,由于有glibc的支持,所以printf函数能够在Linux系统中正确的进行输出。但是在嵌入式系统中,printf的输出却不那么容易了,基于如下几个原因:

  • 嵌入式系统使用newlib作为C运行库,而newlib的C运行库所提供的printf函数最终依赖于如本文中所介绍的newlib桩函数write,因此必须实现此write函数才能够正确的执行printf函数。
  • 嵌入式系统往往没有“显示终端”存在,譬如常见的单片机其作为一个黑盒子一般的芯片,根本没有显示终端。因此,为了能够支持显示输出,通常需要借助单片机芯片的UART接口将printf函数的输出重新定向到主机PC的COM口上,然后借助主机PC的串口调试助手显示出输出信息。同理,对于scanf输入函数,也需要通过主机PC的串口调试助手获取输入然后通过主机PC的COM口发送给单片机芯片的UART接口。
  • 从以上两点可以看出,嵌入式平台的UART接口非常重要,往往扮演了输出管道的角色,为了能够将printf函数的输出定向到UART接口,需要实现newlib的桩函数write,使其通过编程UART的相关寄存器将字符通过UART接口输出。

提供板级支持包

对于特定的嵌入式硬件平台,为了方便用户在硬件平台上开发嵌入式程序,硬件平台一般会提供板级支持包(Board Support Package,BSP)。板级支持包所包含的内容没有绝对的标准,通常说来,其必须包含如下内容:

  • 底层硬件设备的地址分配信息
  • 底层硬件设备的驱动函数
  • 系统的引导程序
  • 中断和异常处理服务程序
  • 系统的链接脚本
  • 如果使用newlib作为C运行库,一般还提供newlib桩函数的实现。

CMake 基础

从广义上来讲,CMake 是一组工具,包括了 CMakeCTestCPack

最小 CMake 工程

cmake_minimum_required(VERSION 3.20)
project(MyApp
        VERSION 1.0
        LANGUAGES C)
set(SOURCES main.c)
add_executable(MyExe ${SOURCES})

add_executable 等“函数”在 CMake 中称为 命令,和函数不同,CMake 命令虽然可以传入参数,但是无法 return 结果给调用者(返回结果需要使用其它技巧)。CMake 命令对大小写不敏感,但是通常使用小写。

cmake_minimum_required

cmake_minimum_required(VERSION major.minor[.patch[.tweak]])

该命令需要放置在 CMakeLists.txt 文件的第一行。

  • 该命令声明了 CMake 项目依赖的最小版本号,确保某些 CMake 功能在用户的 cmake 软件中是存在的
  • 设置默认的 CMake 策略,使其与指定的版本号匹配

project

project(projectName
 [VERSION major[.minor[.patch[.tweak]]]]
 [LANGUAGES languageName ...]
)

Out-of-Source Build

mkdir build
cd build
cmake -G "Unix Makefiles" ../source
cmake --build . --config Release --target MyApp

CMake 支持多种项目文件格式:

Category
Generator Examples Multi-config
Visual Studio Visual Studio 15 2017 Yes
Visual Studio 14 2015
...
Xcode Xcode Yes
Ninja Ninja No
Makefiles Unix Makefiles No
MSYS Makefiles
MinGW Makefiles
NMake Makefiles

变量

set(color Green CACHE STRING "Color of folower" FORCE)
set_property(CACHE color PROPERTY STRINGS Red Orange Green)
variable_watch(color)
message(STATUS "Color = ${color}")
message(STATUS "IDF_PATH = $ENV{IDF_PATH}")
message(STATUS "Version = ${PROJECT_VERSION}")
message(STATUS "SrcPath = ${PROJECT_SOURCE_DIR}")
message(STATUS)
set(longStr " ESP8266 ESP32 ESP8089 ")
set(shortStr "ESP")

get_cmake_property(resultVar MACROS)
message(${resultVar})

set_directory_properties(PROPERTIES username "morris")
get_directory_property(resultVar username)
message(${resultVar})

set_property(
 GLOBAL
 PROPERTY FOO
 1
 2
 3)

get_cmake_property(foo_value FOO)
message(STATUS "value of FOO is ${foo_value}")

set(my_list 1 2 3)
set_property(
 DIRECTORY
 PROPERTY FOO
 "${my_list}")

get_property(foo_value DIRECTORY PROPERTY FOO)
message(STATUS "value of FOO is ${foo_value}")

string 命令

string(STRIP ${longStr} longStr)
message(${longStr})
string(FIND ${longStr} ${shortStr} outVar)
message(${outVar})
string(FIND ${longStr} ${shortStr} outVar REVERSE)
message(${outVar})
string(REPLACE "ESP" "Espressif" outVar ${longStr})
message(${outVar})
string(REGEX MATCHALL "[0-9]" outVar ${longStr})
message(${outVar})
set(testStr abcdefabcd)
string(REGEX REPLACE "(de)" "X\\1Y" outVar ${testStr})
message(${outVar})

list 命令

set(myList a;b;c;def)
message(${myList})
list(LENGTH myList outVar)
message(${outVar})
list(GET myList 3 0 outVar)
message(${outVar})
set(myPaths "/a/b/c" "/b/e" "/a/d" "/b/e")
message(${myPaths})
list(REMOVE_DUPLICATES myPaths)
message(${myPaths})
list(SORT myPaths)
message(${myPaths})

set(tlist "/a;/b;/c")
message(${tlist})
set(tlist "/d;/e" ${tlist})
message(${tlist})
list(APPEND tlist "/f")
message(${tlist})

条件判断

if("/b/e" IN_LIST myPaths)
        message(STATUS "/b/e is in the list")
endif()

set(x 3)
set(y 7)
math(EXPR z "(${x}+${y})/2")
message(${z})

if(x AND ("23" EQUAL 23))
        message("YES")
else()
        message("NO")
endif()

set(who "Morris")
if("Hi from ${who}" MATCHES "Hi from (Morris|Wendy).*")
        message("${CMAKE_MATCH_1} says hello : ${CMAKE_MATCH_0}")
else()
        message("Nobody says hello")
endif()

if(IS_DIRECTORY "/home")
        message("Is Directory")
endif()

if(COMMAND string)
        message("string command exist")
endif()

if(DEFINED ENV{IDF_PATH})
        message("environment IDF_PATH has been defined")
endif()

循环语句

set(list1 A B)
set(list2)
set(foo WillNotBeShown)
foreach(loopVar IN LISTS list1 list2 ITEMS ${foo} bar)
        message("Iteration for: ${loopVar}")
endforeach()

foreach(loopVar RANGE 0 5 1)
        message("${loopVar}")
endforeach()

message("source_dir=${CMAKE_SOURCE_DIR}\r\nbin_dir=${CMAKE_BINARY_DIR}")
message("current_source_dir=${CMAKE_CURRENT_SOURCE_DIR}\r\ncurrent_bin_dir=${CMAKE_CURRENT_BINARY_DIR}")

set(my_value 1)
while(my_value LESS 40)
    message(STATUS "value=${my_value}")
    math(EXPR my_value "${my_value}+1")
endwhile()

函数

# ARGN 代表剩余的参数
# ARGV 代表所有的参数
function(func arg)
        message("arg=${arg}")
        message("ARGC=${ARGC}")
        message("ARGV=${ARGV}")
        message("ARGN=${ARGN}")
        if(DEFINED arg)
                message("Function arg is a defined variable")
        else()
                message("Function arg is NOT a defined variable")
        endif()
endfunction()

function(do_cmake_good)
 foreach(arg IN LISTS ARGN)
  message(STATUS "Got argument: ${arg}")
 endforeach()
endfunction()

macro(macr arg)
        message("arg=${arg}")
        message("ARGC=${ARGC}")
        if(DEFINED arg)
                message("Macro arg is a defined variable")
        else()
                message("Macro arg is NOT a defined variable")
        endif()
endmacro()

func(foobar test)
macr(foobar)

function(esp_func)
        set(prefix IDF)
        set(noValues ENABLE_WIFI CONSOLE)
        set(singleValues TARGET)
        set(multiValues SOURCES IMAGES)
        cmake_parse_arguments(${prefix}
                              "${noValues}"
                              "${singleValues}"
                              "${multiValues}"
                              ${ARGN})
        message("Option summary:")
        foreach(arg IN LISTS noValues)
                if(${${prefix}_${arg}})
                        message("${arg} enabled")
                else()
                        message("${arg} disabled")
                endif()
        endforeach()

        foreach(arg IN LISTS singleValues multiValues)
                message("${arg}=${${prefix}_${arg}}")
        endforeach()
endfunction()

esp_func(SOURCES foo.cpp startup.S TARGET esp32 ENABLE_WIFI)
esp_func(CONSOLE TARGET esp8266 IMAGES here.png there.png)

function(myfunc result1 result2)
        set(${result1} "First result" PARENT_SCOPE)
        set(${result2} "Second result" PARENT_SCOPE)
endfunction()

myfunc(res1 res2)
message("result1=${res1}")
message("result2=${res2}")

function(sum out a b)
 math(EXPR ret "${a} + ${b}")
 set("${out}" "${ret}" PARENT_SCOPE)
endfunction()

include 命令

include(CMakePrintHelpers)
cmake_print_properties(TARGETS MyExe MyLib PROPERTIES TYPE)
cmake_print_properties(DIRECTORIES "." PROPERTIES username)
cmake_print_variables(tlist CMAKE_VERSION)

include(TestBigEndian)
test_big_endian(isBigEndian)
cmake_print_variables(isBigEndian)

find_package(PythonInterp)
find_package(PythonLibs)
cmake_print_variables(PYTHON_EXECUTABLE)

cmake_print_variables(CMAKE_BUILD_TYPE)

最佳实战

  • 使用 target_xxx 版本的 CMake 宏

  • 指定 propertyPUBLICPRIVATE 或者 INTERFACE 属性

  • 获取 target 的编译 flag 之前要先将其链接进来

  • 谨慎使用会影响所有 target 的宏,比如:

    • INCLUDE_DIRECTORIES()
    • ADD_DEFINITIONS()
    • LINK_LIBRARIES()
  • 不要在 target_include_directories() 引用模块之外的路径

  • 针对仅仅包含头文件的一些库,建议:

    • add_library(mylib INTERFACE)
    • target_include_directories(mylib INTERFACE include)
    • target_link_libraries(mylib INTERFACE Boost::Boost)
  • 为模块添加新的编译选项

    target_include_directories(mylib PUBLIC include)
    target_include_directories(mylib PRIVATE src)
    
    if(SOME_SETTING)
     target_compile_definitions(mylib
              PUBLIC WITH_SOME_SETTING)
    endif()
    
  • 设置全局编译选项

    if(MSVC)
     add_compile_options(/W3 /WX)
    else()
     add_compile_options(-W -Wall -Werror)
    endif()
    

OpenWRT 基础

Flash 分区

Linux系统对闪存类存储器是采用MTD设备驱动实现的。

[    0.290000] spi-mt7621 10000b00.spi: sys_freq: 193333333
[    0.300000] m25p80 spi32766.0: w25q256 (32768 Kbytes)
[    0.300000] m25p80 spi32766.0: using chunked io
[    0.310000] 4 ofpart partitions found on MTD device spi32766.0
[    0.310000] Creating 4 MTD partitions on "spi32766.0":
[    0.320000] 0x000000000000-0x000000030000 : "u-boot"
[    0.320000] 0x000000030000-0x000000040000 : "u-boot-env"
[    0.330000] 0x000000040000-0x000000050000 : "factory"
[    0.340000] 0x000000050000-0x000002000000 : "firmware"
[    0.410000] 2 uimage-fw partitions found on MTD device firmware
[    0.410000] 0x000000050000-0x000000168da1 : "kernel"
[    0.420000] 0x000000168da1-0x000002000000 : "rootfs"
[    0.420000] mtd: device 5 (rootfs) set to be root filesystem
[    0.430000] 1 squashfs-split partitions found on MTD device rootfs
[    0.440000] 0x000000620000-0x000002000000 : "rootfs_data"

::: tip 系统在SPI设备上创建了4个MTD分区 :::

分区名分区作用
u-boot引导程序
u-boot-env引导程序的配置数据
factory路由器芯片的初始化参数
firmware固件分区
kernel固件分区
Linux 内核
rootfs固件分区
文件系统子集
rootfs_rom固件分区
文件系统子集
只读分区子集
rootfs_data固件分区
文件系统子集
可写分区子集

::: tip 分区存在子分区,kernel和rootfs就是firmware的子分区,rootfs_rom和rootfs_data就是rootfs的子分区。 :::

查看系统 MTD 分配

root@Widora:~# cat /proc/mtd
dev:    size   erasesize  name
mtd0: 00030000 00010000 "u-boot"
mtd1: 00010000 00010000 "u-boot-env"
mtd2: 00010000 00010000 "factory"
mtd3: 01fb0000 00010000 "firmware"
mtd4: 00118da1 00010000 "kernel"
mtd5: 01e9725f 00010000 "rootfs"
mtd6: 019e0000 00010000 "rootfs_data"

查看系统 MTD 分配

root@Widora:~# cat /proc/partitions
major   minor  #blocks    name
31       0        192   mtdblock0
31       1         64   mtdblock1
31       2         64   mtdblock2
31       3      32448   mtdblock3
31       4       1123   mtdblock4
31       5      31324   mtdblock5
31       6      26496   mtdblock6

读取/备份 factory 分区

factory 分区(位于 /dev/mtd2)保存了重要的配置参数(如MAC地址,天线匹配参数等)。

查看分区内容

root@Widora:~# hexdump /dev/mtd2
0000000 7628 0001 ef0c d2af b8a9 0000 0000 0000
0000010 ffff ffff ffff ffff ffff ffff ffff ffff
0000020 0000 0000 0020 0000 ef0c d2af b8a9 ef0c
0000030 d2af b9a9 3422 2000 ffff 0100 0000 0000
0000040 0000 0022 0000 0000 0030 0000 0000 0000
0000050 0081 9400 b040 c640 c31a c2c3 c5c0 0027
0000060 ffff ffff ffff ffff ffff ffff ffff ffff
*
00000a0 c6c6 c4c4 c0c4 c4c0 c4c4 c0c4 c0c0 0000
00000b0 ffff ffff ffff ffff ffff ffff ffff ffff
*
00000f0 0000 0000 00d1 8800 0000 0000 0000 0000
0000100 ffff ffff ffff ffff ffff ffff ffff ffff
*
0000120 0000 0000 0000 0000 0000 0000 0000 0077
0000130 1d11 1d11 7f15 7f15 7f17 7f17 3b10 3b10
0000140 ffff ffff ffff ffff ffff ffff ffff ffff
*
0010000

备份分区

使用 dd 命令将分区读到一个文件中,然后使用浏览器下载到本地。

root@Widora:~# dd if=/dev/mtd2 of=/www/art.bin
128+0 records in
128+0 records out
root@Widora:~# ls -l /www/art.bin
-rw-r--r--    1 root     root         65536 Apr 27 18:26 /www/art.bin

文件系统

root@Widora:/# df
Filesystem           1K-blocks      Used Available Use% Mounted on
rootfs                   26496       752     25744   3% /
/dev/root                 4864      4864         0 100% /rom
tmpfs                    63232        84     63148   0% /tmp
/dev/mtdblock6           26496       752     25744   3% /overlay
overlayfs:/overlay       26496       752     25744   3% /
tmpfs                      512         0       512   0% /dev
  1. 引导程序启动内核完成后,由内核加载 rootfs_rom 只读分区来完成系统的初步启动。rootfs_rom 只读分区采用的是 Linux 内核支持的 squashFS 文件系统,加载完毕后将其挂载到 /rom 目录,同时也挂载为 / 目录。
  2. 系统将使用 JFFS2 文件系统格式化的 rootfs_data 可写文件分区并将这部分挂载到 /overlay 目录。
  3. 系统再将 /overlay 透明挂载为 / 根目录。
  4. 最后将一部分内存挂载为/tmp 目录。

透明挂载根目录 /

::: tip

OpenWrt 设计的一个特点是:系统先将 rootfs_rom 挂载为 / 根目录,这样就具备了一个完整的系统,然后再将 rootfs_data 以透明方式挂载在 / 根目录上。

:::

  • 最终呈现的根文件系统是由 rootfs_romrootfs_data 两个分区组合在一起的效果。
  • 对任何文件的修改(增、删、改)都会记录在 rootfs_data 分区中。
  • 读取文件内容时,首先检测 rootfs_data 里的状态,再检测 roots_rom 里的内容,最后输出结果。

::: warning

如果修改了一个名为 abc 的文件,那么在 /rom 里还会保留修改之前的那个 abc 文件,同时在 /overlay 里有修改后的 abc 文件,因此所占的空间将会倍增。这样带来的一个好处是,在任何时候,只要删除 /overlay 里所有的文件,就能达到恢复出厂的效果。

:::

常用文件夹

文件夹路径作用
/etc/存放系统所有的配置文件
/etc/init.d/存放启动的服务脚本
/etc/config/存放 OpenWrt 的配置文件,包括网络
/tmp/存放临时文件和动态的配置文件
/tmp/TZ系统启动后所使用的时区参数

软件包管理器 OPKG

配置 OPKG

/etc/opkg.conf 文件保存 OPKG 相关设置:

root@Widora:/# cat /etc/opkg.conf
dest root /
dest ram /tmp
lists_dir ext /var/opkg-lists
option overlay_root /overlay
option check_signature 1
配置选项说明
dest root安装目标的跟路径
dest ram内存临时文件路径
lists_dir ext软件包列表文件
option overlay_root可写分区挂载位置

常用命令搭配

命令说明
opkg update下载服务器上可用的软件包列表
opkg upgrade <package>升级软件包
opkg install <package>安装软件包
opkg configure <package>配置某一个软件包
opkg remove <package>卸载软件包
opkg list列出全部可用的软件包
opkg list-installed列出已安装的软件包
opkg list-upgradable列出可以升级的软件包
opkg info [package|regexp]显示指定软件包的信息
opkg status [package|regexp]显示指定软件包的状态
opkg download <package>下载一个软件包到当前目录,但不安装

::: tip

软件包列表可能会比较大,因此并不保存在系统中,每次启动需要首先执行 opkg update 取得最新的软件包。

:::

root@Widora:/# opkg update
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/base/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_base.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/base/Packages.sig.
Signature check passed.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/luci/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_luci.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/luci/Packages.sig.
Signature check passed.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/management/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_management.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/management/Packages.sig.
Signature check passed.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/packages/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_packages.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/packages/Packages.sig.
Signature check passed.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/routing/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_routing.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/routing/Packages.sig.
Signature check passed.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/telephony/Packages.gz.
Updated list of available packages in /var/opkg-lists/chaos_calmer_telephony.
Downloading http://downloads.openwrt.org/chaos_calmer/15.05.1/ramips/mt7688/packages/telephony/Packages.sig.
Signature check passed.

UCI 命令系统

UCI (Unified Configuration Interface) 是 OpenWrt 中配置参数管理系统,UCI 中已经包含了网络配置、无线配置、系统信息配置等基本路由器所需的主要配置参数。

UCI 配置文件全部存储在 /etc/config 目录下:

root@Widora:/# ls /etc/config/
dhcp           fstab          mountd         shairport      uhttpd
dropbear       luci           network        system         wireless
firewall       mjpg-streamer  rpcd           ucitrack

常见的 UCI 配置:

配置文件作用
/etc/config/dhcp面向 LAN 口提供的 IP 地址分配服务配置
/etc/config/dropbearSSH 服务配置
/etc/config/firewall路由转发,端口转发,防火墙规则
/etc/config/network自身网络接口配置
/etc/config/system时间服务器时区配置
/etc/config/wireless无线网络配置

支持 UCI 管理模式的软件包是这样完成启动的(以 samba 软件为例):

  1. 启动脚本 /etc/init.d/samba
  2. 启动脚本通过 UCI 分析库从 /etc/config/samba 获得启动参数
  3. 启动脚本完成正常启动

::: warning

UCI 配置文件既可以使用 UCI 命令进行修改,也可以使用文本编辑器直接修改。但如果两种方式都使用,需要注意 UCI 命令修改会产生缓存,每次修改好要尽快保存确认,以免出现冲突。

:::

UCI 文件格式

config 'section-type' 'section'
	option 'key' 'value'
	option 'list_key' 'list_value1'
	option 'list_key' 'list_value2'

::: tip

UCI 允许只有 section-type 的匿名配置节点。

:::

UCI 命令读写配置

uci [<option>] <command> [<arguments>]

读取类语法

命令说明
uci get <config>.<section>获取节点类型
uci get <config>.<section>.<option>获取取得一个值
uci show显示全部 UCI 配置
uci show <config>显示指定文件配置
uci show <config>.<section>显示指定节点的配置
uci show <config>.<section>.<option>显示指定选项配置
uci changes <config>显示尚未生效的修改记录
uci show -X <config>.<section>.<option>显示匿名节点

写入类语法

命令说明
uci add <config> <section-type>增加一个匿名节点
uci set <config>.<section> = <section-type>增加一个节点/修改节点类型
uci set <config>.<section>.<option> = <value>增加一个选项值/修改一个选项值
uci add_list <config>.<section>.<option> = <value>在列表中增加一个值
uci delete <config>.<section>删除指定节点
uci delete <config>.<section>.<option>删除指定选项
uci delete <config>.<section>.<list>删除列表
uci del_list <config>.<section>.<option> = <string>删除列表中的一个值
uci commit <config>使修改生效

::: warning

UCI 读取总是先读取内存中的缓存,然后再读取文件中的。进行过增删改操作后要执行生效指令,否则所有的修改只留存在缓存中。

:::

常用配置操作

查看 system 配置

root@Widora:/# cat /etc/config/system
config system
	option hostname	Widora
	option timezone	CST-8

config timeserver ntp
	list server	0.openwrt.pool.ntp.org
	list server	1.openwrt.pool.ntp.org
	list server	2.openwrt.pool.ntp.org
	list server	3.openwrt.pool.ntp.org
	option enabled 1
	option enable_server 0
root@Widora:/# uci show system
system.@system[0]=system
system.@system[0].hostname='Widora'
system.@system[0].timezone='CST-8'
system.ntp=timeserver
system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org' '2.openwrt.pool.ntp.org' '3.openwrt.pool.ntp.org'
system.ntp.enabled='1'
system.ntp.enable_server='0'

查看网络配置

root@Widora:/# cat /etc/config/network

config interface 'loopback'
	option ifname 'lo'
	option proto 'static'
	option ipaddr '127.0.0.1'
	option netmask '255.0.0.0'

config globals 'globals'
	option ula_prefix 'fdff:c5b2:821c::/48'

config interface 'lan'
	option force_link '1'
	option macaddr '0c:ef:af:d2:a9:b9'
	option type 'bridge'
	option proto 'static'
	option ipaddr '192.168.8.1'
	option netmask '255.255.255.0'
	option ip6assign '60'
	option ifname 'eth0.1'

config interface 'wan'
	option force_link '1'
	option macaddr '0c:ef:af:d2:a9:b8'
	option proto 'dhcp'
	option ifname 'eth0.2'

config interface 'wan6'
	option proto 'dhcpv6'
	option ifname 'eth0.2'

config switch
	option name 'switch0'
	option reset '1'
	option enable_vlan '1'

config switch_vlan
	option device 'switch0'
	option vlan '1'
	option ports '1 2 3 4 6t'

config switch_vlan
	option device 'switch0'
	option vlan '2'
	option ports '0 6t'

重启网络使配置生效

root@Widora:~# /etc/init.d/network restart

开启 Wi-Fi

uci set wireless.radio0.disabled=0 # 使能 Wi-Fi
uci commit wireless # 使配置生效
wifi # 启动 Wi-Fi

查看当前网络

root@Widora:/# ifconfig
br-lan    Link encap:Ethernet  HWaddr 0C:EF:AF:D2:A9:B9
          inet addr:192.168.8.1  Bcast:192.168.8.255  Mask:255.255.255.0
          inet6 addr: fe80::eef:afff:fed2:a9b9/64 Scope:Link
          inet6 addr: fdff:c5b2:821c::1/60 Scope:Global
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:574264 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1217669 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:48102567 (45.8 MiB)  TX bytes:1634912136 (1.5 GiB)

eth0      Link encap:Ethernet  HWaddr 0C:EF:AF:D2:A9:B8
          inet6 addr: fe80::eef:afff:fed2:a9b8/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1225320 errors:0 dropped:0 overruns:0 frame:0
          TX packets:579507 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:1641989813 (1.5 GiB)  TX bytes:59132909 (56.3 MiB)
          Interrupt:5

eth0.1    Link encap:Ethernet  HWaddr 0C:EF:AF:D2:A9:B8
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1 errors:0 dropped:1 overruns:0 frame:0
          TX packets:3544 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:46 (46.0 B)  TX bytes:244206 (238.4 KiB)

eth0.2    Link encap:Ethernet  HWaddr 0C:EF:AF:D2:A9:B8
          inet addr:192.168.1.5  Bcast:192.168.1.255  Mask:255.255.255.0
          inet6 addr: fe80::eef:afff:fed2:a9b8/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1176423 errors:0 dropped:10706 overruns:0 frame:0
          TX packets:575928 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:1617670919 (1.5 GiB)  TX bytes:56565373 (53.9 MiB)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:294 errors:0 dropped:0 overruns:0 frame:0
          TX packets:294 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:41961 (40.9 KiB)  TX bytes:41961 (40.9 KiB)

ra0       Link encap:Ethernet  HWaddr 0C:EF:AF:D2:A9:B8
          inet6 addr: fe80::eef:afff:fed2:a9b8/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:641847 errors:830 dropped:0 overruns:0 frame:0
          TX packets:1228723 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:70965355 (67.6 MiB)  TX bytes:1630822247 (1.5 GiB)
          Interrupt:6
网络设备名说明
br-lan虚拟设备,LAN 口桥接设备,包含通过 LAN 口和 WAN 口连入系统的设备统一桥接
eth0真实设备,CPU 内部到交换机芯片之间只有一个接口
eth0.1虚拟设备,由 VLAN 划分的有线的 LAN 口,VLAN 编号为 1
eth0.2虚拟设备,由 VLAN 划分的有线的 LAN 口,VLAN编号为 2
lo虚拟设备,回环设备
ra0真实设备,启动 Wi-Fi 后将会产生此无线设备
pppoe-wan虚拟设备,是 PPPoE 拨号上网成功后产生的

查看 br-lan 桥状态

root@Widora:/# brctl show
bridge name	bridge id			STP enabled	interfaces
br-lan		7fff.0cefafd2a9b9	no			eth0.1
											ra0

查看内核日志

root@Widora:/# logread

查看当前 VLAN 划分

root@Widora:/# swconfig dev switch0 show
Global attributes:
	enable_vlan: 1
	alternate_vlan_disable: 0
	bc_storm_protect: 0
	led_frequency: 0
Port 0:
	disable: 0
	doubletag: 0
	untag: 1
	led: 5
	lan: 1
	recv_bad: 0
	recv_good: 2205
	tr_bad: 0
	tr_good: 52155
	pvid: 2
	link: port:0 link:up speed:100baseT full-duplex
Port 1:
	disable: 0
	doubletag: 0
	untag: 1
	led: 5
	lan: 1
	recv_bad: 0
	recv_good: 0
	tr_bad: 0
	tr_good: 0
	pvid: 1
	link: port:1 link:down
Port 2:
	disable: 0
	doubletag: 0
	untag: 1
	led: 5
	lan: 1
	recv_bad: 0
	recv_good: 0
	tr_bad: 0
	tr_good: 0
	pvid: 1
	link: port:2 link:down
Port 3:
	disable: 0
	doubletag: 0
	untag: 1
	led: 5
	lan: 1
	recv_bad: 0
	recv_good: 0
	tr_bad: 0
	tr_good: 0
	pvid: 1
	link: port:3 link:down
Port 4:
	disable: 0
	doubletag: 0
	untag: 1
	led: 5
	lan: 1
	recv_bad: 0
	recv_good: 0
	tr_bad: 0
	tr_good: 0
	pvid: 1
	link: port:4 link:down
Port 5:
	disable: 1
	doubletag: 0
	untag: 0
	led: ???
	lan: 1
	recv_bad: 0
	recv_good: 0
	tr_bad: 0
	tr_good: 0
	pvid: 0
	link: port:5 link:down
Port 6:
	disable: 0
	doubletag: 0
	untag: 0
	led: ???
	lan: ???
	recv_bad: ???
	recv_good: ???
	tr_bad: ???
	tr_good: ???
	pvid: 0
	link: port:6 link:up speed:1000baseT full-duplex
VLAN 1:
	ports: 1 2 3 4 6t
VLAN 2:
	ports: 0 6t

::: tip

哪个网口是 LAN, 哪个是 WAN, 是由 VLAN 划分的。VLAN1 为 LAN 口,包含 1、2、3、4 接口;VLAN2 为 WAN 口,包含了 0 接口。

:::

配置 WAN 口外网

查看 WAN 口配置

root@Widora:/# uci show network.wan
network.wan=interface
network.wan.force_link='1'
network.wan.macaddr='0c:ef:af:d2:a9:b8'
network.wan.proto='dhcp'
network.wan.ifname='eth0.2'
选项说明可选值
ifname设备接口名eth0.2
proto协议类型static:静态 IP 地址
dhcp:动态获取 IP 地址
pppoe:拨号上网
pptp:远程 VPN 服务器
3g:连接 3G/4G 无线电话网络
macaddrWAN 口 MAC 地址,修改该地址即可实现 MAC 地址克隆首次数据根据 factory 分区内参数自动生成

动态获取 IP 选项

选项说明可选值及说明
proto协议类型dhcp
ifname设备名称eth0.2
macaddrMAC 地址根据 factory 分区自动生成的值
mtu最大数据包大小,默认不用设置数值
reqopts在向 DHCP 服务器发出请求时增加附加的 DHCP 信息字符串
dns使用指定的 DNS 服务器地址替代获得的 DNS字符串

指定静态 IP 选项

选项说明可选值及说明
proto协议类型static
ifname设备名称eth0.2
macaddrMAC 地址根据 factory 分区自动生成的值
mtu最大数据包大小,默认不用设置数值
ipaddrWAN 口的 IP 地址字符串
netmaskWAN 口的子网掩码字符串
gateway默认网关字符串
broadcast广播地址字符串
dns使用指定的 DNS 服务器地址替代获得的 DNS字符串

PPPOE 拨号上网选项

选项说明可选值及说明
proto协议类型pppoe
ifname设备名称eth0.2
macaddrMAC 地址根据 factory 分区自动生成的值
mtu最大数据包大小,默认不用设置数值
username拨号使用的帐号字符串
password拨号使用的密码字符串
ac使用指定的访问集中器进行连接字符串
service连接的服务名称字符串
connect连接时候执行的外部脚本字符串
disconnect断开连接时执行的外部脚本字符串
demand等待多久没有活动就断开 PPPOE 连接数字,单位秒
dnsDNS 服务器地址字符串
pppd_options用于 pppd 进程执行时候的附加参数字符串

配置 LAN 口服务

::: tip

LAN 口下的设备可以通过 WAN 口接入网络,也可以直接访问设备上的各项功能(系统防火墙对 LAN 口默认不做任何拦截)。

:::

查看 LAN 口配置

root@Widora:~# uci show network.lan
network.lan=interface
network.lan.force_link='1'
network.lan.macaddr='0c:ef:af:d2:a9:b9'
network.lan.type='bridge'
network.lan.proto='static'
network.lan.ipaddr='192.168.8.1'
network.lan.netmask='255.255.255.0'
network.lan.ip6assign='60'
network.lan.ifname='eth0.1'
选项说明可选值及说明
ifname设备名称eth0.1
proto协议类型static
macaddrMAC 地址根据 factory 分区自动生成的值
type网络类型bridge,桥模式(这样才有交换机功能)
ipaddrLAN 口的 IP 地址,用于局域网内其它设备访问路由器字符串
netmaskLAN 口的子网掩码字符串

::: warning

修改过 LAN 口的配置后要重启网络以及 DHCP 服务。

root@Widora:~# /etc/init.d/network restart
root@Widora:~# /etc/odhcpd restart

:::

配置无线网络

查看无线网络配置

root@Widora:~# cat /etc/config/wireless

config wifi-device 'radio0'
	option type 'ralink'
	option variant 'mt7628'
	option country 'CN'
	option hwmode '11bgn'
	option htmode 'HT40'
	option channel 'auto'
	option disabled '0'

config wifi-iface 'ap'
	option device 'radio0'
	option mode 'ap'
	option network 'lan'
	option ifname 'ra0'
	option ssid 'Widora-A9B8'
	option hidden '0'
	option encryption 'psk2'
	option key 'passworkd'

config wifi-iface 'sta'
	option device 'radio0'
	option disabled '1'
	option mode 'sta'
	option network 'wwan'
	option ifname 'apcli0'
	option ssid 'UplinkAp'
	option key 'SecretKey'

wifi-device 选项参数

选项说明可选值及说明
type设备类型ralink
channel无线信道,不同的国家支持的信道不同auto 或 1~13
hwmode无线协议类型11bgn: IEEE802.11b + IEEE802.11g + IEEE802.11n
htmode无线频宽HT20、HT40
disable关闭无线设备0:启用;1:禁用
country国家类型,跟支持的频道有关,中国为 CN,支持 1~13CN:中国

wifi-iface 选项参数

选项说明可选值及说明
device关联的无线设备radio0
network关联网络设备类型lan:表示桥接到 LAN 网上
wwan:表示启用无线中继
mode无线工作模式ap:热点模式
sta:客户端模式
ssid无线的名称字符串
hidden隐藏无线名称0:显示名称
1:隐藏名称
encryption无线加密方式none:不加密
psk:WPA-PSK 模式
psk2:WPA-PSK2 模式
psk-mixed:WPA-PSK / WPA-PSK2 混合模式
key无线密钥字符串,长度为 8~64 个 ASCII 字符

::: tip

修改过无线配置后需要使用命令 wifi 使之生效

:::

查看无线网络状态

root@Widora:~# iwinfo
ra0       ESSID: "Widora-A9B8"
          Access Point: 0C:EF:AF:D2:A9:B8
          Mode: Client  Channel: 7 (2.442 GHz)
          Tx-Power: 18 dBm  Link Quality: 10/100
          Signal: -256 dBm  Noise: -82 dBm
          Bit Rate: 150.0 MBit/s
          Encryption: unknown
          Type: wext  HW Mode(s): 802.11bg
          Hardware: unknown [Generic WEXT]
          TX power offset: unknown
          Frequency offset: unknown
          Supports VAPs: no  PHY name: ra0

搜索范围内的其它无线设备

root@Widora:~# iwinfo ra0 scan
Cell 01 - Address: 50:64:2B:4B:02:4E
          ESSID: "Xiaomi_024D"
          Mode: Master  Channel: 11
          Signal: -256 dBm  Quality: 55/100
          Encryption: WPA2 PSK (TKIP, AES-OCB)

Cell 02 - Address: 44:97:5A:EC:C5:14
          ESSID: "FAST_C514"
          Mode: Master  Channel: 1
          Signal: -256 dBm  Quality: 47/100
          Encryption: WPA2 PSK (AES-OCB)

防火墙

防火墙、DMZ(独立隔离区)、NAT 转发在 OpenWrt 系统中都是由 /etc/config/firewall 配置文件管理的。

防火墙命令

重置防火墙

root@Widora:~# /etc/init.d/firewall reload

重启防火墙

root@Widora:~# /etc/init.d/firewall restart

查看防火墙完整策略

root@Widora:~# iptables -L

防火墙 defaults 配置

config defaults
	option syn_flood	1			# 启用防洪水攻击
	option input		ACCEPT		# INPUT 链过滤策略
	option output		ACCEPT		# OUTPUT 链过滤策略
	option forward		REJECT		# FORWARD 链过滤策略

防火墙 zone 域配置

系统将 LAN 和 WAN 分为两个不同的 zone,它们之间是隔离的。

config zone
	option name			lan			# zone 节点名
	list   network		'lan'		# 指定绑定到该 zone 上的设备
	option input		ACCEPT
	option output		ACCEPT
	option forward		ACCEPT

config zone
	option name			wan
	list   network		'wan'
	list   network		'wan6'
	list   network		'wwan'
	option input		ACCEPT
	option output		ACCEPT
	option forward		REJECT
	option masq			1			# 传输伪装开关,WAN 必须设为 1
	option mtu_fix		1			# 数据输出时开启 MSS 钳制,WAN 必须设为 1

防火墙 forwarding 转发配置

forwarding 配置可以实现两个不同 zone 域之间的数据发送

config forwarding
	option src		lan			# 来源 zone
	option dest		wan			# 目标 zone

防火墙 rule 规则

默认情况下,所有进入 WAN 口的请求都会被拒绝,如果希望有例外,那么要通过 rule 来实现许可。

# Allow IPv4 ping
config rule
	option name			Allow-Ping
	option src			wan					# 数据源的 zone 域
	option proto		icmp				# 数据源的协议类型
	option icmp_type	echo-request
	option family		ipv4				# IP 协议类型
	option target		ACCEPT				# 规则动作

# 禁止 LAN 口的某个 IP 访问 WAN 口
config rule
	option src		lan						# 数据源的 zone 域
	option src_ip	192.168.45.2			# 数据源的 IP 地址
	option dest		wan						# 目的地的 zone 域
	option proto	tcp
	option target	REJECT

# 禁止某个 MAC 地址访问 WAN
config rule
	option dest		wan
	option src_mac	00:11:22:33:44:66		# 数据源的 MAC 地址
	option target	REJECT

# 阻塞某个 zone 上的 ICMP 流量
config rule
	option src		lan
	option proto	ICMP
	option target	DROP

防火墙 redirect 端口转发

端口转发允许访问者通过 WAN 口访问 LAN 口中的一个特定端口,并将结果转发回给访问者。

# 将 LAN 口的 80 端口开放到 WAN 口上
config redirect
	option src			wan				# 被转发来源 zone 域
	option src_dport	80				# 被转发的端口
	option dest			lan				# 转发到哪个 zone 域
	option dest_ip		192.168.16.235 	# 转发到哪个 IP 地址
	option dest_port	80				# 转发到哪个端口
	option proto		tcp				# 协议类型

# 将所有来自 WAN 口的 TCP 协议访问 22001 的请求都转发给 LAN 中的一台 80 端口的计算机
config redirect
	option src			wan
	option src_dport	22001
	option dest			lan
	option dest_port	22
	option proto		tcp

# 将 IP 地址 192.168.1.2 设置到 DMZ 隔离区
config redirect
	option src			wan
	option proto		all
	option dest_ip		192.168.1.2

NTP

NTP 用来使网络中的各个计算机时间同步的一种协议,它的用途是把系统的时钟同步到 UTC 时区,其精度在局域网内可达到 0.1ms,在互联网上绝大多数情况其精度可以达到1~50ms。

配置内容如下:

root@Widora:~# cat /etc/config/system
config system
	option hostname	Widora				# 主机名
	option timezone	CST-8				# 时区

config timeserver ntp
	list server	0.openwrt.pool.ntp.org	# NTP 服务器地址
	list server	1.openwrt.pool.ntp.org
	list server	2.openwrt.pool.ntp.org
	list server	3.openwrt.pool.ntp.org
	option enabled 1					# 开启 NTP 功能
	option enable_server 0

::: tip

阿里云提供了 NTP 服务功能:

ntp1.aliyun.com

ntp2.aliyun.com

ntp3.aliyun.com

:::

服务管理

查看系统服务命令

root@Widora:~# ls /etc/init.d/
avahi-daemon   dbus           dropbear       led            mountd         rpcd           sysctl         system         umount
boot           dnsmasq        firewall       log            network        setnetmode     sysfixtime     telnet
cron           done           fstab          mjpg-streamer  odhcpd         shairport      sysntpd        uhttpd

服务命令的语法格式

服务的语法格式:/etc/init.d/服务名称 [命令]
可用命令:
		start	临时开启这个服务
		stop	临时关闭这个服务
		restart 重启当前已开启的服务,如果没有开启就开启它
		reload 	重新读取该服务的配置信息(如果服务支持的话)
		enable	设置该服务随系统一同启动
		disable	禁止该服务随系统一同启动

Rust 知识碎片

编译过程

Rust 的编译过程

  1. 分词:把词法结构处理成词条流
  2. 词条流经过语法解析形成抽象语法树
  3. 抽象语法树简化成高级中间语言 (HIR),编译器对 HIR 进行类型检查、方法查找等工作
  4. HIR 进一步简化形成中级中间语言 (MIR),编译器对 MIR 进行借用检查、优化等工作,在 MIR 中已经看不到 Rust 各版次(Edition)的差异了
  5. 产生 LLVM 中间语言
  6. LLVM 后端会对 LLVM 中间语言进行优化,最终生成机器代码

常用数据结构

数据结构

值放堆上还是栈上

#![allow(unused)]
fn main() {
let s = "hello world".to_string();
println!("'hello world': {:p}, s: {:p}, len: {}, capacity: {}, size: {}",
        &"hello world", &s, s.len(), s.capacity(), std::mem::size_of_val(&s));
}

string的内存布局

栈上存放的数据是静态的,固定大小,固定生命周期;堆上存放的数据是动态的,不固定大小,不固定生命周期。

#![allow(unused)]
fn main() {
static MAX: u32 = 0;
fn foo() {}
let hello = "hello world".to_string();
let data = Box::new(1);

// string literals 指向 RODATA 地址
println!("RODATA: {:p}", "hello world!");
// static 变量在 DATA section
println!("DATA (static var): {:p}", &MAX);
// function 在 TEXT
println!("TEXT (function): {:p}", foo as *const ());
// String 结构体分配在栈上,所以其引用指向一个栈地址
println!("STACK (&hello): {:p}", &hello);
// 需要通过解引用获取其堆上数据,然后取其引用
println!("HEAP (&*hello): {:p}", &*hello);
// Box 实现了 Pointer trait 无需额外解引用
println!("HEAP (box impl Pointer) {:p} {:p}", data, &*data);
}

move, copy, borrow

move, copy, borrow

其实 Copy 和 Move 在内部实现上,都是浅层的按位做内存复制,只不过 Copy 允许你访问之前的变量,而 Move 不允许。

关于内存复制上的误区

如果代码的关键路径中的每次都要复制几百 k 的数据(比如一个大数组),这是很低效的。但是,如果要复制的只是原生类型(Copy)或者栈上的胖指针(Move),不涉及堆内存的复制(即没有做深拷贝(deep copy)),那这个效率是非常高的,不必担心每次赋值或者每次传参带来的性能损失。

Rust 的集合类型会在使用过程中自动扩展。以一个 Vec 为例,当使用完堆内存当前容量后,还继续添加新的内容,就会触发堆内存的自动增长。有时候,集合类型里的数据不断进进出出,导致集合一直增长,但实际只使用了很小部分的容量,导致内存的使用效率很低,这时可以考虑使用 shrink_to_fit 方法来节约对内存的使用。

所有权

ownership

所有权的静态检查和动态检查

在所有权模型下,堆内存的生命周期,和创建它的栈内存的生命周期保持一致。编译器可以保证代码符合所有权规则(静态检查)。

动态检查,通过 Box::leak() 让堆内存拥有不受限制的生命周期,然后在运行过程中,通过对引用计数的检查,保证这样的堆内存最终会得到释放。

外部可变性与内部可变性

使用方法所有权检查
外部可变性let mut 或者 &mut编译时,如果不符合规则,产生编译错误
内部可变性使用 Cell/RefCell运行时,如果不符合规则,产生 panic
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    // 根据所有权规则,在同一个作用域下,不能同时有活跃的可变借用和不可变借用
    // 通过这对花括号,我们缩小了可变借用的生命周期
    {
        // 获得 RefCell 内部数据的可变借用
        let mut v = data.borrow_mut();
        *v += 1;
    }
    println!("data: {:?}", data.borrow());
}

生命周期

pub fn strtok<'a>(s: &mut &'a str, delimiter: char) -> &'a str {
    if let Some(i) = s.find(delimiter) {
        let prefix = &s[..i];
        // 由于 delimiter 可以是 utf8,所以我们需要获得其 utf8 长度,
        // 直接使用 len 返回的是字节长度,会有问题
        let suffix = &s[(i + delimiter.len_utf8())..];
        *s = suffix;
        prefix
    } else {
        // 如果没找到,返回整个字符串,把原字符串指针 s 指向空串
        let prefix = *s;
        *s = "";
        prefix
    }
}

fn main() {
    let s = String::from("hello world");
    let mut s1 = s.as_str();
    let hello = strtok(&mut s1, ' ');
    println!("hello is: {}, s1: {}, s: {}", hello, s1, s);
}

lifetime

注意:当你要返回在函数执行过程中,创建的或者得到的数据,和参数无关,那么无论它是一个有所有权的数据,还是一个引用,你只能返回带所有权的数据。对于引用,这就意味着调用 clone() 或者 to_owned() 来从引用中得到所有权。

结构体成员自动重排

use std::mem::{align_of, size_of};

struct S1 {
    a: u8,
    b: u16,
    c: u8,
}

struct S2 {
    a: u8,
    c: u8,
    b: u16,
}

#[repr(C)]
struct S3 {
    a: u8,
    b: u16,
    c: u8,
}

fn main() {
    println!(
        "sizeof S1: {}, S2: {}, S3: {}",
        size_of::<S1>(),
        size_of::<S2>(),
        size_of::<S3>()
    );
    println!(
        "alignof S1: {}, S2: {}, S3: {}",
        align_of::<S1>(),
        align_of::<S2>(),
        align_of::<S3>()
    );
}

Rust 编译器默会优化结构体的排列,但我们也可以使用 #[repr] 宏,强制让 Rust 编译器不做优化,和 C 的行为一致,这样,Rust 代码可以方便地和 C 代码无缝交互。

enum 的内存布局

enum

enum 是一个标签联合体(tagged union),它的大小是标签的大小,加上最大类型的长度。所以对于 Option<u8>,其长度是 1 + 1 = 2 字节,而 Option<f64>,长度是 8 + 8 =16 字节。

use std::collections::HashMap;
use std::mem::size_of;

enum MyEnum {
    A(f64),
    B(HashMap<String, String>),
    C(Result<Vec<u8>, String>),
}

// 这是一个声明宏,它会打印各种数据结构本身的大小,在 Option 中的大小,以及在 Result 中的大小
macro_rules! show_size {
    (header) => {
        println!(
            "{:<24} {:>4}    {}    {}",
            "Type", "T", "Option<T>", "Result<T, io::Error>"
        );
        println!("{}", "-".repeat(64));
    };
    ($t:ty) => {
        println!(
            "{:<24} {:4} {:8} {:12}",
            stringify!($t),
            size_of::<$t>(),
            size_of::<Option<$t>>(),
            size_of::<Result<$t, std::io::Error>>(),
        )
    };
}

fn main() {
    show_size!(header);
    show_size!(u8);
    show_size!(f64);
    show_size!(&u8);
    show_size!(Box<u8>);
    show_size!(&[u8]);

    show_size!(String);
    show_size!(Vec<u8>);
    show_size!(HashMap<String, String>);
    show_size!(MyEnum);
}

但是 Rust 编译器会对 enum 做一些额外的优化,让某些常用结构的内存布局更紧凑。Option 配合带有引用类型的数据结构,比如 &u8、Box、Vec、HashMap ,没有额外占用空间。引用类型的第一个域是个指针,而指针是不可能等于 0 的,通过复用这个指针:当其为 0 时,表示 None,否则是 Some,减少了内存占用。

线程安全的全局变量 (lazy_static)

use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

lazy_static! {
    static ref HASHMAP: Arc<Mutex<HashMap<u32, &'static str>>> = {
        let mut m = HashMap::new();
        m.insert(0, "foo");
        m.insert(1, "bar");
        m.insert(2, "baz");
        Arc::new(Mutex::new(m))
    };
}

fn main() {
    let mut map = HASHMAP.lock().unwrap();
    map.insert(3, "waz");

    println!("map: {:?}", map);
}

带关联类型的 trait

use std::str::FromStr;

use lazy_static::lazy_static;
use regex::Regex;

pub trait ParseFromStr {
    type Error;
    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
    where
        Self: Sized;
}

impl<T> ParseFromStr for T
where
    T: FromStr,
{
    // 定义关联类型 Error 为 String
    type Error = String;

    fn parse_from_str(s: &str) -> Result<Self, Self::Error> {
        // ensure that regular expressions are compiled exactly once.
        lazy_static! {
            static ref RE: Regex = Regex::new(r"^\d+(\.\d+)?").unwrap();
        };
        if let Some(captures) = RE.captures(s) {
            captures
                .get(0)
                .map_or(Err("failed to capture".to_string()), |s| {
                    s.as_str()
                        .parse()
                        .map_err(|_e| "failed to parse captured string".to_string())
                })
        } else {
            Err("failed to parse string".to_string())
        }
    }
}

fn main() {
    println!("result: {}", u8::parse_from_str("255 hello").unwrap());
    println!("result: {}", u8::parse_from_str("001 world").unwrap());
    println!("result: {}", u8::parse_from_str("!").unwrap_or_default());
    println!("result: {}", f64::parse_from_str("123.45abc").unwrap());
}

trait object 的实现机制

trait object 的内存布局

trait object 的底层逻辑就是旁指针,其中一个指针指向数据本身,另一个则指向虚函数表(vtable)。vtable 是一张静态的表,Rust 在编译时会为使用了 trait object 的类型的 trait 实现生成一张表,放在可执行文件中(一般在text或rodata段)。

如果 trait 所有的方法,返回值是 Self 或者携带泛型参数,那么这个 trait 就不能产生 trait object。trait object 在产生时,原来的类型会被抹去,所以 Self 究竟是谁不知道。Rust 里带泛型的类型在编译时会做单态化,而 trait object 是运行时的产物,两者不能兼容。如果一个 trait 只有部分方法返回 Self 或者携带泛型参数,那么这部分方法在 trait object 中不能被调用

常用 trait 介绍

Clone

Clone 是深度拷贝,栈内存和堆内存一起拷贝。

#![allow(unused)]
fn main() {
pub trait Clone {
    // 在 clone 一个数据时只需要有已有数据的只读引用
    fn clone(&self) -> Self;

    fn clone_from(&mut self, source: &Self) {
        *self = source.clone()
    }
}
}

Copy

Copy trait 没有任何额外的方法,它只是一个标记 trait,可以用作 trait bound 来进行类型安全检查。

#![allow(unused)]
fn main() {
// 如果要实现 Copy trait 的话,必须实现 Clone trait。
pub trait Copy: Clone {}
}

Drop

#![allow(unused)]
fn main() {
pub trait Drop {
    fn drop(&mut self);
}
}

大部分场景无需为数据结构提供 Drop trait,系统默认会依次对数据结构的每个域做 drop。但有两种情况需要手动实现 Drop:

  1. 希望在数据结束生命周期的时候做一些事情,比如记录日志
  2. 需要对资源进行回收,比如锁资源的释放

Copy trait 和 Drop trait 是互斥的,两者不能共存。因为 Copy 是按位做浅拷贝,它拷贝的数据没有需要释放的资源,而 Drop 恰恰是为了释放额外的资源而生的。

Sized

Sized trait 用于标记有具体大小的类型。在使用泛型参数时,Rust 编译器会自动为泛型参数加上 Sized 约束。如果开发者显式定义了T: ?Sized,那么 T 就可以是任意大小。

Send / Sync 用于并发安全

#![allow(unused)]
fn main() {
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
}

这里的 auto 意味着编译器会在合适的场合,自动为数据结构添加它们的实现。

如果一个类型 T 实现了 Send trait,意味着 T 可以安全地从一个线程移动到另一个线程,即所有权可以在线程间移动。 如果一个类型 T 实现了 Sync trait,意味着 &T 可以安全地在多个线程间共享。

对于 Send/Sync 在线程安全中的作用:如果一个类型 T: Send,那么 T 在某个线程中的独占访问是线程安全的;如果一个类型 T: Sync,那么 T 在线程间的只读共享是安全的。

引用计数 Rc<T> 不支持 Send 也不支持 Sync。所以 Rc<T> 无法跨线程。 RefCell<T> 实现了 Send,所以可以在线程间转移所有权。但没有实现 Sync,因此无法跨线程使用 Arc<RefCell<T>> 这样的数据(因为 Arc 内部的数据是共享的,需要支持 Sync 的数据结构)。

From<T> / Into<T> 用于从值到值的转换

#![allow(unused)]
fn main() {
pub trait From<T> {
    fn from(t: T) -> Self;
}

pub trait Into<T> {
    fn into(self) -> T;
}

// 实现 From 会自动实现 Into
impl<T, U> Into<U> for T where U: From<T> {
    fn into(self) -> U {
        U::from(self)
    }
}

// From(以及 Into)是自反的:把类型 T 的值转换成类型 T,会直接返回
impl<T> From<T> for T {
    fn from(t: T) -> T {
        t
    }
}
}

有了这两个 trait,函数的接口就可以变得灵活,比如函数如果接受一个 IpAddr 为参数,那就可以使用 Into<IpAddr> 让更多的类型可以被这个函数使用。

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

fn print(v: impl Into<IpAddr>) {
    println!("{:?}", v.into());
}

fn main() {
    let v4: Ipv4Addr = "2.2.2.2".parse().unwrap();
    let v6: Ipv6Addr = "::1".parse().unwrap();

    // IPAddr 实现了 From<[u8; 4],转换 IPv4 地址
    print([1, 1, 1, 1]);
    // IPAddr 实现了 From<[u16; 8],转换 IPv6 地址
    print([0xfe80, 0, 0, 0, 0xaede, 0x48ff, 0xfe00, 0x1122]);
    // IPAddr 实现了 From<Ipv4Addr>
    print(v4);
    // IPAddr 实现了 From<Ipv6Addr>
    print(v6);
}

如果数据类型在转换过程中有可能出现错误,就需要使用 TryFrom<T> 和 TryInto<T>。

AsRef<T> / AsMut<T> 用于从引用到引用的转换

#![allow(unused)]
fn main() {
// T 使用大小可变的类型,如 str、[u8] 等
pub trait AsRef<T> where T: ?Sized {
    fn as_ref(&self) -> &T;
}

pub trait AsMut<T> where T: ?Sized {
    fn as_mut(&mut self) -> &mut T;
}
}

标准库中打开文件的接口 std::fs::File::open

#![allow(unused)]
fn main() {
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File>
}

意味着我们可以为这个参数传入 String、&str、PathBuf、Path 等类型,当使用 path.as_ref() 时,就能得到一个 &Path

Deref / DerefMut

Rust 为所有的运算符都提供了 trait,我们可以给自定义类型重载某些操作符。

操作运算符

#![allow(unused)]
fn main() {
pub trait Deref {
    // 解引用出来的结果类型
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

// DerefMut “继承”了 Deref
pub trait DerefMut: Deref {
    fn deref_mut(&mut self) -> &mut Self::Target;
}
}

Deref 和 DerefMut 是自动调用的,*b 会被展开为 *(b.deref())

deref

Debug / Display

#![allow(unused)]
fn main() {
pub trait Debug {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

pub trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
}

Debug 是为开发者调试打印数据结构所设计的,而 Display 是给用户显示数据结构所设计的。Debug trait 的实现可以通过派生宏直接生成,而 Display 必须手工实现。在使用的时候,Debug 用 {:?} 来打印,Display 用 {} 打印。

Default 为类型提供缺省值

#![allow(unused)]
fn main() {
pub trait Default {
    fn default() -> Self;
}
}

可以通过 derive 宏 #[derive(Default)] 来生成实现,前提是类型中的每个字段都实现了 Default trait。注意,enum 不能通过 derive 宏来实现 Default,因为 enum 的每个变体都可能有不同的字段,所以需要手动实现。

在初始化一个数据结构时,我们可以部分初始化,然后剩余的部分使用 ..Default::default()

智能指针

智能指针 vs 胖指针

智能指针一定是胖指针(比如 String),但是胖指针不一定是一个智能指针(比如 &str),因为 String 对堆上的值有所有权,而 &str 没有所有权。

可变引用可以传给需要不可变引用的函数

编译器不会报类型不匹配错误,因为可变引用可以隐式转换为不可变引用。

#![allow(unused)]
fn main() {
impl<T: ?Sized> Deref for &mut T {
    type Target = T;
    fn deref(&self) -> &T {
        *self
    }
}
}

智能指针 vs 结构体

凡是需要做资源回收的数据结构,且实现了 Deref/DerefMut/Drop,都是智能指针。

Box<T>

new 方法

#![allow(unused)]

fn main() {
#[cfg(not(no_global_oom_handling))]
#[inline(always)]
pub fn new(x: T) -> Self {
    // box 是 Rust 的内部关键字,在编译时,会使用内存分配器来分配内存
    box x
}
}

Box::new() 是一个函数,在 debug 模式下,传入它的数据会出现在栈上,再移动到堆上,有可能会引起栈溢出。在 release 模式下,该函数调用会被inline优化。

实现 Drop trait

#![allow(unused)]
fn main() {
unsafe impl<#[may_dangle] T: ?Sized, A: Allocator> Drop for Box<T, A> {
    fn drop(&mut self) {
        // Do nothing, drop is currently performed by compiler.
    }
}
}

Cow<'a, B>

#![allow(unused)]
fn main() {
pub enum Cow<'a, B> where B: 'a + ToOwned + ?Sized {
  Borrowed(&'a B),
  Owned(<B as ToOwned>::Owned),
}
}

Cow 包裹了一个只读借用,但如果调用者需要所有权或者需要修改内容,那么它会 clone 借用的数据。这种数据结构可以减少不必要的堆内存分配,提升系统效率。

#![allow(unused)]
fn main() {
pub trait ToOwned {
    type Owned: Borrow<Self>;
    #[must_use = "cloning is often expensive and is not expected to have side effects"]
    fn to_owned(&self) -> Self::Owned;

    fn clone_into(&self, target: &mut Self::Owned) { ... }
}

// Borrow 是个泛型 trait,表明一个类型可以被借用成不同的引用
// 比如 String 可以被借用为 &String 或者 &str
pub trait Borrow<Borrowed> where Borrowed: ?Sized {
    fn borrow(&self) -> &Borrowed;
}
}

str 对 ToOwned trait 的实现:

#![allow(unused)]
fn main() {
impl ToOwned for str {
    type Owned = String;
    #[inline]
    fn to_owned(&self) -> String {
        unsafe { String::from_utf8_unchecked(self.as_bytes().to_owned()) }
    }

    fn clone_into(&self, target: &mut String) {
        let mut b = mem::take(target).into_bytes();
        self.as_bytes().clone_into(&mut b);
        *target = unsafe { String::from_utf8_unchecked(b) }
    }
}
}

同时 String 必须要实现 Borrow<str> trait,这样能符合 ToOwned 的要求。

#![allow(unused)]
fn main() {
impl Borrow<str> for String {
    #[inline]
    fn borrow(&self) -> &str {
        &self[..]
    }
}
}

给 Cow 实现 Deref

#![allow(unused)]
fn main() {
impl<B: ?Sized + ToOwned> Deref for Cow<'_, B> {
    type Target = B;

    fn deref(&self) -> &B {
        // 我们分别取其内容,生成引用
        match *self {
            Borrowed(borrowed) => borrowed, // 对于 Borrowed,直接就是取出当中的引用
            Owned(ref owned) => owned.borrow(), // 对于 Owned,调用其 borrow() 方法,获得引用
        }
    }
}
}

应用案例

use std::borrow::Cow;
use url::Url;

fn main() {
    let url = Url::parse("https://tyr.com/rust?page=1024&sort=desc&extra=hello%20world").unwrap();
    let mut pairs = url.query_pairs();

    assert_eq!(pairs.count(), 3);

    let (mut k, v) = pairs.next().unwrap();
    // 因为 k, v 都是 Cow<str> 他们用起来感觉和 &str 或者 String 一样
    // 此刻,他们都是 Borrowed
    println!("key: {}, v: {}", k, v);
    // 当修改发生时,k 变成 Owned
    k.to_mut().push_str("_lala");

    print_pairs((k, v));

    print_pairs(pairs.next().unwrap());
    // 在处理 extra=hello%20world 时,value 被处理成 "hello world"
    // 所以这里 value 是 Owned
    print_pairs(pairs.next().unwrap());
}

fn print_pairs(pair: (Cow<str>, Cow<str>)) {
    println!("key: {}, value: {}", show_cow(pair.0), show_cow(pair.1));
}

fn show_cow(cow: Cow<str>) -> String {
    match cow {
        Cow::Borrowed(v) => format!("Borrowed {}", v),
        Cow::Owned(v) => format!("Owned {}", v),
    }
}
use serde::Deserialize;
use std::borrow::Cow;

#[derive(Debug, Deserialize)]
struct User<'input> {
    #[serde(borrow)]
    name: Cow<'input, str>,
    age: u8,
}

fn main() {
    let input = r#"{ "name": "Tyr", "age": 18 }"#;
    let user: User = serde_json::from_str(input).unwrap();

    match user.name {
        Cow::Borrowed(x) => println!("borrowed {}", x),
        Cow::Owned(x) => println!("owned {}", x),
    }
}

MutexGuard<T>

MutexGuard 通过 Drop trait 来确保退出时释放互斥锁,这样用户在使用 Mutex 时,可以不必关心何时释放这个互斥锁。因为无论你在调用栈上怎样传递 MutexGuard ,哪怕在错误处理流程上提前退出,Rust 的所有权机制可以确保只要 MutexGuard 离开作用域,锁就会被释放。

MutexGuard 不允许 Send,只允许 Sync,也就是说,你可以把 MutexGuard 的引用传给另一个线程使用,但你无法把 MutexGuard 整个移动到另一个线程。这样可以避免因加锁和解锁在不同的线程下带来的死锁风险。

use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    // 用 Arc 来提供并发环境下的共享所有权(使用引用计数)
    let metrics: Arc<Mutex<HashMap<Cow<'static, str>, usize>>> =
        Arc::new(Mutex::new(HashMap::new()));
    for _ in 0..32 {
        let m = metrics.clone();
        thread::spawn(move || {
            let mut g = m.lock().unwrap();
            // 此时只有拿到 MutexGuard 的线程可以访问 HashMap
            let data = &mut *g;
            // Cow 实现了很多数据结构的 From trait,
            // 所以我们可以用 "hello".into() 生成 Cow
            let entry = data.entry("hello".into()).or_insert(0);
            *entry += 1;
            // MutexGuard 被 Drop,锁被释放
        });
    }

    thread::sleep(Duration::from_millis(100));

    println!("metrics: {:?}", metrics.lock().unwrap());
}

内存分配器

替换默认的内存分配器

堆上分配内存的 Box<T> 有一个缺省的泛型参数 A,需要满足 Allocator,并且默认是 Global,这个 Global 就是默认的内存分配器。

use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

fn main() {}

自定义内存分配器

如果想要编写一个全局分配器,可以实现 GlobalAlloc trait,它和 Allocator trait 的主要区别在于是否允许分配长度为0的内存。

use std::alloc::{GlobalAlloc, Layout, System};

struct MyAllocator;

unsafe impl GlobalAlloc for MyAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let data = System.alloc(layout);
        // 这里不能使用 println!()
        // stdout 会打印到一个由 Mutex 互斥锁保护的共享全局 buffer 中,这个过程中会涉及内存的分配
        // 分配的内存又会触发 println!(),最终造成程序崩溃
        // eprintln! 直接打印到 stderr,不会 buffer
        eprintln!("ALLOC: {:p}, size {}", data, layout.size());
        data
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        System.dealloc(ptr, layout);
        eprintln!("FREE: {:p}, size {}", ptr, layout.size());
    }
}

#[global_allocator]
static GLOBAL: MyAllocator = MyAllocator;

#[allow(dead_code)]
struct Matrix {
    // 使用不规则的数字如 505 可以让 dbg! 的打印很容易分辨出来
    data: [u8; 505],
}

impl Default for Matrix {
    fn default() -> Self {
        Self { data: [0; 505] }
    }
}

fn main() {
    // 在这句执行之前已经有好多内存分配
    let data = Box::new(Matrix::default());

    // 输出中有一个 1024 大小的内存分配,是 println! 导致的
    println!(
        "!!! allocated memory: {:p}, len: {}",
        &*data,
        std::mem::size_of::<Matrix>()
    );

    // data 在这里 drop,可以在打印中看到 FREE
    // 之后还有很多其它内存被释放
}

Standard Error 中输出的结果如下:

ALLOC: 0x55c63eb5b940, size 5
ALLOC: 0x55c63eb5b960, size 48
ALLOC: 0x55c63eb5b9d0, size 505
ALLOC: 0x55c63eb5b500, size 1024
FREE: 0x55c63eb5b9d0, size 505
FREE: 0x55c63eb5b500, size 1024
FREE: 0x55c63eb5b940, size 5
FREE: 0x55c63eb5b960, size 48

切片

当我们构建自己的数据结构时,如果它内部也有连续排列的等长的数据结构,可以考虑 AsRef 或者 Deref 到切片。

切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)。在使用中主要用以下形式:

  • &[T]:表示一个只读的切片引用
  • &mut [T]:表示一个可写的切片引用
  • Box<[T]>:一个在堆上分配的切片。
fn main() {
    let arr = [1, 2, 3, 4, 5];
    let vec = vec![1, 2, 3, 4, 5];
    let s1 = &arr[..2];
    let s2 = &vec[..2];
    println!("s1: {:?}, s2: {:?}", s1, s2);

    // &[T] 和 &[T] 是否相等取决于长度和内容是否相等
    assert_eq!(s1, s2);
    // &[T] 可以和 Vec<T>/[T;n] 比较,也会看长度和内容
    // 这是因为它们之间实现了 PartialEq trait
    assert_eq!(&arr[..], vec);
    assert_eq!(&vec[..], arr);
}

在使用的时候,支持切片的具体数据类型,可以根据需要,解引用转换成切片类型。比如 Vec 和 [T; n] 会转化成为 &[T],这是因为 Vec 实现了 Deref trait,而 array 内建了到 &[T] 的解引用。这也就意味着,通过解引用,这几个和切片有关的数据结构都会获得切片的所有能力,包括:binary_searchchunksconcatcontainsstart_withend_withgroup_byiterjoinsortsplitswap 等一系列丰富的功能。

use std::fmt;
fn main() {
    let v = vec![1, 2, 3, 4];

    // Vec 实现了 Deref,&Vec<T> 会被自动解引用为 &[T],符合接口定义
    print_slice(&v);
    // 直接是 &[T],符合接口定义
    print_slice(&v[..]);

    // &Vec<T> 支持 AsRef<[T]>
    print_slice1(&v);
    // &[T] 支持 AsRef<[T]>
    print_slice1(&v[..]);
    // Vec<T> 也支持 AsRef<[T]>
    print_slice1(v);

    let arr = [1, 2, 3, 4];
    // 数组虽没有实现 Deref,但它的解引用就是 &[T]
    print_slice(&arr);
    print_slice(&arr[..]);
    print_slice1(&arr);
    print_slice1(&arr[..]);
    print_slice1(arr);
}

fn print_slice<T: fmt::Debug>(s: &[T]) {
    println!("{:?}", s);
}

fn print_slice1<T, U>(s: T)
where
    T: AsRef<[U]>,
    U: fmt::Debug,
{
    println!("{:?}", s.as_ref());
}

将 slice 转化成迭代器

fn main() {
    // 这里 Vec 在调用 iter() 时被解引用成 &[T],所以可以访问 iter()
    let result: Vec<i32> = vec![1, 2, 3, 4]
        .iter()
        .map(|v| v * v)
        .filter(|v| *v < 16)
        .collect();

    println!("{:?}", result);
}

Rust 下的迭代器是个懒接口(lazy interface),也就是说这段代码直到运行到 collect 时才真正开始执行,之前的部分不过是在不断地生成新的结构,来累积处理逻辑而已。Rust 大量使用了 inline 等优化技巧,使得迭代器的性能和 C 语言的 for 循环差别不大。

此外,itertools crate 还提供了额外的,标准库中没有提供的迭代器。

use itertools::Itertools;

fn main() {
    let err_str = "bad happened";
    let input = vec![Ok(21), Err(err_str), Ok(7)];
    let it = input
        .into_iter()
        // 对成功的结果进一步做 filter/map 操作
        .filter_map_ok(|i| if i > 10 { Some(i * 2) } else { None });
    // 结果应该是:vec![Ok(42), Err(err_str)]
    println!("{:?}", it.collect::<Vec<_>>());
}

特殊的切片 &str

字符列表和字符串的区别

use std::iter::FromIterator;

fn main() {
    let arr = ['h', 'e', 'l', 'l', 'o'];
    let vec = vec!['h', 'e', 'l', 'l', 'o'];
    let s = String::from("hello");
    let s1 = &arr[1..3];
    let s2 = &vec[1..3];
    // &str 本身就是一个特殊的 slice
    let s3 = &s[1..3];
    println!("s1: {:?}, s2: {:?}, s3: {:?}", s1, s2, s3);

    // &[char] 和 &[char] 是否相等取决于长度和内容是否相等
    assert_eq!(s1, s2);
    // &[char] 和 &str 不能直接对比,我们把 s3 变成 Vec<char>
    assert_eq!(s2, s3.chars().collect::<Vec<_>>());
    // &[char] 可以通过迭代器转换成 String,String 和 &str 可以直接对比
    assert_eq!(String::from_iter(s2), s3);
}

字符列表可以通过迭代器转换成 String,String 也可以通过 chars() 函数转换成字符列表,如果不转换,二者不能比较。

Box<[T]> 堆上的切片

Box<[T]> 和切片的引用 &[T] 很类似:它们都是在栈上有一个包含长度的胖指针,指向存储数据的内存位置。区别是:Box<[T]> 只会指向堆,&[T] 指向的位置可以是栈也可以是堆;此外,Box<[T]> 对数据具有所有权,而 &[T] 只是一个借用。

Box<[T]>Vec<T> 有一点点差别:Vec<T> 有额外的 capacity,可以增长;而 Box<[T]> 一旦生成就固定下来,没有 capacity,也无法增长。

堆上的切片

use std::ops::Deref;

fn main() {
    let mut v1 = vec![1, 2, 3, 4];
    v1.push(5);
    println!("cap should be 8: {}", v1.capacity());

    // 从 Vec<T> 转换成 Box<[T]>,此时会丢弃多余的 capacity
    let b1 = v1.into_boxed_slice();
    let mut b2 = b1.clone();

    let v2 = b1.into_vec();
    println!("cap should be exactly 5: {}", v2.capacity());

    assert!(b2.deref() == v2);

    // Box<[T]> 可以更改其内部数据,但无法 push
    b2[0] = 2;
    // b2.push(6);
    println!("b2: {:?}", b2);

    // 注意 Box<[T]> 和 Box<[T; n]> 并不相同
    let b3 = Box::new([2, 2, 3, 4, 5]);
    println!("b3: {:?}", b3);

    // b2 和 b3 相等,但 b3.deref() 和 v2 无法比较
    assert!(b2 == b3);
    // assert!(b3.deref() == v2);
}

Vec<T> 可以通过 into_boxed_slice() 转换成 Box<[T]>Box<[T]> 也可以通过 into_vec() 转换回 Vec<T>

Box<[T]> 有一个很好的特性是,不像 Box<[T;n]> 那样在编译时就要确定大小,它可以在运行期生成,以后大小不会再改变。

HashMap

Rust 哈希表不是用冲突链来解决哈希冲突,而是用开放寻址法的二次探查来解决的。

如果只需要简单确认元素是否在集合中,可以用 HashSet,它就是简化的 HashMap,可以用来存放无序的集合。

#![allow(unused)]
fn main() {
use hashbrown::hash_map as base;

// RandomState 使用 SipHash 作为缺省的哈希算法
#[derive(Clone)]
pub struct RandomState {
    k0: u64,
    k1: u64,
}

pub struct HashMap<K, V, S = RandomState> {
    // 复用了 hashbrown 库的 HashMap
    base: base::HashMap<K, V, S>,
}

// hashbrown 库的 HashMap
pub struct HashMap<K, V, S = DefaultHashBuilder, A: Allocator + Clone = Global> {
    pub(crate) hash_builder: S,
    pub(crate) table: RawTable<(K, V), A>,
}

pub struct RawTable<T, A: Allocator + Clone = Global> {
    table: RawTableInner<A>,

    // Tell dropck that we own instances of T.
    marker: PhantomData<T>,
}

struct RawTableInner<A> {
    bucket_mask: usize,

    // [Padding], T1, T2, ..., Tlast, C1, C2, ...
    //                                ^ points here
    // 指向哈希表堆内存末端的 ctrl 区
    ctrl: NonNull<u8>,

    // 哈希表在下次自动增长前还能存储多少数据
    // 随着哈希表不断插入数据,它会以 2 的幂减一的方式增长
    growth_left: usize,

    // 哈希表现在有多少数据
    items: usize,
    alloc: A,
}
}

自定义数据结构做 Hash key

use std::{
    collections::{hash_map::DefaultHasher, HashMap},
    hash::{Hash, Hasher},
};

// 如果要支持 Hash,可以用 #[derive(Hash)],前提是每个字段都实现了 Hash
// 如果要能作为 HashMap 的 key,还需要 PartialEq 和 Eq
#[derive(Debug, Hash, PartialEq, Eq)]
struct Student<'a> {
    name: &'a str,
    age: u8,
}

impl<'a> Student<'a> {
    pub fn new(name: &'a str, age: u8) -> Self {
        Self { name, age }
    }
}

fn main() {
    let mut hasher = DefaultHasher::new();
    let student = Student::new("Tyr", 18);
    // 实现了 Hash 的数据结构可以直接调用 hash 方法
    student.hash(&mut hasher);
    let mut map = HashMap::new();
    // 实现了 Hash / PartialEq / Eq 的数据结构可以作为 HashMap 的 key
    map.insert(student, vec!["Math", "Writing"]);
    println!("hash: 0x{:x}, map: {:?}", hasher.finish(), map);
}

BTreeMap

BTreeMap 是内部使用 B-tree 来组织哈希表的数据结构,和 HashMap 不同的是,BTreeMap 是有序的。

use std::collections::BTreeMap;

fn main() {
    let map = BTreeMap::new();
    let mut map = explain("empty", map);

    for i in 0..16usize {
        map.insert(format!("Tyr {}", i), i);
    }

    let mut map = explain("added", map);

    map.remove("Tyr 1");

    let map = explain("remove 1", map);

    for item in map.iter() {
        println!("{:?}", item);
    }
}

// BTreeMap 结构有 height,node 和 length
// 我们 transmute 打印之后,再 transmute 回去
fn explain<K, V>(name: &str, map: BTreeMap<K, V>) -> BTreeMap<K, V> {
    let arr: [usize; 3] = unsafe { std::mem::transmute(map) };
    println!(
        "{}: height: {}, root node: 0x{:x}, len: 0x{:x}",
        name, arr[0], arr[1], arr[2]
    );
    unsafe { std::mem::transmute(arr) }
}

如果想自定义的数据结构可以作为 BTreeMap 的 key,那么需要实现 PartialOrd 和 Ord。

use std::collections::BTreeMap;

#[derive(Debug, PartialOrd, Ord, PartialEq, Eq)]
struct Name {
    pub name: String,
    pub flags: u32,
}

impl Name {
    pub fn new(name: impl AsRef<str>, flags: u32) -> Self {
        Self {
            name: name.as_ref().to_string(),
            flags,
        }
    }
}

fn main() {
    let mut map = BTreeMap::new();
    map.insert(Name::new("/etc/password", 0x1), 12);
    map.insert(Name::new("/etc/hosts", 0x1), 4);
    map.insert(Name::new("/home/tchen", 0x0), 28);

    for item in map.iter() {
        println!("{:?}", item);
    }
}

错误处理

? 操作符

? 操作符内部被展开成类似这样的代码:

#![allow(unused)]
fn main() {
match result {
    Ok(v) => v,
    Err(e) => return Err(e.into())
}
}

函数式错误处理

函数式错误处理

cache_unwind

Rust 标准库提供了catch_unwind()函数,能够像异常处理那样将调用栈回溯到 catch_unwind 这一刻,作用和其它语言的 try {…} catch {…} 一样。

use std::panic;

fn main() {
    let result = panic::catch_unwind(|| {
        println!("hello!");
    });
    assert!(result.is_ok());
    let result = panic::catch_unwind(|| {
        panic!("oh no!");
    });
    assert!(result.is_err());
    println!("panic captured: {:#?}", result);
}

Error trait

为了规范代表错误的数据类型的行为,Rust 定义了 Error trait:

#![allow(unused)]
fn main() {
pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
    fn backtrace(&self) -> Option<&Backtrace> { ... }
    fn description(&self) -> &str { ... }
    fn cause(&self) -> Option<&dyn Error> { ... }
}
}

thiserror 可以帮助简化错误类型的定义。

#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
}
}

anyhow 实现了 anyhow::Error 和任意符合 Error trait 的错误类型之间的转换,让你可以使用 ? 操作符,不必再手工转换错误类型。

闭包

闭包是一种匿名类型,一旦声明,就会产生一个新的类型,但这个类型无法被其它地方使用。这个类型就像一个结构体,会包含所有捕获的变量。闭包是存储在上,并且除了捕获的数据外,闭包本身不包含任何额外函数指针指向闭包的代码。

#![allow(unused)]
fn main() {
use std::{collections::HashMap, mem::size_of_val};

// 长度为 0
let c1 = || println!("hello world!");
// 和参数无关,长度也为 0
let c2 = |i: i32| println!("hello: {}", i);
let name = String::from("tyr");
let name1 = name.clone();
let mut table = HashMap::new();
table.insert("hello", "world");
// 如果捕获一个引用,长度为 8
let c3 = || println!("hello: {}", name);
// 捕获移动的数据 name1(长度 24) + table(长度 48),因此 closure 长度 72
let c4 = move || println!("hello: {}, {:?}", name1, table);
let name2 = name.clone();
// 和局部变量无关,捕获了一个 String name2,因此 closure 长度 24
let c5 = move || {
    let x = 1;
    let name3 = String::from("lindsey");
    println!("hello: {}, {:?}, {:?}", x, name2, name3);
};

println!(
    "c1: {}, c2: {}, c3: {}, c4: {}, c5: {}, main: {}",
    size_of_val(&c1),
    size_of_val(&c2),
    size_of_val(&c3),
    size_of_val(&c4),
    size_of_val(&c5),
    size_of_val(&main),
);
}

不带 move 时,闭包捕获的是对应自由变量的引用;带 move 时,对应自由变量的所有权会被移动到闭包结构中

闭包的类型

闭包的类型

#![allow(unused)]
fn main() {
pub trait FnOnce<Args> {
    type Output;
    // 会转移 self 的所有权到 call_once 函数中
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
}
#![allow(unused)]
fn main() {
// 一个 FnMut 闭包,可以被传给一个需要 FnOnce 的上下文,此时调用闭包相当于调用了 call_once()
pub trait FnMut<Args>: FnOnce<Args> {
    extern "rust-call" fn call_mut(
        &mut self,
        args: Args
    ) -> Self::Output;
}
}
#![allow(unused)]
fn main() {
// 任何需要 FnOnce 或者 FnMut 的场合,都可以传入满足 Fn 的闭包
pub trait Fn<Args>: FnMut<Args> {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
}

将闭包作为参数传递

fn main() {
    let v = vec![0u8; 1024];
    let v1 = vec![0u8; 1023];

    // Fn,不移动所有权
    let mut c = |x: u64| v.len() as u64 * x;
    // Fn,移动所有权
    let mut c1 = move |x: u64| v1.len() as u64 * x;

    println!("direct call: {}", c(2));
    println!("direct call: {}", c1(2));

    println!("call: {}", call(3, &c));
    println!("call: {}", call(3, &c1));

    println!("call_mut: {}", call_mut(4, &mut c));
    println!("call_mut: {}", call_mut(4, &mut c1));

    println!("call_once: {}", call_once(5, c));
    println!("call_once: {}", call_once(5, c1));
}

fn call(arg: u64, c: &impl Fn(u64) -> u64) -> u64 {
    c(arg)
}

fn call_mut(arg: u64, c: &mut impl FnMut(u64) -> u64) -> u64 {
    c(arg)
}

fn call_once(arg: u64, c: impl FnOnce(u64) -> u64) -> u64 {
    c(arg)
}

返回闭包

use std::ops::Mul;

fn main() {
    let c1 = curry(5);
    println!("5 multiply 2 is: {}", c1(2));

    let adder2 = curry(3.14);
    println!("pi multiply 4^2 is: {}", adder2(4. * 4.));
}

fn curry<T>(x: T) -> impl Fn(T) -> T
where
    T: Mul<Output = T> + Copy,
{
    move |y| x * y
}

给闭包实现其他 trait

有些接口既可以传入一个结构体,又可以传入一个函数或者闭包。

pub trait Executor {
    fn execute(&self, cmd: &str) -> Result<String, &'static str>;
}

struct BashExecutor {
    env: String,
}

impl Executor for BashExecutor {
    fn execute(&self, cmd: &str) -> Result<String, &'static str> {
        Ok(format!(
            "fake bash execute: env: {}, cmd: {}",
            self.env, cmd
        ))
    }
}

impl<T> Executor for T
where
    T: Fn(&str) -> Result<String, &'static str>,
{
    fn execute(&self, cmd: &str) -> Result<String, &'static str> {
        self(cmd)
    }
}

fn main() {
    let env = "PATH=/usr/bin".to_string();

    let cmd = "cat /etc/passwd";
    let r1 = execute(cmd, BashExecutor { env: env.clone() });
    println!("{:?}", r1);

    let r2 = execute(cmd, |cmd: &str| {
        Ok(format!("fake fish execute: env: {}, cmd: {}", env, cmd))
    });
    println!("{:?}", r2);
}

fn execute(cmd: &str, exec: impl Executor) -> Result<String, &'static str> {
    exec.execute(cmd)
}

参考资料

控制系统基础

控制系统建模

对于一个输入为 \(u\),输出为 \(y\) 的动态系统,当 \(t\) 时刻的输出 \(y(t)\) 由直到 \(t\) 时刻为止的输入和输出来决定的时候,这个系统可以用下面的微分方程来表示:

$$ \frac{d^n}{dt^n}y(t) + a_{n-1}\frac{d^{n-1}}{dt^{n-1}}y(t) + \cdots + a_1\frac{d}{dt}y(t) + a_0y(t) \\ = b_m\frac{d^m}{dt^m}u(t) + b_{m-1}\frac{d^{m-1}}{dt^{m-1}}u(t) + \cdots + b_1\frac{d}{dt}u(t) + b_0u(t) $$

微分方程很难求解以及分析系统行为,所以通常会将微分方程转换成传递函数模型或者状态空间模型

传递函数

传递函数模型使用复变函数来表现系统模型,可以通过将微分方程两边进行初始值为0的拉普拉斯变换得到。

拉普拉斯变换的定义:\(g(s) = \mathcal{L}[g(t)] = \int_0^{\infty}g(\tau)e^{-s\tau}d\tau\)


微分的拉普拉斯变换

$$ \begin{align} \mathcal{L}[\dot y(t)] &= \int_0^{\infty}\dot y(\tau)e^{-s\tau}d\tau \\ &= [y(\tau)e^{-s\tau}]_0^{\infty} - \int_0^{\infty}-sy(\tau)e^{-s\tau}d\tau \\ &= [y(\tau)e^{-s\tau}]_0^{\infty} + s\int_0^{\infty}y(\tau)e^{-s\tau}d\tau \\ &= y(\infty) -y(0) + s\mathcal{L}[y(t)] \\ &= sy(s) - y(0) \end{align} $$

令 \(y(0) = 0\),则得到 \(\mathcal{L}[\dot y(t)] = sy(s)\)


积分的拉普拉斯变换

令 \(f(t) = \int_0^ty(\tau)d\tau\), 则 \(\dot f(t) = y(t)\)

$$ \begin{align} \mathcal{L}[\dot f(t)] &= sf(s) - f(0) \\ &= sf(s) - \int_0^0y(\tau)d\tau \\ &= sf(s) \end{align} $$

$$ \mathcal{L}[\int_0^ty(\tau)d\tau] = \mathcal{L}[f(t)] = f(s) = \frac{1}{s}\mathcal{L}[\dot f(t)] = \frac{1}{s}\mathcal{L}[y(t)] = \frac{1}{s}y(s) $$

状态空间

状态空间模型通过矩阵的形式将多元高阶微分方程表示成一阶微分方程的形式。传递函数模型表达的是输入和输出的关系,而状态空间模型表达的是输入->状态->输出的关系。我们可以自由地选择状态变量。此外,状态空间模型也可以处理初始值不为0的情况。

$$ \mathcal{P}: \begin{cases} \mathbf{\dot x}(t) &= \mathbf{A} \mathbf{x}(t) + \mathbf{B} \mathbf{u}(t) \\ \mathbf{y}(t) &= \mathbf{C} \mathbf{x}(t) + \mathbf{D} \mathbf{u}(t) \end{cases} $$

这里的\(\mathbf{x}\)为状态,\(\mathbf{u}\)为输入,\(\mathbf{y}\)为输出,\(\mathbf{A}\), \(\mathbf{B}\), \(\mathbf{C}\), \(\mathbf{D}\)为常数矩阵。

RLC 电路

RLC电路

根据欧姆定律, $$ v_{in}(t) = L\frac{d}{dt}i(t) + Ri(t) + \frac{1}{C}\int_{0}^{t}i(\tau) d\tau $$

设定输出为 \(y(t) = v_{out}(t) = \frac{1}{C}\int_{0}^{t}i(\tau) d\tau\), 则 \(C\dot y(t) = i(t)\)

设定输入为 \(u(t) = v_{in}(t)\)

可以得到 $$ LC\ddot y(t) + RC\dot y(t) + y(t) = u(t) $$

传递函数模型

对微分方程两边做拉普拉斯变换,得到 $$ LCs^2y(s) + RCsy(s) + y(s) = u(s) $$

于是传递函数模型等于 $$ \mathcal{P} = \frac{y(s)}{u(s)} = \frac{1}{LCs^2 + RCs + 1} $$

状态空间模型

设 \(\mathbf{x}(t) = \left[ \begin{matrix} \int_0^t i(\tau)d\tau \\ i(t) \\ \end{matrix} \right] \), \(u(t) = v_{in}\),\(y(t) = \frac{1}{C}\int_0^t i(\tau)d\tau\)

则状态方程为 $$ \mathbf{\dot x}(t) = \left[ \begin{matrix} 0& 1 \\ -\frac{1}{LC}& -\frac{R}{L} \\ \end{matrix}\right] \mathbf{x}(t) + \left[ \begin{matrix} 0 \\ \frac{1}{L} \\ \end{matrix} \right] u(t) $$

而输出方程为 $$ y(t) = \left[ \begin{matrix} \frac{1}{C} \quad 0 \end{matrix}\right] \mathbf{x}(t) $$

运放电路

运放电路

根据运放的"虚短"特性,可以得到 \(i_1(t) = \frac{v_{in}(t)}{R_1}\), \(i_2(t) = \frac{v_{out}(t)}{R_2}\)

以及 \(v_{out}(t) = \frac{1}{C}\int_{0}^{\tau}i_3(\tau) d\tau\), 即 \(i_3(t) = C\dot v_{out}\)

根据运放的"虚断"特性,\(i_1(t) + i_2(t) + i_3(t) = 0\)

代入方程得到 $$ \frac{v_{in}(t)}{R_1} + \frac{v_{out}(t)}{R_2} + C\dot v_{out}(t) = 0 $$

设定输出 \(y(t)=v_{out}(t)\), 设定输入 \(u(t)=v_{in}(t)\)

因此可以得到 $$R_1R_2C\dot y(t) + R_1y(t) = -R_2u(t)$$

传递函数模型

对微分方程两边做拉普拉斯变换,得到 \(R_1R_2Csy(s) + R_1y(s) = -R_2u(s)\), 于是传递函数模型等于 $$ \mathcal{P} = \frac{y(s)}{u(s)} = \frac{-R_2}{R_1R_2Cs + R_1} $$

状态空间模型

设 \(x(t) = v_{out}(t), u(t)=v_{in}(t),y(t)=v_{out}(t)\),则可得到状态方程 $$ \dot x(t) = -\frac{1}{CR_2}x(t) - \frac{1}{CR_1}u(t) $$ 输出方程为 $$ y(t)=x(t) $$

一阶滞后系统的阶跃响应

一阶滞后系统的传递函数模型可以表示为: $$ \mathcal{P}(s) = \frac{K}{Ts + 1} $$ \(K\) 是滞后系统的增益,\(T\) 是滞后系统的时间常数。 时间常数指的是达到稳定值的 63.2% 时所需的时间,它决定了系统的响应速度。

一阶滞后系统的阶跃响应

推导一阶滞后系统在时域上的阶跃响应

$$ y(s) = \frac{K}{Ts+1}\frac{1}{s} = K(\frac{1}{s} - \frac{T}{Ts+1}) = K(\frac{1}{s} - \frac{1}{s+\frac{1}{T}}) $$

对其进行拉普拉斯逆变换得到 $$ y(t) = K(1-e^{-\frac{1}{T}t}) $$

当 T > 0 时,\(y(\infty)=K, y(T)=1-e^{-1}=0.632\)

二阶滞后系统的阶跃响应

二阶滞后系统的传递函数模型可以表示为: $$ \mathcal{P}(s) = \frac{K\omega_n^2}{s^2+2\zeta\omega_ns+\omega_n^2} $$ \(\zeta\)称为阻尼系数,\(\omega_n\)称为无阻尼自然振荡频率

二阶滞后系统的阶跃响应

阻尼系数是确定阻尼特性(稳定度)的参数:

  • 当\(\zeta\) 为负值时,输出会发散
  • 当\(\zeta\) 为正值时,输出会收敛于稳定值:
    • 当\(0<\zeta<1\)时,系统振荡而收敛,在 \(T_p = \frac{\pi}{\omega_n \sqrt{1-\zeta^2}}\)处取得最大值 \(y_{max}=K(1+e^{-\zeta\omega_nT_p})\)
    • 当\(\zeta \geq 1\)时系统不振荡而收敛

无阻尼自然振荡频率\(\omega_n\)类似于一阶滞后系统中的时间常数T,决定了响应速度的快慢。

pytest 基础

常用命令行选项

  • --collect-only:展示在给定的配置下哪些测试用例会被运行

  • -k:使用表达式指定要运行的测试用例,表达式可以匹配测试名,且表达式中可以包含 and,or,not 关键字

    ➤ pytest -v --collect-only -k "replace or asdict"
    
    <Package tests>
      <Module test_named_tuple.py>
        <Function test_asdict>
          _asdict() should return a dictionary.
        <Function test_replace>
          replace() should change passed in fields.
    
  • -m:标记测试并分组,以便快速选中并运行,可以同时指定多个标记,标记之间可以使用 and,or,not 关键字

  • -s:允许终端在测试运行时输出某些结果,包括任何符合标准的输出流信息,等价于 --capture=no

  • --markers:列出所有可用的 marker

  • --fixtures:列出所有可用的 fixture

  • --setup-show:显示测试用例的 setup 和 teardown 过程

  • --durations=0:将所有阶段的耗时从长到短排序

  • -l:显示失败的测试用例的局部变量

  • -x:遇到失败的测试用例时停止运行

  • --lf, --last-failed:只运行上次失败的测试用例,如果没有失败的测试用例,则运行所有测试用例

  • --ff, --failed-first:先运行上次失败的测试用例,然后再运行其他测试用例

  • --cache-show:显示缓存的内容(可用于多个测试会话之间共享)

  • --cache-clear:清除缓存

  • --pdb:遇到失败的测试用例时进入调试模式

  • --tb=[auto/long/short/line/native/no]:指定发生错误时堆栈回溯信息的粒度

  • --junit-xml:将测试结果输出为 junit 格式的 xml 文件

测试搜索

pytest 能自动搜索所有待执行的测试用例,你需要遵守以下几条命名规则:

  • 测试文件应该命名为 test_*.py 或者 *_test.py
  • 测试函数、测试类方法应当命名为 test_<something>
  • 测试类要命名为 Test<Something>
  • 默认的测试搜索规则可以在 pytest.ini 中进行修改

编写测试函数

  • pytest 会截断对原生 assert 的调用,替换为 pytest 定义的 assert

  • 使用 with pytest.raises(Exception): 来断言异常

    def test_start_tasks_db_raises():
      """Make sure unsupported db raises an exception."""
      with pytest.raises(ValueError) as exc_info:
          tasks.start_tasks_db("some/great/path", "mysql")
      exception_msg = exc_info.value.args[0]
      assert exc_info.type == ValueError
      assert exception_msg == "db_type must be a 'tiny' or 'mongo'"
    
  • 使用 pytest.approx 来断言浮点数

    def test_approx():
      """Using pytest.approx."""
      v1 = 0.1 + 0.2
      v2 = 0.3
      assert v1 != v2
      assert v1 == pytest.approx(v2)
    
  • 使用 @pytest.mark.skip@pytest.mark.skipif 装时期来跳过不希望运行的测试

  • 使用 @pytest.mark.xfail 装饰器来标记预期失败的测试

    @pytest.mark.xfail(
        sys.platform == "win32",
        reason="can't run on Windows"
    )
    def test_unique_id_is_a_duck():
        """Demonstrate xfail"""
        uid = tasks.unique_id()
        assert uid == "a duck"
    
  • 使用 @pytest.mark.parametrize(argnames, argvalues) 装饰器来运行相同测试用例的不同参数组合, parametrize() 的第一个参数是用逗号分隔的字符串列表,第二个参数是值列表

    @pytest.mark.parametrize(
        "summary, owner, done",
        [
            ("sleep", None, False),
            ("wake", "brian", False),
            ("breath", "iris", True),
            ("eat", "bob", True),
        ],
    )
    def test_add_variety(summary, owner, done):
        """Demonstrate parameterize with 3 parameters."""
        new_task = Task(summary, owner, done)
        task_id = tasks.add(new_task)
        task_from_db = tasks.get(task_id)
        assert equivalent(new_task, task_from_db)
    
  • parametrize() 还可以通过 ids 参数来指定每个参数组合的名字, ids 参数的值是一个字符串列表,长度必须和 argvalues 参数的长度一致

    tasks_to_try = (
      Task("sleep", None, True),
      Task("wake", "brian", False),
      Task("breathe", "bob", True),
      Task("exercise", "iris", False),
    )
    task_ids = [f"Task({t.summary},{t.owner},{t.done})" for t in tasks_to_try]
    
    
    @pytest.mark.parametrize("task", tasks_to_try, ids=task_ids)
    def test_add_variety(task):
        """Demonstrate parameterize with 3 parameters."""
        task_id = tasks.add(task)
        task_from_db = tasks.get(task_id)
        assert equivalent(task, task_from_db)
    
  • ids 不能被参数化批量生成时,需要自定义的时候,可以给每个参数值定义一个 id 来做标识: pytest.param(<value>, id="something")

    @pytest.mark.parametrize(
        "task",
        [
            pytest.param(Task("sleep")),
            pytest.param(Task("wake", "brian"), id="summary/owner"),
            pytest.param(Task("breathe", "BRIAN", True), id="summary/owner/done"),
        ],
    )
    def test_add_variety(task):
        """Demonstrate parameterize with 3 parameters."""
        task_id = tasks.add(task)
        task_from_db = tasks.get(task_id)
        assert equivalent(task, task_from_db)
    

    运行结果:

    test_add.py::test_add_variety[task0] PASSED # 因为没有添加id标识,所以可读性不好
    test_add.py::test_add_variety[summary/owner] PASSED
    test_add.py::test_add_variety[summary/owner/done] PASSED
    

Fixture

  • Fixture 是一种特殊的函数,在测试函数运行前后,由 pytest 执行的外壳函数,通常用来做一些准备工作,比如创建数据库连接,创建临时文件等。

    @pytest.fixture()
    def some_data():
        return 42
    
    
    def test_some_data(some_data):
        assert some_data == 42
    
  • 多个 fixture 之间可以相互依赖,比如一个 fixture 依赖另一个 fixture,这时可以在 fixture 函数的参数列表中添加依赖的 fixture 函数

    @pytest.fixture()
    def db_with_3_tasks(tasks_db, tasks_just_a_few):
        """Connected db with 3 tasks, all unique"""
        for t in tasks_just_a_few:
            tasks.add(t)
    
    def test_add_increases_count(db_with_3_tasks):
      tasks.add(Task("throw a party"))
      assert tasks.count() == 4
    
  • 使用 usefixtures 为测试类指定 fixture,这种方法不能够使用 fixture 函数的返回值

    @pytest.mark.usefixtures("tasks_db")
    class TestAdd:
        """Using a fixture for setup and teardown."""
    
        def test_add_increases_count(self):
            """tasks.add() affect tasks.count()."""
            tasks.add(Task("throw a party"))
            assert tasks.count() == 1
    
  • 通过 scope 参数指定 fixture 的作用范围(也可以理解为控制 fixture 执行 setup 和 teardown 的频率),scope 参数的值可以是 functionclassmodulesession,默认值是 function。fixture 只能使用同级别的 fixture 或者比自己级别更高的 fixture。

    @pytest.fixture(scope="session")
    def tasks_db(tmpdir_factory):
        """Connect to db before testing, disconnect after."""
        tmp_dir = tmpdir_factory.mktemp("temp")
        tasks.start_tasks_db(str(tmp_dir), "tiny")
        yield
        tasks.stop_tasks_db()
    
  • 可以为常用 fixture 添加 autouse=True 选项,使得作用域内的测试函数自动运行该 fixture

  • 可以设置 name 参数来重命名 fixture

  • 对测试函数进行参数化,可以多次运行的只是该测试函数。而使用参数化 fixture,每个使用该 fixture 的测试函数都可以被运行多次

    tasks_to_try = [
        pytest.param(Task("sleep")),
        pytest.param(Task("wake", "brian")),
        pytest.param(Task("breathe", "BRIAN", True)),
    ]
    
    
    def id_func(task: Task):
        """Create a string representation for test report."""
        return f"({task.summary},{task.owner},{task.done})"
    
    
    @pytest.fixture(params=tasks_to_try, ids=id_func)
    def a_task(request):
        """Using a fixture to pass data to a test."""
        return request.param
    
    
    def test_add_variety(a_task):
        """Demonstrate parameterize with 3 parameters."""
        task_id = tasks.add(a_task)
        task_from_db = tasks.get(task_id)
        assert equivalent(a_task, task_from_db)
    

    ids 参数可以指定为一个函数,供 pytest 针对每个参数生成标识

内置 Fixture

tmpdir 和 tmpdir_factory

def test_tmpdir(tmpdir):
    print(tmpdir.realpath)
    # tmpdir already has a path name associated with it
    # join() extends the path to include a filename
    # the file is created when it's written to
    # tmpdir is a py.path.local object
    a_file = tmpdir.join("something.txt")

    # you can create directories
    a_sub_dir = tmpdir.mkdir("anything")

    # you can create files in the directory (created when written)
    another_file = a_sub_dir.join("something_else.txt")

    # this write creates "something.txt"
    a_file.write("contents may settle during shipping")

    # this write creates "anything/something_else.txt"
    another_file.write("something different")

    assert a_file.read() == "contents may settle during shipping"
    assert another_file.read() == "something different"
def test_tmpdir_factor(tmpdir_factory):
    print(tmpdir_factory.getbasetemp())

    # you should start with making a directory
    # a_dir acts like the object returned from the tmpdir fixture
    a_dir = tmpdir_factory.mktemp("mydir")
    a_file = a_dir.join("something.txt")

    # you can create directories
    a_sub_dir = a_dir.mkdir("anything")

    # you can create files in the directory (created when written)
    another_file = a_sub_dir.join("something_else.txt")

    # this write creates "something.txt"
    a_file.write("contents may settle during shipping")

    # this write creates "anything/something_else.txt"
    another_file.write("something different")

    assert a_file.read() == "contents may settle during shipping"
    assert another_file.read() == "something different"

tmpdir_factory 的作用范围是会话级别,tmpdir 的作用范围是函数级别。如果需要模块或者类级别作用范围的目录,可以利用 tmpdir_factory 再创建一个 fixture。

@pytest.fixture(scope="module")
def my_tmpdir(tmpdir_factory):
    pass

pytestconfig

pytestconfigrequest.config 的快捷方式,可以通过 pytestconfig.getoption() 获取命令行选项。

def test_option(pytestconfig):
    print(f"args: {pytestconfig.args}")
    print(f"invocation_dir: {pytestconfig.invocation_dir}")
    print(f"rootdir: {pytestconfig.rootdir}")
    print(f"inifile: {pytestconfig.inifile}")
    print(f"basetemp: {pytestconfig.getoption('basetemp')}")
    print(f"-k EXPRESSION: {pytestconfig.getoption('keyword')}")
    print(f"-v, --verbose: {pytestconfig.getoption('verbose')}")
    print(f"--tb style: {pytestconfig.getoption('tbstyle')}")

也可以基于 pytestconfig 创建新的 fixture

@pytest.fixture()
def foo(pytestconfig):
    return pytestconfig.option.foo


def test_option(foo):
    print("foo: {}".format(foo))

cache

cache 的作用是存储一段测试会话的信息,在下一段测试会话中使用。cache的接口主要有两个:cache.get(key, default)cache.set(key, value)

习惯上,键名以应用名字或插件名字开始,接着是/,然后是分隔开的键字符串。键值可以是任何可以转换成 JSON 的对象。

Duration = namedtuple("Duration", ["current", "last"])


@pytest.fixture(scope="session")
def duration_cache(request):
    key = "duration/test_durations"
    d = Duration({}, request.config.cache.get(key, {}))
    yield d
    request.config.cache.set(key, d.current)


@pytest.fixture(autouse=True)
def check_duration(request, duration_cache):
    d = duration_cache
    nodeid = request.node.nodeid  # nodeid is a unique identifier for the test
    start_time = datetime.datetime.now()
    yield
    duration = (datetime.datetime.now() - start_time).total_seconds()
    d.current[nodeid] = duration
    if d.last.get(nodeid, None) is not None:
        errorstring = "test duration over 2x last duration"
        assert duration <= (d.last[nodeid] * 2), errorstring


@pytest.mark.parametrize("i", range(5))
def test_slow_stuff(i):
    time.sleep(random.random())
➤ pytest --cache-show
================ test session starts ================
platform linux -- Python 3.10.7, pytest-7.1.2, pluggy-1.0.0
rootdir: /home/morris/tasks/tests
cachedir: /home/morris/tasks/tests/.pytest_cache
---------------- cache values for '*' ----------------
duration/test_durations contains:
  {'test_cache.py::test_slow_stuff[0]': 0.133439,
   'test_cache.py::test_slow_stuff[1]': 0.569769,
   'test_cache.py::test_slow_stuff[2]': 0.7625,
   'test_cache.py::test_slow_stuff[3]': 0.930151,
   'test_cache.py::test_slow_stuff[4]': 0.748842}

同时也能看到被 cache 的数据保存在 .pytest_cache 目录下。

capsys

  • capsys.readouterr():返回一个包含 outerrnamedtuple,分别是标准输出和标准错误的内容

    def test_capsys(capsys):
        print("hello")
        print("world", file=sys.stderr)
        out, err = capsys.readouterr()
        assert out == "hello\n"
        assert err == "world\n"
    
  • 临时让输出绕过默认的输出捕获机制,可以使用 capsys.disabled() 上下文管理器

    def test_capsys_disabled(capsys):
        with capsys.disabled():
            print("always print this")
        print("normal print, usually captured")
    

monkeypatch

monkeypatch 可以在运行期间对类或模块进行同态修改,比如修改环境变量、修改类属性、修改模块属性等。

  • setattr(target, name, value, raising=True):修改对象的属性值

    def test_setattr(monkeypatch):
        class A:
            a = 1
    
        monkeypatch.setattr(A, "a", 2)
        assert A.a == 2
    
  • delattr(target, name, raising=True):删除对象的属性

    def test_delattr(monkeypatch):
        class A:
            a = 1
    
        monkeypatch.delattr(A, "a")
        assert not hasattr(A, "a")
    
  • setitem(mapping, name, value):修改字典的值

    def test_setitem(monkeypatch):
        d = {"a": 1}
        monkeypatch.setitem(d, "a", 2)
        assert d["a"] == 2
    
  • delitem(obj, name, raising=True):删除字典的值

    def test_delitem(monkeypatch):
        d = {"a": 1}
        monkeypatch.delitem(d, "a")
        assert "a" not in d
    
  • setenv(name, value, prepend=False):修改环境变量

    def test_setenv(monkeypatch):
        monkeypatch.setenv("foo", "bar")
        assert os.environ["foo"] == "bar"
    
  • delenv(name, raising=True):删除环境变量

    def test_delenv(monkeypatch):
        monkeypatch.setenv("foo", "bar")
        monkeypatch.delenv("foo")
        assert "foo" not in os.environ
    
  • syspath_prepend(path):在 sys.path 的开头添加路径,sys.path 是 Python 模块的搜索路径

    def test_syspath_prepend(monkeypatch):
        monkeypatch.syspath_prepend("/foo")
        assert sys.path[0] == "/foo"
    
  • chdir(path):修改当前工作目录

    def test_chdir(monkeypatch):
        monkeypatch.chdir("/tmp")
        assert os.getcwd() == "/tmp"
    

recwarn

recwarn 用来检查待测代码产生的警告信息。

def test_recwarn(recwarn):
    warnings.warn("Please stop using this function", DeprecationWarning)
    assert len(recwarn) == 1
    w = recwarn.pop()
    assert w.category == DeprecationWarning
    assert str(w.message) == "Please stop using this function"

pytest 还可以使用 pytest.warns 来检查警告信息。

def lame_function():
    warnings.warn("Please stop using this", DeprecationWarning)


def test_lame_function():
    with pytest.warns(DeprecationWarning) as warning_list:
        lame_function()

    assert len(warning_list) == 1
    w = warning_list.pop()
    assert w.category == DeprecationWarning
    assert str(w.message) == "Please stop using this"

插件

寻找插件

编写插件

  • 使用 pytest_addoption 可以给 pytest 添加额外的命令行选项

    def pytest_addoption(parser):
        parser.addoption(
            "--myopt",
            action="store_true",
            help="some boolean option",
        )
        parser.addoption(
            "--foo",
            action="store",
            default="bar",
            help="foo: bar or baz",
        )
    

    或者不通过命令行参数,使用 pytest.ini 文件做自定义的配置

    def pytest_addoption(parser):
        parser.addini(
            "myopt",
            type="bool",
            default=False,
            help="some boolean option",
        )
        parser.addini(
            "foo",
            type="string",
            default="bar",
            help="foo: bar or baz",
    
  • 使用 pytest_report_header 可以在测试报告的 header 中添加额外的信息

    def pytest_report_header(config):
        return "🍎🍎🍎🍎🍎🍎🍎🍎🍎"
    
  • 使用 pytest_report_teststatus 可以修改测试报告中的测试状态

    def pytest_report_teststatus(report):
        if report.when == "call" and report.failed:
            return report.outcome, "💥", "💥"
    

测试插件

  • 开启 pytester 插件,在 conftest.py 中添加 pytest_plugins = ["pytester"]

  • 使用 pytester 来测试插件

    @pytest.fixture()
    def sample_test(testdir):
        testdir.makepyfile(
            """
            def test_sample():
                assert True
            """
        )
        return testdir
    
    
    def test_verbose(sample_test):
        result = sample_test.runpytest("-v")
        result.stdout.fnmatch_lines(["*::test_sample PASSED*"])
        result.assert_outcomes(passed=1)
    

    testdir 自动创建了一个临时目录用来存放测试文件

常用插件

pytest-cov

报告测试覆盖率

  • 安装:pip install pytest-cov
  • 使用:pytest --cov=src --cov-report=html tests

pytest-mock

替换系统的某个部分以隔离要测试的代码

  • 安装:pip install pytest-mock

  • 使用

    def test_mock(mocker):
        mocker.patch("os.path.exists", return_value=True)
        assert os.path.exists("/tmp")
    

pytest-xdist

通常,测试都是依次执行的,因为有些资源一次只能被一个测试用例访问。如果你的测试不需要访问共享资源,可以通过并行运行来提速

  • 安装:pip install pytest-xdist
  • 使用:pytest -n auto

pytest-benchmark

测试代码的性能

  • 安装:pip install pytest-benchmark

  • 使用

    def test_benchmark(benchmark):
        benchmark.pedantic(lambda: 1 + 1, iterations=100, rounds=100)
    

pytest-repeat

重复运行测试

  • 安装:pip install pytest-repeat
  • 使用:pytest --count=3

pytest-rerunfailures

失败的测试重跑

  • 安装:pip install pytest-rerunfailures
  • 使用:pytest --reruns=3

pytest-timeout

设置测试的超时时间(正常情况下,pytest 里的测试是没有时间限制的)

  • 安装:pip install pytest-timeout
  • 使用:pytest --timeout=10
  • 或者在代码中使用 @pytest.mark.timeout(10) 来设置单个测试的超时时间

pytest-instafail

测试失败时立即输出堆栈回溯信息,而不是等到所有测试都执行完毕

  • 安装:pip install pytest-instafail
  • 使用:pytest --instafail

pytest-sugar

显示彩色和进度条

  • 安装:pip install pytest-sugar
  • 使用:pytest --sugar

pytest-emoji

显示 emoji

  • 安装:pip install pytest-emoji
  • 使用:pytest --emoji

pytest-html

生成 html 报告

  • 安装:pip install pytest-html
  • 使用:pytest --html=report.html

pytest-flake8

检查代码风格

  • 安装:pip install pytest-flake8
  • 使用:pytest --flake8

pytest-selenium

借助浏览器完成自动化测试

  • 安装:pip install pytest-selenium

  • 使用

    def test_selenium(selenium):
        selenium.get("https://www.baidu.com")
        selenium.find_element_by_id("kw").send_keys("pytest")
        selenium.find_element_by_id("su").click()
        assert "pytest" in selenium.title
    

配置

pytest.ini

它是 pytest 的主配置文件,可以在项目根目录下创建该文件,也可以在 ~/.config/pytest.ini 中创建全局配置文件

修改默认命令行选项

[pytest]
addopts = -s --tb=short --strict-markers

注册标记

[pytest]
markers =
    smoke: smoke test
    regression: regression test

忽略那些不需要递归搜索的目录

[pytest]
norecursedirs = .* venv *.egg dist build

指定测试目录

[pytest]
testpaths = tests

更改测试搜索的规则

[pytest]
python_files = test_*.py *_test.py pytest_*.py check_*.py
python_classes = Tests* *Test *Suite
python_functions = test_* check_*

禁用 xpass

那些被标记为 @pytest.mark.xfail 但实际通过的测试用例也被报告为失败

[pytest]
xfail_strict = true

conftest.py

它是本地插件库,其中的 hook 函数和 fixture 将作用于该文件所在的目录以及所有子目录

视频处理

视频处理工作流程

视频与图像的基本概念

视频图像基础

  • 存取一幅图像需要特别注意 Stride 这个参数,它跟分辨率中的 Width 是不一样的。为了快速存取,往往会选择以内存对齐的方式存储一行像素(比如 16 字节)。有的时候即便图像的 Width 是一个规则的值,图像存储在内存中有可能 Stride 和 Width 也是不一样的,尤其是不同的视频解码器内部实现的不同,会导致输出的图像的 Stride 不一样。
  • 我们在电影院看的电影帧率一般是 24fps(帧每秒),监控行业常用 25fps。
  • 我们存储视频的时候需要对图像进行压缩之后再存储。码率是指视频在单位时间内的数据量的大小,单位一般是 Kb/s 或者 Mb/s。
  • 视频压缩之后的清晰度还跟压缩时选用的压缩算法,以及压缩时使用的压缩速度有关。压缩算法越先进,压缩率就会越高,码率自然就会越小。压缩速度越慢,压缩的时候压缩算法就会越精细,最后压缩率也会有提高,相同的清晰度码率也会更小。

Color Range

对于一个 8bit 的 RGB 图像,Full Range 的 R、G、B 取值范围是 0255, 而 Limited Range 的 R、G、B 取值范围是 16235。

颜色空间

RGB

RGB存储格式

  • OpenCV 使用的是 BGR 格式,而不是 RGB。
  • RGB 三个颜色是有相关性的,所以不太方便做图像压缩编码。
  • RGB 颜色空间更适合图像采集和显示。

YUV

YUV 图像将亮度信息 Y 与色彩信息 U、V 分离开来。Y 表示亮度(Luma),是图像的总体轮廓,U、V 表示色度(Chroma),主要描绘图像的色彩等信息。YUV 颜色空间更适合于编码和存储。

根据采样方式的不同,YUV 主要分为 YUV 4:4:4、YUV 4:2:2、YUV 4:2:0 三种。

根据存储方式的不同,YUV 还可以分成三大类:PlanarSemi-PlanarPacked。Planar 格式的 YUV 是先连续存储所有像素点的 Y,然后存储所有像素点的 U(或者 V),之后再存储所有像素点的 V(或者 U)。Semi-planar 格式的 YUV 是先存储完所有像素的 Y,然后 U、V 连续地交错存储。packed 格式的 YUV 是连续交错存储的。

YUV444

YUV444采样

Planar 存储格式: YUV444存储

YUV422

YUV422采样

Planar 存储格式: YUV422P存储

Semi-Planar 存储格式: YUV422SP存储

YUV420 (最常用)

YUV420采样

Planar 存储格式: YUV420P存储

Semi-Planar 存储格式: YUV420SP存储

RGB 与 YUV 转换

RGB 和 YUV 格式转换需要双方确定好转换标准和 Color Range

BT601 标准(标清)

Limited Range:

RGB->YUV 转换公式

$$ \begin{cases} Y &= 0.299 * R + 0.587 * G + 0.114 * B \\ U &= -0.172 * R - 0.339 * G + 0.511 * B + 128 \\ V &= 0.511 * R - 0.428 * G - 0.083 * B + 128 \end{cases} $$

YUV->RGB 转换公式

$$ \begin{cases} R &= Y + 1.371 * (V - 128) \\ G &= Y - 0.336 * (U - 128) - 0.698 * (V - 128) \\ B &= Y + 1.732 * (U - 128) \end{cases} $$

Full Range:

RGB->YUV 转换公式

$$ \begin{cases} Y &= 16 + 0.257 * R + 0.504 * G + 0.098 * B \\ U &= 128 - 0.148 * R - 0.291 * G + 0.439 * B \\ V &= 128 + 0.439 * R - 0.368 * G - 0.071 * B \end{cases} $$

YUV->RGB 转换公式

$$ \begin{cases} R &= 1.164 * (Y - 16) + 1.596 * (V - 128) \\ G &= 1.164 * (Y - 16) - 0.392 * (U - 128) - 0.812 * (V - 128) \\ B &= 1.164 * (Y - 16) + 2.016 * (U - 128) \end{cases} $$

BT709 标准(高清)

Limited Range:

RGB->YUV 转换公式

$$ \begin{cases} Y &= 0.213 * R + 0.715 * G + 0.072 * B \\ U &= -0.117 * R - 0.394 * G + 0.511 * B + 128 \\ V &= 0.511 * R - 0.464 * G - 0.047 * B + 128 \end{cases} $$

YUV->RGB 转换公式

$$ \begin{cases} R &= Y + 1.540 * (V - 128) \\ G &= Y - 0.183 * (U - 128) - 0.459 * (V - 128) \\ B &= Y + 1.816 * (U - 128) \end{cases} $$

Full Range:

RGB->YUV 转换公式

$$ \begin{cases} Y &= 16 + 0.183 * R + 0.614 * G + 0.062 * B \\ U &= 128 - 0.101 * R - 0.339 * G + 0.439 * B \\ V &= 128 + 0.439 * R - 0.339 * G - 0.040 * B \end{cases} $$

YUV->RGB 转换公式

$$ \begin{cases} R &= 1.164 * (Y - 16) + 1.792 * (V - 128) \\ G &= 1.164 * (Y - 16) - 0.213 * (U - 128) - 0.534 * (V - 128) \\ B &= 1.164 * (Y - 16) + 2.114 * (U - 128) \end{cases} $$

使用 ffmpeg 将 png 图片转成 YUV 格式

获取 png 图片

ffmpeg -i hello.png -pix_fmt yuv420p hello-yuv420p.yuv

转换得到的 yuv 图像可以使用 YUView 软件打开(注意,需要自行设置图片的分辨率等参数,否则不能正确显示)。

由于 yuv 图片除了原始的像素数据,没有保存额外的数据,因此转换得到的图像大小为:320*320*3/2 = 153600 字节。

图像的缩放

图像的缩放就是将原图像的已有像素经过加权运算得到目标图像的目标像素。

假设原图像的分辨率是 w0 * h0,我们需要缩放到 w1 * h1。那我们只需要将目标图像中的像素位置(x,y)映射到原图像的(x * w0 / w1,y * h0 / h1),再插值得到这个像素值就可以了,这个插值得到的像素值就是目标图像像素点(x,y)的像素值。注意,(x * w0 / w1,y * h0 / h1)绝大多数时候是小数。

图像缩放的场景

  1. 播放窗口与原始图像分辨率不匹配的时候需要缩放
  2. 在线观看视频时会有多种分辨率可以选择,即需要在一个图像分辨率的基础上缩放出多种不同尺寸的图像出来做编码,并保存多个不同分辨率的视频文件
  3. RTC 场景,有的时候我们需要根据网络状况实时调节视频通话的分辨率

插值算法

使用周围已有的像素值通过一定的加权运算得到“插值像素值”。插值算法主要包括:最近邻插值算法(Nearest)、双线性插值算法(Bilinear)、双三次插值算法(BiCubic)等。

最近邻插值算法

选择待插值像素周围的 4 个像素,并取离待插值像素位置最近的像素点权重为 1,其余 3 个点权重为 0

  1. 将目标图像中的目标像素位置,映射到原图像的映射位置
  2. 找到原图像中映射位置周围的 4 个像素
  3. 取离映射位置最近的像素点的像素值作为目标像素

缺点: 它直接使用离插值位置最近的整数位置的像素作为插值像素,导致相邻两个插值像素有很大的概率是相同的。这样得到的放大图像大概率会出现块状效应,而缩小图像容易出现锯齿。

双线性插值算法

选择待插值像素周围的 4 个像素,并且每个像素以距离作为权重,距离越近权重越大,距离越远权重越小

双线性插值其实就是三次线性插值的过程,我们先通过两次线性插值得到两个中间值,然后再通过对这两个中间值进行一次插值得到最终的结果。

双线性插值

$$ val(m) = \frac{x - x_1}{x_2 - x_1} * val(b) + \frac{x_2 - x}{x_2 - x_1} * val(a) \\ val(n) = \frac{x - x_1}{x_2 - x_1} * val(d) + \frac{x_2 - x}{x_2 - x_1} * val(c) \\ val(p) = \frac{y - y_1}{y_2 - y_1} * val(m) + \frac{y_2 - y}{y_2 - y_1} * val(n) $$

其中的 val() 表示该像素的像素值。

双三次插值算法

  1. 双三次插值选取的是周围的 16 个像素,比前两种插值算法多了 3 倍
  2. 周围像素的权重计算是使用一个特殊的 BiCubic 基函数来计算的

先通过这个 BiCubic 基函数计算得到待插值像素周围 16 个像素的权重,然后将 16 个像素加权平均就可以得到最终的待插值像素了。

BiCubic 函数的计算公式如下:

$$ f(x) = \begin{cases} (a+2)|x|^3 - (a+3)|x|^2 + 1 & 0 \le |x| < 1 \\ a|x|^3 - 5a|x|^2 + 8a|x| - 4a & 1 \le |x| < 2 \\ 0 & 2 \le |x| \end{cases} $$

其中 a 的取值范围是 [-1, 0],一般取 -0.5。

对于周围 16 个点中的每一个点,其坐标值为(x,y),而目标图像中的目标像素在原图像中的映射坐标为 p(u,v)。那么通过上面公式可以求得其水平权重 W(u - x),垂直权重 W(v - y)。将 W(u - x)乘以 W(v - y)得到最终权重值,然后再用最终权重值乘以该点的像素值,并对 16 个点分别做同样的操作并求和,就得到待插值的像素值了。

视频编码

人眼对于亮度信息更加敏感,而对于色度信息稍弱,所以视频编码是将 Y 分量和 UV 分量分开来编码的。对于每一帧图像,又是划分成一个个块来进行编码的,这一个个块在 H264 中叫做宏块。宏块的大小一般是 16x16。

图像的数据冗余

  • 空间冗余:一幅图像中相邻像素的亮度和色度信息是比较接近的,并且亮度和色度信息也是逐渐变化的,不太会出现突变。
  • 时间冗余:相邻两帧图像的变化比较小,相似性很高
  • 视觉冗余:人眼对于图像中高频信息的敏感度要小于低频信息的,去除图像中的一些高频信息对于人眼来说差别不大
  • 信息熵冗余:图像中的一些像素值出现的概率比较大,而另一些像素值出现的概率比较小,因此可以通过合理的编码来压缩数据

编码原理

在每一个宏块中,从左上角开始字形扫描每一个像素值,可以得到一个“像素串”。为了能够在最后熵编码的时候压缩率更高,我们希望送到熵编码(以行程编码为例)的“像素串”,是一串含有很多 0,并且最好连续为 0 的“像素串”。

帧内预测减小图像块的空间冗余

帧内预测就是在当前编码图像内部已经编码完成的块中找到与将要编码的块相邻的块。一般就是即将编码块的左边块、上边块、左上角块和右上角块,通过将这些块与编码块相邻的像素经过多种不同的算法得到多个不同的预测块。然后我们再用编码块减去每一个预测块得到一个个残差块。最后,我们取这些算法得到的残差块中像素的绝对值加起来最小的块为预测块。而得到这个预测块的算法为帧内预测模式。帧内预测是根据块的大小分为不同的预测模式的。在 H264 标准里面,块分为宏块和子块。宏块的大小是 16 x 16(YUV 4:2:0 图像亮度块为 16 x 16,色度块为 8 x 8)。在帧内预测中,亮度宏块可以继续划分成 16 个 4 x 4 的子块。因为图像中有的地方细节很多,我们需要划分成更小的块来做预测会更精细,所以会将宏块再划分成 4 x 4 的子块。帧内预测中亮度块和色度块是分开独立进行预测的,即亮度块参考已编码亮度块的像素,而色度块参考已编码色度块的像素。

帧内预测

4 x 4 亮度块的帧内预测模式
  1. Vertical 模式

    Vertical模式

  2. Horizontal 模式

    Horizontal模式

  3. DC 模式

    DC 模式就是指,当前编码亮度块的每一个像素值,是上边已经编码块的最下面那一行和左边已编码块右边最后一列的所有像素值的平均值。注意,DC 模式预测得到的块中每一个像素值都是一样的。

    DC模式

  4. Diagonal Down-Left 模式

    Diagonal Down-Left模式

  5. Diagonal Down-Right 模式

    Diagonal Down-Right模式

  6. Vertical-Right 模式

    Vertical-Right模式

  7. Horizontal-Down 模式

    Horizontal-Down模式

  8. Vertical-Left 模式

    Vertical-Left模式

  9. Horizontal-Up 模式

    Horizontal-Up模式

对于每一个块或者子块,我们可以得到预测块,再用实际待编码的块减去预测块就可以得到残差块。主要有下面 3 种方案来得到最优预测模式:

第一种方案,先对每一种预测模式的残差块的像素值求绝对值再求和,称之为 cost,然后取其中残差块绝对值之和也就是 cost 最小的预测模式为最优预测模式。

第二种方案,对残差块先进行 Hadamard 变换(在 DCT 变换和量化那节课中会介绍),变换到频域之后再求绝对值求和,同样称为 cost,然后取 cost 最小的预测模式为最优预测模式。

第三种方案,也可以对残差块直接进行 DCT 变换量化熵编码,计算得到失真大小和编码后的码流大小,然后通过率失真优化的方法来选择最优预测模式。

通过上面讲的这些方法我们找到了每一个 4 x 4 块的最优模式之后,将这 16 个 4 x 4 块的 cost 加起来,与 16 x 16 块的最小 cost 对比,选择 cost 最小的块划分方式和预测模式作为帧内预测模式。

在 H264 标准里面,视频的第一帧的第一个块的左和上都是空,没法预测,所以设置成了一个约定值128,方便编码

帧间预测减小图像块的时间冗余

在前面已经编码完成的图像中,循环遍历每一个块,将它作为预测块,用当前的编码块与这个块做差值,得到残差块,取残差块中像素值的绝对值加起来最小的块为预测块,预测块所在的已经编码的图像称为参考帧。预测块在参考帧中的坐标值 (x0, y0) 与编码块在编码帧中的坐标值 (x1, y1) 的差值 (x0 - x1, y0 - y1) 称之为运动矢量。在参考帧中去寻找预测块的过程称之为运动搜索。帧间预测既可以参考前面的图像也可以参考后面的图像。只参考前面图像的帧我们称为前向参考帧,也叫 P 帧;参考后面的图像或者前面后面图像都参考的帧,我们称之为双向参考帧,也叫做 B 帧。B 帧相比 P 帧主要是需要先编码后面的帧,并且 B 帧一个编码块可以有两个预测块,这两个预测块分别由两个参考帧预测得到,最后加权平均得到最终的预测块。

帧间预测的宏块大小 16 x 16,可以划分为 16 x 8,8 x 16, 8 x 8 三种,其中 8 x 8 可以继续划分成 8 x 4,4 x 8 和 4 x 4,这是亮度块的划分。在 YUV 4:2:0 中,色度块宽高大小都是亮度块的一半。

在 H264 标准中,P 帧最多支持从 16 个参考帧中选出一个作为编码块的参考帧,但是同一个帧中的不同块可以选择不同的参考帧,这就是多参考。

运动搜索
  • 钻石搜索

钻石搜索

  • 六边形搜索

六边形搜索

DCT变换和量化减小图像块的视觉冗余

在H264中,如果一个块大小是16x16,一般会划分成16个4x4的块,然后对每个 4x4 的块做 DCT 变换得到相应的 4x4 的变换块。变换块的每一个“像素值”我们称为系数。变换块左上角的系数值就是图像的低频信息,其余的就是图像的高频信息,并且高频信息占大部分。低频信息表示的是一张图的总体样貌。一般低频系数的值也比较大。而高频信息主要表示的是图像中人物或物体的轮廓边缘等变化剧烈的地方。高频系数的数量多,但高频系数的值一般比较小。

让变换块的系数都同时除以一个值,这个值我们称之为量化步长,也就是 QStep(QStep 是编码器内部的概念,用户一般使用量化参数 QP 这个值,QP 和 QStep 一一对应),得到的结果就是量化后的系数。QStep 越大,得到量化后的系数就会越小。同时,相同的 QStep 值,高频系数值相比低频系数值更小,量化后就更容易变成 0。这样一来,我们就可以将大部分高频系数变成 0。

QP 值越大,损失就越大,从而画面的清晰度就会越低。同时,QP 值越大系数被量化成 0 的概率就越大,这样编码之后码流大小就会越小,压缩就会越高。

余弦变换与量化

码流结构 (H264)

帧类型

帧类型预测方式参考帧优缺点
I 帧帧内编码帧只进行帧内预测自身能独立完成编码解码,压缩率小
P 帧前向编码帧可以进行帧内预测和帧间预测前面的 I 帧和 P 帧压缩率比 I 帧高,必须要参考帧才能正确编解码
B 帧双向编码帧可以进行帧内预测和帧间预测前面或者后面的 I 帧和 P 帧压缩率最高,需要缓存帧,延时高,RTC 场景不适合

三种帧的示例图

为了防止某个参考帧出错而导致错误的不断传递,H264 规定了一个特殊的 I 帧叫 IDR帧,也叫立即刷新帧。IDR 帧之后的帧不能再参考 IDR 帧之前的帧

视频图像的序列结构

从一个 IDR 帧开始到下一个 IDR 帧的前一帧为止,这里面包含的 IDR 帧、普通 I 帧、P 帧和 B 帧,我们称为一个 GOP (Group of Pictures) 图像组。

GOP

GOP 越大,编码的 I 帧就越少,相比而言,P帧和B帧的压缩率更高,因此整个视频的编码效率越高。但是 GOP 太大,会导致 IDR 帧距离太大,点播场景时进行视频的 seek 操作不方便。并且,在 RTC 和直播场景中,可能会因为网络原因导致丢包而引起接收端的丢帧,大的 GOP 最终可能导致参考帧丢失而出现解码错误,从而引起长时间花屏和卡顿。

图像内部的层次结构

Slice 其实是为了并行编码设计的。将一帧图像划分成几个 Slice,并且 Slice 之间相互独立、互不依赖、独立编码。并行对多个 Slice 进行编码可以提升速度,但是帧内预测不能跨 Slice 进行,因此编码性能会差一些。一个 Slice 会包含整数个宏块。在做帧内和帧间预测的时候,我们又可以将宏块继续划分成不同大小的子块,用来给复杂区域做精细化编码。

Slice

码流格式

H264 码流有两种格式:一种是 Annexb 格式;一种是 MP4 格式。

annexb

Annexb 格式使用起始码来表示一个编码数据的开始。起始码本身不是图像编码的内容,只是用来分隔用的。

mp4

MP4 格式在图像编码数据的开始使用了 4 个字节作为长度标识,用来表示编码数据的长度。

NALU (网络抽象层单元)

编码数据中除了图像数据,还有一些编码参数数据,为了能够将一些通用的编码参数提取出来,不在图像编码数据中重复,H264 设计了两个重要的参数集:一个是 SPS(序列参数集);一个是 PPS(图像参数集)

SPS 主要包含的是图像的宽、高、YUV 格式和位深等基本信息。

PPS 主要包含熵编码类型、基础 QP 和最大参考帧数量等基本编码信息。

H264 的码流主要是由 SPS、PPS、I Slice、P Slice和B Slice 组成的

H264码流组成

SPS 是一个 NALU、PPS 是一个 NALU、每一个 Slice 也是一个 NALU。每一个 NALU 又都是由一个 1 字节的 NALU Header 和若干字节的 NALU Data 组成的。而对于每一个 Slice NALU,其 NALU Data 又是由 Slice Header 和 Slice Data 组成,并且 Slice Data 又是由一个个 MB Data 组成。

NALU Header

NALU Header

  • F: 禁止位,H264 码流必须为 0
  • NRI: nal_ref_idc,表示当前 NALU 的重要性。参考帧、SPS 和 PPS 对应的 NALU 必须要大于 0
  • Type: nal_unit_type,表示 NALU 的类型

NALU 类型表格:

NALU 类型描述
0未使用
1非 IDR 图像中的 Slice
2片分区 A
3片分区 B
4片分区 C
5IDR 图像中的 Slice
6补充增强信息单元 SEI
7序列参数集 SPS
8图像参数集 PPS
9分解符
10序列结束
11码流结束
12填充
13...23保留
24...31未使用

NALU 类型只区分了 IDR Slice 和非 IDR Slice,至于非 IDR Slice 是普通 I Slice、P Slice 还是 B Slice,则需要继续解析 Slice Header 中的 Slice Type 字段得到。

如何从码流中判断哪几个 Slice 是同一帧的

H264 码流中没有字段表示一帧包含几个 Slice,但是 Slice Header 中有一个 first_mb_in_slice 的字段,表示当前 Slice 的第一个宏块在当前编码图像中的序号。如果 first_mb_in_slice 的值等于 0,就代表了当前 Slice 的第一个宏块是一帧的第一个宏块,也就是说当前 Slice 就是一帧的第一个 Slice。first_mb_in_slice 是以无符号指数哥伦布编码的,需要使用对应的解码方式才能解码出来。但是有一个小技巧,如果 slice_header[0] & 0x80 == 1, 则first_mb_in_slice 等于 0。

如何从码流中获取 QP 值

在 PPS 中有一个全局基础 QP,字段是 pic_init_qp_minus26。当前序列中所有依赖该 PPS 的 Slice 共用这个基础 QP,且每一个 Slice 在这个基础 QP 的基础上做调整。在 Slice Header 中有一个 slice_qp_delta 字段来描述这个调整偏移值。更进一步,H264 允许在宏块级别对 QP 做更进一步的精细化调节。这个字段在宏块数据里面,叫做 mb_qp_delta。

使用 ffmpeg 从视频文件中提取 H264 码流

ffmpeg -i input.mp4 -c:v copy -bsf:v h264_mp4toannexb -an output.h264

不同的编码器标准比较

对比不同的编码器标准

USB 基础

USB 基本概念

USB 协议标准

USB 协议标准主要特点速度等级
USB 2.0 Full Speed
(旧称 USB 1.1)
规范了 USB 低全速传输1.5 Mbps~12 Mbps
USB 2.0 High Speed
(旧称 USB 2.0)
规范了 USB 高速传输480 Mbps
USB 3.2 gen1
(旧称 USB 3.0)
采用 8b/10b 编码,增加一对超高速差分线,供电 5V/0.9A5 Gbps
USB 3.2 gen2
(旧称 USB 3.1)
采用 128b/132b 编码,速度提高 1 倍,供电 20V/5A,同时增加了 A/V 影音传输标准10 Gbps
USB 3.2 gen2*2
(旧称 USB 3.2)
增加一对超高速传输通道,速度再次翻倍,只能在 C 型接口上运行20 Gbps

通讯接口

USB 通讯接口

编码方式

USB 编码方式

这种编码方式也称为反向不归零编码(NRZI)

位填充:在数据进行 NRZI 编码前,每 6 个连续的 1 信号之后都会插入 1 个 0 信号,以避免长时间电平保持不变带来的同步漂移。

信号传输状态

USB 信号传输状态

帧是一个时间单位,固定为1ms(低/全速),高速-微帧为 125us

通讯过程划分

USB 通讯过程划分

事务是最基本的传输单位。

四种传输

::: tip 控制传输 主机获取设备信息、状态,选择设备配置等一系列命令式工作。 :::

::: tip 中断传输

收发数据量少、周期性传输。

:::

::: tip 批量传输

利用任何可获得的总线带宽进行数据传输。

:::

::: tip 等时传输

恒定速率、没有差错控制的传输。

:::

其他术语

上传/下传

USB 主机接收 USB 设备的数据称为上传,USB 主机发送数据给 USB 设备称为下传。

地址

主机管理设备,而为每一个连接的设备分配一个地址,主机最多可以分配 127 个地址。

端点

USB 设备中实际的物理单元,端点和地址决定了主机和设备之间通讯的物理通道。

USB 传输特点

物理传输双方角色一定是主机和设备,一问一答传输方式,永远是主机先发起包请求。

主设备和从设备

主设备

  • 检测 USB 设备的插拔动作
  • 管理主从通讯之间的控制流
  • 管理主从通讯之间的数据流
  • 记录主机状态和设备动作信息
  • 控制主控制器和 USB 设备间的电气接口

集线器

  • 扩展 USB 主机和 USB 端口
  • 结构上有一个上行端口,多个下行端口
  • 支持级联,系统中最多 5 个集线器(不包括主机的根集线器)
  • 支持速度切换

功能设备

  • 一个独立的外围设备,可以是单一功能,也可以是多功能的合成设备
  • 内部包含有描述自身功能和资源需求的配置信息

USB 设备结构图

USB 系统分层

USB 系统分层

连接与检测

USB 连接检测

USB 全速设备连接

USB 高速设备连接

总线的状态

常见的总线状态描述
正常工作总线上存在周期性 SOF 包
总线复位总线维持 SE0 状态 > 10ms
总线挂起总线无活动 > 3ms
常见的几种变化触发点
无连接 -> 连接D+/D- 上出现高电平 > 2ms
正常 -> 挂起J 状态保持 > 3ms
挂起 -> 正常(唤醒)出现 K 状态信号并持续一段时间

枚举

:::tip 枚举的定义

USB 主设备向 USB 从设备通过获取各种描述符,从而了解设备属性,知道是什么样的设备,并加载对应的 USB 类、功能驱动程序,然后进行后续一系列的数据通信。

:::

  • 主设备连接识别从设备必须的过程
  • 由多个控制传输构成
  • 经过地址0 (缺省地址)到其他地址(主设备分配地址)的通讯
  • 对于挂载多个 USB 从设备的系统,主设备是逐一进行枚举操作

USB 枚举

设备描述符

第一个需要获取的描述符,长度固定 18 字节。

配置描述符

描述了设备特定的配置,提供了当前配置下设备的功能接口,供电方式,耗电等信息。是一个配置的集合,集合长度不固定,包含了配置描述符、接口描述符、类定义描述符、端点描述符。

控制传输

USB 控制传输

USB 控制传输涉及到的事务

建立阶段

USB 控制传输建立阶段

USB 键盘

USB 键盘设计思路

USB 软件设计

参考文献

CAN 基础

拓扑结构

CAN 拓扑结构

CAN总线有两个 ISO 国际标准:ISO11898 和 ISO11519。

  • ISO11898 定义了通信速率为 125 Kbps~1 Mbps 的高速 CAN 通信标准,属于闭环总线,总线长度 ≤ 40 米。
  • ISO11519 定义了通信速率为 10~125 Kbps 的低速 CAN 通信标准,属于开环总线,总线长度可达 1000 米。
  • ISO16845 定义了认证需要的测试用例
  • 在同一条总线上,所有节点的通信速度必须相同;如果两条不同通信速度的总线上的节点想要实现信息交互,必须通过网关或者中继器转发信息。

信号表示

CAN 信号表示

通信特点

多主多从结构

  • CAN 总线上的所有节点没有主从之分,在总线空闲状态,任意节点都可以向总线上发送消息
  • 当总线上出现连续的 11 位隐形电平,那么总线就处于空闲状态
  • 最先向总线发送消息的节点获得总线的发送权,当多个节点同时向总线发送消息时,所发送消息的优先级高的那个节点获得总线的发送权
  • 依赖于硬件的验收滤波技术,CAN 总线可以实现一对一,一对多以及广播的数据传输方式。

非破坏性位仲裁机制

当多个节点同时向总线发送消息时,对各个消息的标识符(即ID号)进行逐位仲裁,如果某个节点发送的消息仲裁获胜,那么这个节点将获取总线的发送权,仲裁失败的节点则立即停止发送并转变为监听(接收)状态。

这种仲裁机制既不会造成已发送数据的延迟,也不会破坏已经发送的数据。

报文过滤

CAN 总线中没有地址的概念,CAN 总线是通过报文 ID 来实现收发数据的。每个节点上都会有一个验收滤波 ID 表,其位于 CAN 节点的验收滤波器中,如果总线上的报文的 ID 号在某个节点的验收滤波 ID 表中,那么这一帧报文就能通过该节点验收滤波器的验收,该节点就会接收这一帧报文。

远程数据请求

某个节点 Node_A 可以通过发送遥控帧到总线上的方式,请求某个节点 Node_B 来发送由该遥控帧所指定的报文。

出错处理

  • 所有的节点都可以检测出错误
  • 检测出错误的节点会立即通知总线上其它所有的节点
  • 正在发送消息的节点,如果检测到错误,会立即停止当前的发送,同时不断地重复发送此消息,直到该消息发送成功为止

故障封闭

节点能够判断错误的类型,判断是暂时性的数据错误(如噪声干扰)还是持续性的数据错误(如节点内部故障),如果判断是严重的持续性错误,那么节点就会切断自己与总线的联系,从而避免影响总线上其他节点的正常工作。

位填充

CAN 位填充原则

CAN 协议中规定,当相同极性的电平持续五位时,则添加一个极性相反的位。

网络分层架构

CAN 网络层次结构

帧结构

数据帧和遥控帧

CAN 数据帧

CAN 遥控帧

  • RTR(Remote Transmission Request) 位保证了数据帧的优先级高于遥控帧
  • SRR(Substitutes Remote Requests) 位保证了标准数据帧的优先级高于扩展数据帧
  • IDE(Identifier Extension) 位保证了标准遥控帧的优先级高于扩展遥控帧
  • DLC(Data Length Code) 位指示了数据段中的字节数,对于遥控帧而言,DLC 表示该遥控帧对应的数据帧的数据段的字节数
  • 数据段从 MSB 开始输出
  • CRC 校验序列(15bit)的计算范围包括:SOF,仲裁段,控制段和数据段
  • ACK 包括 ACK 槽和 ACK 分界符:
    • 发送节点发出的报文中 ACK 槽为隐性1
    • 接收节点在接收到正确的报文之后会在 ACK 槽发送显性0,通知发送节点正常接收结束
  • EOF(End Of Frame) 表示该帧报文的结束,由7个隐性位构成

错误帧

在 CAN 总线通信中,一共有五种错误,分别是:位错误、ACK错误、填充错误、CRC错误、格式错误。

CAN 错误帧

  • 主动错误标志:6个连续的显性位0
  • 被动错误标志:6个连续的隐性位1
  • 错误分界符:8个连续的隐性位1

过载帧

CAN 过载帧

  • 接受单元会发从此帧来通知总线自己还没有做好接收准备

帧间隔

CAN 帧间隔

  • 数据帧和遥控帧可通过插入帧间隔将本帧与前面的任何帧(数据帧、遥控帧、错误帧、过载帧)分开,过载帧和错误帧前不能插入帧间隔

错误通知

节点错误状态

按照 CAN 协议的规定,CAN 总线上的节点始终处于以下三种状态之一:

  • 主动错误状态

    • 可以正常通信
    • 在检测出错误时,发出主动错误标志
  • 被动错误状态

    • 可以正常通信
    • 在检测出错误时,发出被动错误标志
  • 总线关闭状态

    • 节点不能收发报文
    • 在满足一定条件的时候,再次进入到主动错误状态

错误状态的转换

在 CAN 节点内,有两个计数器:发送错误计数器(TEC)和接收错误计数器(REC)。TEC 和 REC 计数值的变化,是根据下表的规定来进行的

TEC/REC

CAN节点错误状态的转换,就是基于这两个计数器来进行的

错误状态转换

错误帧的发送

错误帧的发送

  1. 发送节点 Node_A 发送一个显性位,但是却从总线上听到一个隐形位,于是 Node_A 节点就会检测到一个位错误
  2. Node_A 检测到位错误之后,立即在下一位开始发送主动错误帧:6个连续显性位的主动错误标志+8个连续隐性位的错误界定符
  3. 对应 Node_A 发出的主动错误标志,总线上电平为6个连续显性位
  4. 接收节点 Node_B 和 Node_C 从总线上听到连续6个显性位,那么就会检测到一个填充错误,于是这两个节点都会发送主动错误帧
  5. 对应 Node_B 和 Node_C 发出的主动错误标志,总线电平又有6个连续显性电平,对应 Node_B 和 Node_C 发出的错误界定符,总线电平有8个连续的隐性电平
  6. 在间歇域之后,Node_A 节点重新发送刚刚出错的报文

Socket CAN

命令行工具

ip 命令

ip link set can0 type can help
设置 CAN 设备的波特率
ip link set can0 type can bitrate 500000
ip link set can0 type can bitrate 500000 dbitrate 2000000 fd on
ip link set can0 type can bitrate 500000 sample-point 0.875
启动/关闭 CAN 设备
ip link set can0 up
ip link set can0 down
设置 CAN 设备的模式
ip link set can0 type can loopback on
ip link set can0 type can listen-only on
查看详细的配置信息
ip -details link show can0

can-utils 程序

candump
candump can0,0x123:0x7FF # 仅显示can0上收到的ID为0x123的消息
cansend
cansend can0 123#1122334455667788 # 发送一个ID为0x123的报文
cangen
cangen can0 -g 0x123 -I 1000 -L 8 -D 0x1122334455667788 # 每1000ms发送一个ID为0x123的报文
cansniffer
cansniffer can0 # 抓取can0上的所有报文,可以过滤掉数据不变的帧

python-can

安装

pip install python-can

使用

# import the library
import can

# create a bus instance
# many other interfaces are supported as well (see documentation)
bus = can.Bus(interface='socketcan',
              channel='vcan0',
              receive_own_messages=True)

# send a message
message = can.Message(arbitration_id=123, is_extended_id=True,
                      data=[0x11, 0x22, 0x33])
bus.send(message, timeout=0.2)

# iterate over received messages
for msg in bus:
    print(f"{msg.arbitration_id:X}: {msg.data}")

# or use an asynchronous notifier
notifier = can.Notifier(bus, [can.Logger("recorded.log"), can.Printer()])

MIPI DSI 基础

MIPI 层次结构

MIPI 应用的层级结构

DSI

MIPI DSI 是基于字节的协议,应用层完成各种操作下相关命令的选择、各像素点图像数据的字节映射处理。MIPI 定义了命令模式视频模式两种传输模式。视频模式以实时像素数据流的方式从处理器向外设传输数据,而命令模式可以做到按需传输,只需要在图像内容发生变化时再进行新图像数据的传输。命令模式需要显示模组中的帧缓存存储器的支持。这两种传输模式的支持,由模组硬件结构决定

MIPI DSI 的高速数据传输采用差分信号来传输,在时钟通道和各个数据通道上都会有高速数据传输。

MIPI DSI 的低功耗数据传输并不需要时钟传输,这时 D-PHY 采用归零码的方式来表示逻辑数据“0” “1”,并且低功耗数据只会在数据通道0上进行传输,对通道0的P端和N端进行异或操作就可以恢复出数据比特流的“同步时钟”。

DSI 系统架构

MIPI DSI 系统架构

时钟最小频率最大频率
rxclkesc由 PHY 限制,通常不超过 20MHz
txclkesc由 PHY 限制,通常不超过 20MHz
lanebyteclk3 * rxclkesc1/8 of the DPHY maximum speed
pclk2MHz220MHz
dpiclk250MHz
dbiclk41MHz

DSI 层级结构

DSI 层级结构

视频模式

视频模式下,图像数据的传输是用低阶协议层包结构中的数据类型标识符(DT,Data Type)字段区分,用不同的 DT 来构造视频模式传输所需要的各种数据包。视频模式下,应用层只提供图像数据的净荷,利用低阶协议层进行组包处理。在 MIPI DSI 中,同步信号分为以下几个命令:

同步信号命令功能
HSSHSA开始命令
HSEHSA结束命令
VSSVSA开始命令
VSEVSA结束命令
非突发同步脉冲模式

非突发同步脉冲模式

非突发同步事件模式

非突发同步事件模式

在这个模式下,不再向显示模组传输 HSE、VSE 命令,而仅仅传输 HSS、VSS 命令。

突发模式

突发模式

突发模式是指传输 RGB 图像数据时,充分利用 MIPI 提供的带宽,采用突发方式先把图像数据传输给显示模组进行缓存,然后切换到低功耗模式。这种模式需要显示模组有行缓存区或类似的存储区域

常用的数据命令传输方式:

传输方式

在有效显示行、行同步信号和有效数据之间,需要使用消隐包(blanking packet) 来填充相应的时间,在消隐行期间,没有图像数据传输,也用消隐包来替代。采用这种结构,一帧中的每一行,处于发送的时间都是相同的。

图像数据包格式(DT=0x3E)

视频模式下,在应用层,只提供实时图像数据流,LLP层提供一定开销,通过所组数据包中的DI字段,形成VSS、HSS等时序命令,向从设备传递时序信息。RGB图像数据也通过 DI 字段的不同命令来传输。

图像数据包格式

低阶协议层 (LLP)

单次传输

基于 MIPI DSI 物理层 D-PHY 的特殊结构,每个包在开始传输前,一定处于一个被称为低功耗模式的状态(LPS)。开始数据传输时,最先传输的是一种特殊的包结构——包开始指示(SoT, Start of Transmission)。SOT之后才传输 MIPI DSI 数据包。为了增加接口处理带宽,MIPI DSI 允许一次数据传输支持多个数据包,且长包和短包可以任意顺序出现。在同一次传输中,这些数据包要么都用高速传输模式,要么都用低功耗传输模式。

DSI 包结构

  • 长包结构(最大65535字节的payload) DSI 长包结构

  • 短包结构(固定传输两个字节) DSI 短包结构

DSI 数据传输字节序

DSI 数据传输字节序

一个数据包内的各个字节按照低字节,低比特位先传。

数据包类型标识符(DT)

  • 正向数据传输的 DT
DT 编码值DT 含义说明长短包类型
01hVSS ( V Sync Start )帧同步开始数据包短包
11hVSE ( V Sync End )帧同步结束数据包短包
21hHSS (H Sync Start )行同步开始数据包短包
31hHSE (H Sync End )行同步结束数据包短包
08hEoTp ( EoT Packet )传输结束指示数据包短包
02hColor Mode Off Command显示模组进入浅色显示模式短包
12hColor Mode On Command显示模组恢复正常显示模式短包
22hShutdown Peripheral Command关闭显示模组命令短包
32hTurn On Peripheral Command激活显示模组命令短包
03hGeneric Short Write通用短包写命令,不带参数短包
13hGeneric Short Write通用短包写命令,带1字节参数短包
23hGeneric Short Write通用短包写命令,带2字节参数短包
04hGeneric Read通用读命令,不带参数短包
14hGeneric Read通用读命令,带1字节参数短包
24hGeneric Read通用读命令,带2字节参数短包
05hDCS Short WriteDCS短包写命令,不带参数短包
15hDCS Short WriteDCS短包写命令,带1字节参数短包
06hDCS ReadDCS读命令,不带参数短包
37hSet Maximum Return Packet设置最大返回包长度短包
09hNull Packet空包,不带数据短包
19hBlanking Packet消隐包,不带数据长包
29hGeneric Long Write通用长包写命令长包
39hDCS Long WriteDCS长包写命令长包
0ChLoosely Packet Pixel Stream20比特4:2:2格式YUV像素流长包
1ChPacked Pixel Stream24比特4:2:2格式YUV像素流长包
2ChPacked Pixel Stream16比特4:2:2格式YUV像素流长包
0DhPacked Pixel Stream30比特10:10:10格式RGB像素流长包
1DhPacked Pixel Stream36比特12:12:12格式RGB像素流长包
3DhPacked Pixel Stream12比特4:2:0格式YUV像素流长包
0EhPacked Pixel Stream16比特5:6:5格式RGB像素流长包
1EhPacked Pixel Stream18比特6:6:6格式RGB像素流长包
2EhLoosely Packet Pixel Stream18比特6:6:6格式RGB像素流长包
3EhPacked Pixel Stream24比特8:8:8格式RGB像素流长包
  • 反向数据传输的 DT
DT 编码值DT 含义说明长短包类型
02hAcknowledge Error Report错误应答报告短包
08hEoTp ( EoT Packet )传输结束指示数据包短包
11hGeneric Short Read Response通用短包读响应,返回1字节数据短包
12hGeneric Short Read Response通用短包读响应,返回2字节数据短包
21hDCS Short Read ResponseDCS短包读响应,返回1字节数据短包
22hDCS Short Read ResponseDCS短包读响应,返回2字节数据短包
1AhGeneric Long Read Response通用长包读响应长包
1ChDCS Long Read ResponseDCS长包读响应长包

其中的错误应答报告数据包的各个比特的含义如下:

比特位含义
0传输开始错误
1传输开始同步错误
2传输结束同步错误
3逃逸模式进入命令错误
4低功耗传输同步错误
5外设超时错误
6错误控制错误
7冲突检测错误
8单比特ECC错误 (检测到,且已纠正)
9多比特ECC错误 (检测到,但未纠正)
10CRC错误
11无法识别的DSI数据类型
12无效的虚拟通道号
13无效的数据长度
14保留位
15DSI协议违例

不管是正向数据传输还是反向数据传输,都有 EoTp 的数据类型,DT 值为08h,虚拟通道号固定为0

当从设备同时有其他数据包需要传输回主处理器时,应答和错误报告数据包必须是最后一个传输的数据包。如果没有检测到任何错误,应该和错误数据包也可以不传输。

链路管理层

MIPI DSI 是一个数据通道灵活可变的数据传输系统,对于一个指定的系统,一旦上电后,支持的通道数量也就固定下来了,不能在工作过程中进行修改。MIPI DSI 数据包传输使用多个数据通道时,每个通道发起各自的 SoT,所以每个通道的第一个字节发送时间是相同的,但是EoT却不尽然。接收端必须等全部通道的数据收集完毕后才能把数据送给低阶协议层。

MIPI D-PHY

信号电平

一个通道两根信号的电平值确定了 D-PHY 的工作状态。分别把两个信号线用 Dp、Dn来表示,MIPI 通道的电平状态有以下几种:

  • HS-0: 高速模式下的逻辑0,Dp=差分低电平(100mV),Dn=差分高电平(300mV)
  • HS-1: 高速模式下的逻辑1,Dp=差分高电平(300mV),Dn=差分低电平(100mV)
  • LP-00: Dp 和 Dn 都是低功耗信令低电平
  • LP-11: Dp 和 Dn 都是低功耗信令高电平。也称为停止状态,MIPI 主机和设备之间无法进行任何数据通信。
  • LP-01: Dp 是低功耗信令低电平,Dn 是低功耗信令高电平
  • LP-10: Dp 是低功耗信令高电平,Dn 是低功耗信令低电平

处于停止状态时,需要Dp、Dn上的特定电平序列才能进入允许数据/命令传输的状态,这些特定的电平序列被称为传输请求

Dp Dn 电平序列传输请求类型
LP11 -> LP01 -> LP00高速数据传输请求
LP11 -> LP10 -> LP00 -> LP01 -> LP00逃逸模式请求
LP11 -> LP10 -> LP00 -> LP10 -> LP00(总线方向)反转请求
总线方向反转(Bus Turn Around)

总线反转步骤

图中1号区域代表低功耗请求,3号区域代表反转请求,6号区域代表反转应答

逃逸模式

逃逸模式是低功耗模式下的一种特殊场景,用来实现一些特定功能。进入逃逸模式后传输的第一个字节被称为Entry Command逃逸命令,它确定了进入逃逸模式后实现什么样的功能。

字节值命令名称含义
87h低功耗数据传输命令模式命令,表示后面需要进行低功耗数据传输
78h超低功耗状态命令模式命令,表示后面需要进行超低功耗状态
46h复位触发(远端应用)命令触发类型命令

低功耗数据传输示例: 低功耗数据传输

低功耗模式下的传输速率上限是10Mbps,且只能在数据通道0上进行数据传输。

高速模式

高速模式传输数据时,时钟通道必须要进入高速模式,反之,退出高速模式时,时钟通道要在数据通道退出高速模式之后一定时间才能退出高速模式。

  • 时钟通道进入高速模式: 时钟通道进入高速模式

    • 图中的1,2,3部分是高速模式请求序列
    • 第4段表示,时钟进入高速模式时,先驱动一定时间的信号0
    • 第5段表示最后输出的高速差分时钟信号
  • 数据通道进入高速模式: 数据通道进入高速模式

    • 与时钟通道进入高速模式不同,数据通道需要额外的高速同步序列,图中的第2段,该同步序列为一个字节:0xB8

Yosys 基础

建议使用 pip 安装 WebAssembly 打包后的 Yosys 开源工具链: https://yowasp.org/

测试用例代码

module alu(a, b, cin, sel, y);
  input [7:0] a, b;
  input cin;
  input [3:0] sel;
  output [7:0] y;

  reg [7:0] y;
  reg [7:0] arithval;
  reg [7:0] logicval;

  // 算术执行单元
  always @(a or b or cin or sel) begin
    case (sel[2:0])
      3'b000  : arithval = a;
      3'b001  : arithval = a + 1;
      3'b010  : arithval = a - 1;
      3'b011  : arithval = b;
      3'b100  : arithval = b + 1;
      3'b101  : arithval = b - 1;
      3'b110  : arithval = a + b;
      default : arithval = a + b + cin;
    endcase
  end

  // 逻辑处理单元
  always @(a or b or sel) begin
    case (sel[2:0])
      3'b000  : logicval =  ~a;
      3'b001  : logicval =  ~b;
      3'b010  : logicval = a & b;
      3'b011  : logicval = a | b;
      3'b100  : logicval =  ~((a & b));
      3'b101  : logicval =  ~((a | b));
      3'b110  : logicval = a ^ b;
      default : logicval =  ~(a ^ b);
    endcase
  end

  // 输出选择单元
  always @(arithval or logicval or sel) begin
    case (sel[3])
      1'b0    : y = arithval;
      default : y = logicval;
    endcase
  end

endmodule

使用 Yosys 进行综合

  1. 启动 Yosys:yosys
  2. 读取 Verilog 文件: read_verilog alu.v
  3. 检查模块例化结构: hierarchy -check
  4. 逻辑综合于优化:proc; opt; opt; fsm; memory; opt
  5. 生成网表文件:write_verilog alu_synth.v
  6. 输出综合后的逻辑图:show -format dot -prefix ./alu

alu

Tauri 基础

创建项目脚手架

使用 create-tauri-app

pnpm create tauri-app

添加 tailwindcss

pnpm add -D tailwindcss postcss autoprefixer svelte-preprocess
pnpx tailwindcss init tailwind.config.cjs -p

Git 基础

演示文稿

Git 工作流介绍

视频演示

Git 工作流介绍

.gitconfig 文件模板

[user]
    name = suda-morris
    email = 362953310@qq.com
[branch]
    autosetuprebase = always

[core]
    editor = vim
    quotepath = false
    autocrlf = false
    pager = less -+$LESS -FRX

[color]
    status = auto
    branch = auto
    diff = auto
    ui = true
    pager = true

[color "branch"]
    current = green reverse
    local = white
    remote = red

[color "diff"]
    meta = yellow bold
    frag = magenta bold
    old = red bold
    new = green bold

[color "status"]
    added = yellow
    changed = red
    untracked = cyan

[diff]
    tool = git_diff_wrapper

[difftool "git_diff_wrapper"]
    cmd = vimdiff -n   +2  $LOCAL $REMOTE

[pager]
    diff =

[difftool]
    prompt = no

[alias]
    glf  = log -n 10 --name-only --format=\"%Cgreen%h %Cred[%ci] %Creset<%an> %Creset %Cgreen%s %Creset \"
    gl  = log -n 30 --date-order --format=\"%Cgreen%h %Cred[%ci] %Creset <%an>%C(yellow)%d%Creset %Creset %Cgreen%s %Creset \"
    gll  = log -n 30  --format=\"%Cgreen%H %Cred[%ci] %Creset<%an> %Creset %Cgreen%s %Creset \"
    gl3 = log -n 20  --format=\"%Cgreen%h %Cred[%ci] %Creset<%an> %Creset %Cgreen%s %Creset \" --graph
    gl2 = log --format=\"%Cgreen%h %Cred[%ci] %Creset<%an> %Creset %Cgreen%s %Creset \"
    glc = log --format=\"%Cgreen%h %Cred[%cd] %Creset<committer:%cn> : %Cred[%ad] %Creset<author:%cn> %Creset \"
    glc2 = log --format=\"%Cgreen%h %Cred[%ci] %Creset<committer:%cn> : %Cred[%ai] %Creset<author:%cn> %Creset \"
    glc3 = log --format=\"%Cgreen%h %Cred[%ci] %Creset<committer:%cn> : %Cred[%ai] %Creset<author:%cn> %n %Cgreen%s %Creset \"
    glt = log --format=\"%Cgreen%h :: %ad :: %aD :: %ar :: %at :: %ai %Creset \"
    glw = log  -n 20  --format=\"%Cgreen%h %Cred[%ci] %Creset<%an> %Creset %n%Cgreen%s%Creset%n%b  \"
    gldetail = log --format=\"%h `[%cd] `<committer:%cn> `[%ad] `<author:%an> ` %s \"
    hist = log --pretty=format:\"%C(yellow)%h %C(red)%d %C(reset)%s %C(green)[%an] %C(blue)%ad\" --topo-order --graph
    latest = for-each-ref --sort=-committerdate --format=\"%(committername)@%(refname:short) [%(committerdate:short)] %(contents)\"
    st = status -uno -s
    st2 = status -s
    co = checkout
    bl = blame --date=short
    ci = commit
    dt = difftool
    dif = diff --word-diff
    di = diff --no-ext-diff

[log]
    date = short

[commit]
    template = /home/morris/.gitmessage

.gitmessage 文件模板

vfs/fatfs: fix stat call failing when called for mount point

FATFS does not support f_stat call for drive root. When handling stat
for drive root, don't call f_stat and just return struct st with S_IFDIR
flag set.

Closes https://github.com/espressif/esp-idf/issues/xxx

终端演示工具

Slides

安装与使用

# 安装 slides 软件
yay -S slides
# 使用 slides 软件
slides path/to/markdown/file.md

模板

---
theme: https://github.com/maaslalani/slides/raw/main/styles/theme.json
---

# Welcome to Slides

A terminal based presentation tool

---

# Code blocks

Slides allows you to execute code blocks directly inside your slides!

Just press `ctrl+e` and the result of the code block will be displayed as virtual
text in your slides.

---

### Bash

```bash
ls
```

---

### Go

```go
package main

import "fmt"

func main() {
  fmt.Println("Hello, world!")
}
```

---

### Javascript

```javascript
console.log("Hello, world!")
```

---

### Python

```python
print("Hello, world!")
```

---

# h1
## h2
### h3
#### h4
##### h5
###### h6

---

# Markdown components

You can use everything in markdown!

* Like bulleted list
* You know the deal

1. Numbered lists too

* [x] ToDo

---

# Tables

| Tables | Too    |
| ------ | ------ |
| Even   | Tables |

---

# Graphs

```
digraph {
    rankdir = LR;
    a -> b;
    b -> c;
}
```
```
┌───┐     ┌───┐     ┌───┐
│ a │ ──▶ │ b │ ──▶ │ c │
└───┘     └───┘     └───┘
```
---

~~~uname -a
This will be replaced by command `uname -a`
~~~

---

SEE YOU!

[Blog](https://suda-mottis.github.io/blog)

![ME](/home/morris/blog/docs/.vuepress/public/author.jpg)

mdp

安装与使用

# 安装 mdp 软件
sudo pacman -S mdp
# 使用 mdp 软件
mdp path/to/markdown/file.md

模板

%title: Markdown Presentation 演示文稿 🥳
%author: suda-morris
%date: 2019-11-11

-> # 一级标题,居中 <-

-> ## 二级标题,居中 <-

-> 文本内容,居中 <-

_基本控制:_

下一页      *Enter*, *Space*, *Page Down*, *j*, *l*,
            *Down Arrow*, *Right Arrow*

前一页      *Backspace*, *Page Up*, *h*, *k*,
            *Up Arrow*, *Left Arrow*

退出        *q*
重载        *r*
第 N 页     *1..9*
首页        *Home*, *g*
末页        *End*, *G*

-------------------------------------------------


-> # 代码展示 <-

行内代码 `main()`

代码块

​```
int main(int argc, char *argv[]) {
    printf("%s\n", "Hello world!");
}
​```

-------------------------------------------------

-> # 引用 <-

> 引用
>> 嵌套引用 1
>>> 嵌套引用 2

-------------------------------------------------

-> # 下划线与高亮 <-

_仅下划线_ *高亮* _*下划线且高亮*_

-------------------------------------------------

-> # 列表 <-

list
* major
    - minor
        - *important*
          detail
    - minor

-------------------------------------------------

-> # 逐行显示 <-

Agenda
^
* major
^
    * minor
^
* major
  ^
    * minor
      ^
        * detail

-------------------------------------------------

-> # URL 链接 <-

[blog](https://suda-morris.github.io/blog)

-------------------------------------------------

-> # UTF-8 特殊字符 <-

ae = ä, oe = ö, ue = ü, ss = ß
upsilon = Ʊ, phi = ɸ

▛▀▀▀▀▀▀▀▀▀▜
▌rectangle▐
▙▄▄▄▄▄▄▄▄▄▟


-------------------------------------------------

-> # 演示的暂停与恢复 <-

按下 *Ctrl + z* 可以暂停当前演示,回到终端

在终端输入 *fg* 可以恢复之前的演示

-------------------------------------------------

-> # 导出到 PDF 文件 <-

需要安装额外的工具:

\- *markdown* 将 Markdown 文件导出为 HTML
\- *wkhtmltopdf* 将 HTML 转换为 PDF

`$ markdown sample.md | wkhtmltopdf - sample.pdf`

slidev