Jekyll2024-12-25T12:57:00+00:00https://andelf.github.io/feed.xml猫·仁波切会研发的PM才是好OP.猫·仁波切基于 Rust 的 K230 裸机嵌入式编程 - K230 Bare-Metal Embedded Programming Using Rust2024-12-09T08:53:00+00:002024-12-11T05:52:14+00:00https://andelf.github.io/2024/12/09/k230-bare-metal-embedded-programming-using-rust

难度: 中等, 读者应具备嵌入式系统基础知识和 Rust 嵌入式开发基础

本文记录了在 K230 芯片上进行 Rust 裸机开发的过程. 从启动流程的分析, 固件格式的解析, 到编写裸机 Rust 程序, 完善初始化代码, 再到实际的外设控制和功能实现, 和后续开发过程的优化方案, 都进行了探索.

本文相关代码库: k230-bare-metal. 建议参考早期提交记录如 e15968040 配合阅读.

项目背景 (Background)

之前从立创开发板获得了 立创·庐山派K230-CanMV 的测评机会, 另外我自己也有一块 CanMV-K230 开发板.

K230芯片是嘉楠科技推出的 AIoT SoC, 采用异构单元加速计算架构, 集成了 2 个 RISC-V 算核心和 AI 子系统 KPU(Knowledge Process Unit). 按照时间线, 应该是市面上最早一批支持 RV 向量扩展 RVV 1.0 的芯片之一. 主要特点:

  • 双核 RISC-V 处理器
    • Core 0: 64位 RISC-V (RV64GCB), 800MHz
    • Core 1: 64位 RISC-V, 1.6GHz, 支持 RVV 1.0 向量扩展
  • 专用加速单元
    • KPU: AI 推理加速器, 支持 INT8/INT16
    • DPU: 3D 结构光深度计算单元
    • VPU: 视频编解码器, 支持 4K 分辨率
  • 丰富的外设接口
    • 通信接口: UART×5、I2C×5、SPI×3
    • 存储接口: USB 2.0×2、SD/eMMC
    • 其他: GPIO×72、PWM×6、WDT/RTC/Timer

在正常使用情况下, 开发板使用 CanMV 固件, 该固件兼容 OpenMV, 为开发者提供了非常便捷的开发环境. 固件底层实现基于 RT-Thread Smart(RT-Smart), 是支持用户态应用的 RT-Thread 版本, 适用于支持 MMU 的 SoC, 例如 K230. CanMV 实现为 RT-Thread 的一个 APP(MicroPython fork). 另外早期版本的 CanMV 固件使用 Linux + RT-Thread + Micropython. 官方也有纯 Linux 版本固件.

本项目旨在探索:

  1. MPU 与 MCU 在启动方式和使用模式上的区别
  2. 如何使用 Rust 进行 MPU 芯片的裸机开发
  3. K230 的底层启动机制和硬件特性

对于 MPU 及多数 MCU 来说都存在一个片上 Boot ROM 用于启动系统. 通常情况下, Boot ROM 会初始化一些硬件(例如 SPI Flash, TF Card等), 将固件加载到内存 然后加载系统固件的第一行逻辑(例如 U-Boot). 之后用户提供的系统固件会初始化更多硬件及加载真正的操作系统.

所谓的裸机开发, 就是不使用操作系统, 直接在硬件上运行程序, 类似 MCU 直接在系统 Boot ROM 之后运行的方式.

启动代码分析 (Boot Code Analysis)

首先需要阅读官方仓库 CanMV 的代码, 确定是否有不开源的部分. 尤其是核心的 U-Boot 和 RT-Thread/Linux 驱动部分. 对于 U-Boot 还需要确认第一阶段启动代码 SPL(Secondary Program Loader) 是否开源. 因为 SPL 往往用于初始化 DDR 等外设, 以及加载 U-Boot, 很多厂商不开源, 只提供二进制文件.

SPL 字面意思是 第二阶段 启动加载器, Boot ROM 一般被认为是第一阶段加载器

好消息是, 相关的代码都在 CanMV 仓库中, 且开源. 但是代码结构比较复杂, 需要一定时间阅读分析具体的启动流程和逻辑. 然而, 随着 ChatGPT 的出现, 我们可以更快地完成代码分析. 我曾自嘲, ChatGPT 早出现几年的话, 很多工具链都不需要存在.

这里只考虑 TF card 启动的情况, 系统固件在 TF 卡上, 片上 Boot ROM 加载固件到内存. 也就是我们的程序需要做到和 U-Boot 一样的事情, 包括 SPL 的功能.

注: TF 卡、SD 卡、eMMC 在协议层面基本相同, 本文不做严格区分/

上电复位到加载并执行用户固件

首先, Boot ROM 加载固件到内存。这一部分的逻辑是直接固化在芯片内部的 Boot ROM 中, 属于无法控制的部分, 因为 Boot ROM 的代码和逻辑都集成在芯片内部, 无法被用户修改或干预。Boot ROM 内部的实现机制是通过读取 BOOT0 和 BOOT1 两个引脚的状态来判断启动方式。这两个引脚的电平状态决定了芯片在启动时从何种介质加载引导程序。

从芯片手册看, Boot ROM 的内存映射位置位于 0x9120_0000 ~ 0x9121_0000, 使用 SRAM 的前半部分 0x8020_0000 ~ 0x8030_0000. 这些信息可以通过裸机程序读取 sp/ra 等特征确认. 例如 Boot ROM 会将堆栈指针 sp 设置为可用内存的最高地址. Boot ROM 跳转到用户固件往往使用 call 指令, ra 会被设置为当前跳转函数的 pc.

Boot ROM 会按照预先设定的固定格式, 从 TF 卡中加载固件(通常是 U-Boot)到内存中。具体来说, Boot ROM 会访问 TF 卡, 读取固件区域, 将其解码并复制到指定的内存位置 0x8030_0000.

当固件加载完成后, Boot ROM 会将系统的执行权转移到刚刚加载到内存的固件上, 也就是跳转执行 U-Boot。这标志着启动过程从 Boot ROM 阶段进入了固件(U-Boot)阶段。U-Boot 作为一个功能更为强大的引导加载程序, 可以进一步初始化系统硬件、加载操作 RT-Thread 或 Linux Kernel, 以及执行其他用户定义的启动任务。 U-boot 分为两阶段启动, SPL 和 U-Boot, SPL 用于初始化 DDR 等外设, 加载 U-Boot, U-Boot 之后的逻辑(OpenSBI, RT-Thread Smart), 我们此次不考虑. 从固件格式看, 这部分以固件分区的方式存在, 依次由 U-Boot SPL 加载 U-Boot, U-Boot 加载 RT-Thread/Linux Kernel.

K230 配备了两个 CPU, 分别称为 CPU0(小核) 和 CPU1(大核), 两个核心工作在不同频率, 且 CPU1 支持 RVV 1.0 向量扩展, 属于异构多核架构. 在启动过程中, 当芯片的复位信号被解除后, Boot ROM 会在小核上开始执行. 这意味着 CPU0 是第一个被激活的核心, 它负责执行初始的引导程序, 进行系统的基本初始化. 与此同时, 大核的解除复位(de-reset)过程是由小核来控制的。也就是说, 小核完成自身初始化的同时, 还需要发送指令来解除大核的复位状态, 使其从某一位置开始运行. 小核不仅肩负着引导系统启动的重任, 还掌控着大核的启动流程, 为 SoC 整体开始工作奠定了基础.

固件格式

为了让我们的固件被 Boot ROM 识别, 需要特定的固件格式. 各家的 SoC 此类方案不同, 有使用 FAT32 固定文件名的, 有使用特定偏移的固定格式的, 也有使用配置文件的. K230 使用的是固定偏移的固件格式.

K230 的 Boot ROM 会识别 TF 卡的固定偏移位置数据特征, 满足格式的固件会被加载到内存. Boot ROM 已经初始化了 UART0, 会有简单的报错信息, 例如 “boot failed with exit code 19” 表示未找到 TF 卡, “boot failed with exit code 13” 表示固件格式错误等.

经过分析相关编译流程, 得到 K230 的固件格式如下:

00000000  +-------------+-------------+-------------+-------------+
          | ........... | ........... | ........... | ........... |  <- Partition table / any other data
          | ........... | ........... | ........... | ........... |
          +-------------+-------------+-------------+-------------+
00100000  | 4b 32 33 30 | 8c fc 02 00 | 00 00 00 00 | bf 8d 0f 38 |   <- Firmware header: |K230...........8|
          | MAGIC: K230 | Length      | Encryption  | SHA256 hash |   <- Encryption 0: non encryption, 1: SM4, 2: AES+RSA
          +-------------+-------------+-------------+-------------+
00100010  | 03 f3 87 07 | fa 1b d8 1d | 4f a0 cd a0 | 7b 54 35 bd |.  <- SHA256 hash cont.
          +-------------+-------------+-------------+-------------+
00100020  | 35 82 85 89 | 66 4d ac 27 | ca f8 56 49 | 00 00 00 00 |   <- SHA256 hash cont. + Padding
          +-------------+-------------+-------------+-------------+
00100030  | 00 00 00 00 | 00 00 00 00 | 00 00 00 00 | 00 00 00 00 |   <- Padding zeros
          +-------------+-------------+-------------+-------------+
          | ........... | ........... | ........... | ........... |   <- Padding zeros
          +-------------+-------------+-------------+-------------+
00100210  | 00 00 00 00 | 73 25 40 f1 | 2a 82 ae 84 | 93 01 00 00 |   <- Firmware data, length zero position
          | Version     | OpCodes     | Data        | Padding     |   <- Version: 0
          | ........... | ........... | ........... | ........... |   <- Firmware data, raw opcodes
          +-------------+-------------+-------------+-------------+

相关 C 结构定义位于 CanMV src/uboot/uboot/board/kendryte/common/board_common.h.

这里我们简化处理, 不加密固件, 版本号使用 0. 编写一个 Python 脚本完成 TF 卡镜像 .img 固件文件的创建:

#!/usr/bin/env python3
# genimage.py

import hashlib

MAGIC = b"K230"

def sha256(message):
    digest = hashlib.sha256(message).digest()
    return digest


VERSION = b"\x00\x00\x00\x00"

with open("./firmware.bin", "rb") as f:
    data = f.read()

intput_data = VERSION + data

data_len = len(intput_data)
raw_data_len = data_len.to_bytes(4, byteorder="little")

encryption_type = 0
encryption_type = encryption_type.to_bytes(4, byteorder="little")

hash_data = sha256(intput_data)

firmware = MAGIC + raw_data_len + encryption_type + hash_data

firmware += bytes(516 - 32)  # padding
firmware += intput_data

img = bytes(0x100000) + firmware  # image offset 0x100000

# fill 512 boundary, make sure the image size is multiple of 512
if len(img) % 512 != 0:
    img += bytes(512 - len(img) % 512)

with open("./firmware.img", "wb") as f:
    f.write(img)

print("len", len(img))

其中 firmware.bin 通过 objcopy -O binary 生成:

cargo objcopy --release -- -O binary firmware.bin && python3 genimage.py

需要注意磁盘镜像一般都是 512 字节对齐的, 所以需要填充 512 字节对齐. 固件的写入可以借助任何烧录工具, 包括 dd 命令.

开始编写裸机 Rust 程序 (Start Writing Some Bare-Metal Code)

搞定了固件的加载, SoC 的控制流程就可以交给我们的程序了. 这里我们使用 Rust 语言编写裸机程序.

裸机 Rust 嵌入式开发的必备要素大概有:

  • 工具链 target, 使用 rustup 安装: rustup target add riscv64gc-unknown-none-elf
  • 链接脚本 linker script, link.x, 用于定义内存布局(也可以直接定义固件布局)
  • 启动代码, 用于初始化堆栈, 跳转到 Rust 代码, 相当于 C 嵌入式开发中的 start.S

从相关代码阅读得知, TF card 中代码被加载到了 0x80300000 ~ 0x80400000. 为了避免额外的不确定性, 可以直接使用 U-Boot 的 linker script. 确保 Rust 代码中定义的符号被加载即可.

MEMORY { .spl_mem : ORIGIN = 0x80300000, LENGTH = 0x80000 }
MEMORY { .bss_mem : ORIGIN = 0x80380000, LENGTH = 0x20000 }

OUTPUT_ARCH("riscv")

ENTRY(_start)
PROVIDE(__stack_start__ = ORIGIN(.bss_mem) + LENGTH(.bss_mem));

/* 省略具体 section 定义 */

由于缺乏芯片开发的第一手资料, 我们并不知道 Boot ROM 之后初始化的状态具体如何, 这时候只能靠猜测和尝试.

验证裸机执行 - UART

对于裸机编程来说, 需要初始化设备的初始状态, 包括堆栈 sp, 系统执行模式, 中断表, 中断开启等等. 这些工作通常由 start.Scrt0.c 完成. 极小初始化代码往往只需要设置堆栈 sp, 保证函数可以跳转调用执行. 在 sp 非法的情况下, 如果使用堆栈, 例如函数调用, 会导致内存访问或错误指令异常, 即所谓的 “跑飞”.

由于没有 JTAG 调试环境(芯片支持, 我这里没有用 CK-LINK), 如何判断我们的代码是否被执行, 以及代码是否正确执行, 是一个问题. 这里我们可以使用 UART0 输出调试信息. 由于 Boot ROM 已经初始化了 UART0, 我们可以直接使用.

从 U-Boot 源码中的 Device Tree .dtsi 文件中可以得知, K230 大量使用了 DesignWare IP 的外设, 例如 UART0, SPI, I2C 等. 这些外设的具体寄存器手册可以从网上获得. UART 外设兼容 16550, 即我们熟悉的 PC 串口芯片. 串口0 寄存器地址为 0x9140_0000.

可以直接使用 global_asm! 来打印字符来验证固件代码是否被执行. 例如:

#![no_std]
#![no_main]

global_asm!(r#"
.section .text.start
.global _start
     la sp, _stack_start
     call _start_rust
"#);

#[no_mangle]
pub extern "C" fn _start_rust() {
    loop {
        // UART0.THR = 'A'
        core::ptr::write_volatile(0x9140_0000 as *mut u32, 0x41);

        for _ in 0..100000000 {
            unsafe { asm!("nop") }
        }
    }
}

编译烧录如上代码, 不出意外, 你会在串口终端看到一串 A 字符. 这说明我们的代码被成功执行了.

访问外设寄存器 - PAC

Rust 嵌入式访问外设寄存器往往通过 PAC(peripheral access crate) 的方式, 例如 stm32xxxx-pac crate. 但是由于 K230 是一个较新的芯片, 没有相关的 PAC crate. 同时官方也不太可能提供 SVD 文件供参考. 所这里我选择了 chiptool 的方式, 使用 yaml2pac 工具完成 PAC crate 的生成. 手动维护外设寄存器的 YAML 定义. 关于 PAC 的访问, 请参考我 Rust 嵌入式开发中的外设寄存器访问: 从 svd2rust 到 chiptool 和 metapac - 以 hpm-data 为例 一文.

相关 YAML 文件完全可以通过 LLM 协助从 PDF 手册 OCR.

使用 yaml2pac 工具可以方便地形成我们自己的 PAC 库:

yaml2pac -i registers/uart_dw.yaml -o pac/src/uart_dw.rs

然后在 lib.rs 中添加具体的外设地址定义:

#[path = "uart_dw.rs"]
pub mod uart;

pub const UART0: uart::Uart = unsafe { uart::Uart::from_ptr(0x9140_0000 as *mut ()) };
pub const UART1: uart::Uart = unsafe { uart::Uart::from_ptr(0x9140_1000 as *mut ()) };
pub const UART2: uart::Uart = unsafe { uart::Uart::from_ptr(0x9140_2000 as *mut ()) };
pub const UART3: uart::Uart = unsafe { uart::Uart::from_ptr(0x9140_3000 as *mut ()) };

通过简单的封装, 可以方便地访问外设. PAC 的创建和维护在缺乏资料的情况下, 是比较困难的. 但是一旦完成, 可以大大提高开发效率.

方便调试 - println! 宏

有了外设寄存器定义, 此时可以编写完整的 UART HAL Driver, 也可以通过简单寄存器访问, 实现一个 println! 宏.

#[derive(Debug)]
pub struct Console;

impl core::fmt::Write for Console {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        use pac::UART0;

        for c in s.as_bytes() {
            unsafe {
                while !UART0.lsr().read().thre() {
                    asm!("nop");
                }

                UART0.thr().write(|w| w.set_thr(*c));
            }
        }

        Ok(())
    }
}

#[macro_export]
macro_rules! println {
    ($($arg:tt)*) => {
        {
            use core::fmt::Write;
            writeln!(&mut $crate::Console, $($arg)*).unwrap();
        }
    };
    () => {
        {
            use core::fmt::Write;
            writeln!(&mut $crate::Console, "").unwrap();
        }
    };
}

有了 println! 宏, 我们可以方便地输出调试信息了! 大大提高了开发效率.

完善初始化代码 (Complete Initialization Code)

目前为止, 我们只初始化了堆栈, 其他必备要素如系统的中断, 乃至 .bss 段都没有初始化. 这些在一个完整的嵌入式程序中都是必顼的.

和 MCU 编程不同的是, MPU 的代码执行是被 Boot ROM 加载到内存中某一区域的, 所以 MCU start.S 中常见的 .data 区域 copy 是不需要的. 而 .bss 清零则依情况而定, 因为比较简单, 本节略过内存初始化部分.

中断处理函数

对于 RISC-V 来说, 中断处理函数是一个特殊的函数. Rust 提供了 riscv-interrupt-m ABI 专门用于中断处理函数的特殊逻辑. 具体就是增加了中断处理函数的栈帧保存和恢复, 以及使用 mret 指令替代 ret 返回.

#[link_section = ".trap"]
#[no_mangle]
unsafe extern "riscv-interrupt-m" fn _start_trap_rust() {
    println!("trap!");

    let mcause = riscv::register::mcause::read();
    println!("mstatus: {:016x}", riscv::register::mstatus::read().bits());
    println!("mcause:  {:016x}", riscv::register::mcause::read().bits());
    println!("mtval:   {:016x}", riscv::register::mtval::read());
    println!("mepc:    {:016x}", riscv::register::mepc::read());

    loop {}
}

这里打印了一些中断的重要信息, 协助判断中断函数是否被正常调用.

使用 #[no_mangle] 是为了暴露符号, 我们可以在汇编代码中使用这个符号设置中断处理入口地址.

使用 #[link_section = ".trap"] 是为了将这个函数放到 .trap 段, 以便于在链接脚本中处理, 尤其是内存对齐(ALIGN(8)). 这是写裸机代码常见错误, 因为 mtvec 寄存器的地址必须对齐(低2位由向量处理模式位占用), 否则会导致异常.

暂时我们不需要处理中断, 只需要观察中断是否被触发, 以及观察中断处理函数是否被执行. 所以使用 loop {}.

中断初始化

对于 RISC-V 来说, 中断的初始化大概需要如下步骤:

  • 设置 mtvec: 中断处理入口地址
  • 设置 mstatus 的 MIE 位: 允许中断
  • 设置 mie 的 MEIE 位: 允许外部中断, 定时器中断等

K230 使用了 Xuantie C908 核心, 支持 CLINT 和 PLIC 中断控制器. 相关资料可以从 C908 手册中获得.

global_asm!("
    .option push
    .option norelax
    la gp, __global_pointer$
    .option pop

    la t1, __stack_start__
    addi sp, t1, -16

    // 初始化中断
    la t0, _start_trap_rust
    csrw mtvec, t0

    call _early_init

    // 继续调用 _start_rust
    call _start_rust
");

#[no_mangle]
unsafe extern "C" fn _early_init() {
    {
        use riscv::register::*;

        mstatus::set_mie(); // enable global interrupt
        mstatus::set_sie(); // and supervisor interrupt
        mie::set_mext(); // and external interrupt
        mie::set_msoft(); // and software interrupt
        mie::set_mtimer(); // and timer interrupt
    }
}

mstatus 寄存器的 MIE 位用于控制中断使能, mie 寄存器的 MEXT 位用于控制外部中断使能, 即 PLIC, 用来处理外设中断.

这里同时初始化了 gp, 它是一个全局指针寄存器, 用于 Rust 的全局变量访问(在链接脚本中定义的特殊位置). 当然, 在使用内存区域较小较集中的时候, 很可能你不会见到使用 gp 寄存器的指令.

其他 CSR 初始化

基于平台不同, 还需要初始化其他硬件, 例如关闭 PMP, 初始化 FPU, 开启 mcycle, mtime 计数器等等. 其中 FPU 的初始化是必须的, 否则任意浮点数指令会导致异常. Rust 的 riscv-interrupt-m 实现不够智能, 无法判断 FPU 的使用情况, 所以当 target 包含 +f/+d 时, ABI 会默认使用 FPU 压栈指令.

// 这里省略平台特定的寄存器初始化部分
// 包括关闭 PMP
asm!("
    li    t0, 0x00001800
    csrw  mstatus, t0");

mcounteren::set_cy(); // enable cycle counter
mcounteren::set_tm(); // and time counter

// FPU init
mstatus::set_fs(mstatus::FS::Initial);
asm!("csrwi fcsr, 0");

mstatus 除了中断使能之外, 还负责当前 CPU 的运行模式, 例如 M/S/U 模式.

有了系统的 mcycle CSR, 就可以方便地使用 embedded-hal 生态中的 Delay trait, 实现较为精确的延时, 告别 nop.

const CPU0_CORE_CLK: u32 = 800_000_000;

let mut delay = riscv::delay::McycleDelay::new(CPU0_CORE_CLK);
delay.delay_ms(1000);

验证中断处理

我们可以通过直接触发软件中断的方式, 来验证中断处理函数是否被执行. K230 的 CLINT 中断控制器可以通过 msip 寄存器触发软件中断.

pac::CLINT.msip(0).write(|w| w.set_msip(true)); // trigger software interrupt

修改中断处理函数 _start_trap_rust 增加返回:

    if mcause.is_interrupt() && mcause.code() == riscv::interrupt::Interrupt::MachineSoft as _ {
        println!("Machine Software Interrupt");
        pac::CLINT.msip(0).write(|w| w.set_msip(false)); // clear software interrupt
        return;
    }

使用 mtime, mtimecmp CSR 也可以验证定时器中断. 但我在使用中发现一个坑, K230 的 CLINTmtime 无法通过 64 位 load 指令读取, 读出内容随机. 不抛出任何异常. 这导致 64 位的 mtime 必须通过两次 32 位读取, 然后组合成 64 位. 只有 rdtime 指令可以一次读取 64 位 mtime.

DDR init

DDR init / (SDRAM 初始化) 是一个比较复杂的过程, 一般需要初始化时钟, 复位控制器, PHY 训练, 芯片初始化, 时序配置, 自检等等. 这些内容往往都是厂商直接提供, 在相关的 DDR 初始化代码中, 相关寄存器写入流程也是如同天书一般.

所以 DDR init 代码直接通过 LLM 从 C 翻译. 不做额外解释. DDR 芯片不同, DDR init 代码也是不同的.

DDR init 之后, 我们就可以使用 DDR 区域的内存. 这里有一个需要注意的地方是, DDR 内存起始地址是 0x0000_0000, 然而 Rust 访问零地址有诸多限制, 多数函数会直接 panic. 程序中应该避免使用 0x0000_0000 地址.

正式开始裸机编程 (Start Real Bare-Metal Programming)

有了以上的初始化基础, 我们终于可以开始正式的裸机编程了. 例如初始化其他外设, 读写外设寄存器, 甚至是实现一些简单的功能.

这里以两个外设为例简单展示. 相关的外设寄存器定义我已经写好在 k230-bare-metal 仓库中.

GPIO 点灯

无论 MCU 还是 MPU, GPIO 点灯的步骤都是类似的:

  • 使能(或复位) GPIO 外设时钟, 电源
  • 设置引脚功能复用, 引脚模式
  • GPIO write

K230 默认情况下外设的时钟和电源信号都是开启的(检查相关寄存器可以确认). 所以我们只需要通过 IOMUX 设置复用功能, 通过 GPIO 外设设置好引脚模式即可. 相关的功能可以参考官方文档, 引脚复用的文档位于 K230_PINOUT_V*.xlsx.

IOMUX 外设是一个类似 PAD 的结构, 每个引脚通过一个 32 位寄存器设置复用功能, 上拉下拉, 输入输出使能等. 这部分定义我是通过 .dtsi 文件和 C 头文件获得, 也是交给 LLM 来转译成 YAML 定义. IOMUX.pad(n).set_sel(0) 即将引脚的模式设置为对应的 GPIO.

GPIO 外设来自 DW_apb_gpio, 熟悉 Verilog 等 HDL 的朋友一看文档就知道这个是一个最多 4 端口的可配置 GPIO IP Core. 有若干配置寄存器可以获取外设的初始参数:

GPIO0 config_reg1: num_ports=1
GPIO0 config_reg2: len(PA)=32
GPIO1 config_reg1: num_ports=2
GPIO1 config_reg2: len(PA)=32 len(PB)=8

一共 32 + 32 + 8 = 72 个引脚. 分两个 GPIO 控制器, 其中 GPIO1 控制器有两个 PORT. 可以完美适配 chiptool cluster/array 的定义方法.

fn blinky() {
    // RGB LED of LCKFB
    // - R: GPIO62
    // - G: GPIO20
    // - B: GPIO63
    use pac::{GPIO0, GPIO1, IOMUX};

    IOMUX.pad(20).modify(|w| w.set_sel(0)); // function = GPIOx
    IOMUX.pad(62).modify(|w| w.set_sel(0));
    IOMUX.pad(63).modify(|w| w.set_sel(0));

    GPIO0.swport(0).ddr().modify(|w| *w |= 1 << 20); // output mode
    GPIO1.swport(0).ddr().modify(|w| *w |= 1 << 30);
    GPIO1.swport(0).ddr().modify(|w| *w |= 1 << 31);

    loop {
        GPIO0.swport(0).dr().modify(|w| *w ^= 1 << 20); // toggle data
        // GPIO1.swport(0).dr().modify(|w| *w ^= 1 << 30);
        GPIO1.swport(0).dr().modify(|w| *w ^= 1 << 31);

        riscv::delay::McycleDelay::new(CPU0_CORE_CLK).delay_ms(1000);
    }
}

PWM 蜂鸣器

K230 有 6 个 PWM 输出, 分两个 PWM 控制器. 每个控制器内部是 3 个 PWM 输出通道. 1 个额外的通道 0 负责配置 Reload. 庐山派开发板上的蜂鸣器是通过 PWM1 GPIO43 控制的. PWM 外设的输入时钟是 100MHz, 通过 PWMCFG.SCALE 设置 2^n 分频.

为了使蜂鸣器达到人耳可识别的频率, 一般 PWM 的频率设置在 1KHz 左右. 通过 PWMCFG.SCALE 和 PWMx.CMP 设置周期和占空比. 相关代码如下, 寄存器值计算请参考注释

fn buzzer() {
    // GPIO43 - PWM1
    use pac::{IOMUX, PWM0};

    // PCLK, PWM use APB clock to program registers as well as to generate waveforms. The default frequency is 100MHz.
    IOMUX.pad(43).modify(|w| {
        w.set_sel(2); // PWM = 2
        w.set_oe(true);
        w.set_ds(7);
    });

    // Calc:
    // scale = 2
    // period = 0x5000
    // freq = 100_000_000 / (1 << 2) / 0x5000  = 1220.7 Hz
    // duty = period / 2 = 0x2800
    PWM0.pwmcfg().modify(|w| {
        w.set_zerocomp(true);
        w.set_scale(2);
    });

    PWM0.pwmcmp(0).write(|w| w.0 = 0x5000); // PWMCMP0: RELOAD
    let duty = 0x2800;

    PWM0.pwmcmp(2).modify(|w| w.0 = duty); // PWMCMP2: PWM1

    // enable
    PWM0.pwmcfg().modify(|w| w.set_enalways(true));
    riscv::delay::McycleDelay::new(CPU0_CORE_CLK).delay_ms(100);

    // disable
    PWM0.pwmcfg().modify(|w| w.set_enalways(false));
    riscv::delay::McycleDelay::new(CPU0_CORE_CLK).delay_ms(100);
}

一些延展思考 (Some Extended Thoughts)

Why 裸机?

裸机编程是嵌入式开发的基础, 也是最底层的开发方式. 通过裸机编程, 我们可以更好地理解硬件的工作原理, 以及操作系统的底层. 用遍全天下的库和 SDK, 不如自己写一个, 通一则通百.

SHELL?

在裸机环境下, 由于没有操作系统, 没有标准输入输出, 也没有文件系统, 完整的 SHELL 是不可能的. 但是我们可以通过串口, 实现简单的命令行交互. 所需要的只是两个串口函数 putchargetchar, 以及一个简单的解析器.

noline 是一个小巧的 no_std line-editing crate, 可以用于实现简单的命令行交互. 而且它基于 embedded-hal 生态, 可以方便地移植. 支持行历史和常见快捷键. 当然, 从头手写一个 readline 也是一个不错的练习.

通过实现若干 shell 命令, 我们可以实现简单的交互, 例如读写外设寄存器, 读写内存, 打印系统信息等等.

相关实现可以参考 k230-bare-metal 仓库. 最终效果如下:

K230> help
Available commands:
  help - print this help
  echo <text> - print <text>
  reboot - reboot the system
  mem_read <address> <length> - read memory
  mem_write <address> <u32> - write memory
  tsensor - read temperature sensor
  cpuid - print CPUID
  serialboot - enter serial boot mode
  jump <address> - jump to address
  jumpbig <address> - jump to big core and run

Download?

K230 的定位其实更像是 SBC(单板计算机), 烧录固件往往通过 TF 卡, 在裸机开发中极为不便, 持续插拔 TF 卡会导致接触不良, 甚至损坏.

联想到 LiteX 为 FPGA 软核环境提供了非常方便的 kernel/firmware 加载方式, 通过串口下载固件到某一内存位置(DDR), 甚至可以通过网络下载固件. 我尝试移植了 litex_term 的 UART 下载逻辑. 它内置了一个串口下载协议和串口命令行, 在检测到特殊字符串后, 自动切入下载模式, 通过串口下载固件到指定内存位置, 并跳转执行.

最终的效果是:

> litex_term /dev/tty.usbmodem56C40035621 --kernel-adr 0x01000000 --kernel ../firmware.img
......
Press Q or ESC to abort boot completely.
sL5DdSMmkekro
[LITEX-TERM] Received firmware download request from the device.
[LITEX-TERM] Uploading ../firmware.img to 0x01000000 (17400 bytes)...
[LITEX-TERM] Upload calibration... failed, switching to --safe mode.
[LITEX-TERM] Upload complete (8.7KB/s).
[LITEX-TERM] Booting the device.
[LITEX-TERM] Done.
Jumping to 0x01000000...

非常方便, 有机会单独额外介绍. 需要注意对内存区域写入固件需要处理 I-Cache 和 D-Cache 的状态, 本文编写时我选择彻底关闭 I-Cache 和 D-Cache.

跳转大核

前面说道, CPU1(大核) 的启动是由 CPU0(小核) 控制的. 具体启动逻辑也很简单, 设置复位向量并复位 CPU1 即可:

unsafe {
    ptr::write_volatile(0x91102104 as *mut u32, jump_addr as u32);
    ptr::write_volatile(0x9110100c as *mut u32, 0x10001000);
    ptr::write_volatile(0x9110100c as *mut u32, 0x10001);
    ptr::write_volatile(0x9110100c as *mut u32, 0x10000);
}

为了方便开发测试, 我把跳转大核也做成了 SHELL 命令. 通过 UART0 输入 jumpbig 0x01000000 即可跳转大核执行内存区域代码. 尝试 dump 大核寄存器信息, 可以看到启动信息:

Rust 2nd stage on CPU1
mstatus: 0000000a00001900
mie: 0000000000000000
mip: 0000000000000000
misa: 8000000000b4112f
  RV64ABCDFIMSUVX
mvendorid: 5b7
marchid: 8000000009140d00
mhartid: 0
cpuid: 09140b0d 10050000 260c0001

这里 RV64ABCDFIMSUVX 中的 V 表示支持 RVV 向量指令集, K230 是异构双核, 小核不支持 RVV. 可以证明我们的代码成功跳转到了大核.

当然有个有意思的地方, mhartid 是 0, 说明 K230 并没有满足 RISC-V 规范给不同的 hart 分配不同的 ID. 这个在实际开发中是需要注意的. 只能通过 misc CSR 来区分不同的 hart. 也是 K230 的一个小坑.

接下来就可以在大核上进行更复杂的操作, 例如 RVV 向量指令的应用等等.

结语 (Conclusion)

通过此次在 K230 芯片上的 Rust 裸机嵌入式开发, 我们深入探索了 MPU 与 MCU 在启动方式和使用模式上的区别, 掌握了使用 Rust 进行 MPU 芯片裸机开发的关键步骤, 包括启动流程、固件格式解析、中断和外设的初始化等。实践中, 我们成功实现了 UART 调试输出、GPIO 点灯、PWM 蜂鸣器等功能, 加深了对 K230 底层启动机制和硬件特性的理解。这些成果为日后在 K230 以及其他 RISC-V 芯片上开展更复杂的嵌入式开发奠定了坚实的基础。 展望未来, 我们可以进一步完善外设驱动, 探索多核协同工作、RVV 向量指令的应用, 以及结合 Rust 生态构建高效、 安全的嵌入式系统, 为 RISC-V 开源社区贡献更多力量。(由 GPT 总结)

Tips

  • Boot ROM 对于非法执行情况有异常报错, 可以用这种报错行为反向验证代码是否被执行, 例如插入非法指令查看报错位置的 pc
  • 裸机代码最好避免使用完整 target feature, 避免编译器生成还未使能的指令特性, 例如 V 扩展
  • D-Cache 和 I-Cache 的状态需要处理, 一般在跳转到新代码前关闭, 以避免缓存不一致
  • println! 宏可以方便地输出调试信息, 但是需要注意, 打印是阻塞的, 影响时间敏感的操作
  • 学会使用 LLM 协助自己的探索过程, 例如从 PDF 手册 OCR 导出 YAML 定义, DDR init 代码的翻译, 以及对于特定寄存器的解释
  • Boot ROM 会初始化一些外设, 例如 UART0, 但具体状态还需要再次验证, 例如 FIFO 模式, 波特率等
  • 对于可能的硬件实现 BUG 或特异性, 可以尝试使用等价的多种方法
]]>
猫·仁波切
Rust 嵌入式开发中的外设寄存器访问:从 svd2rust 到 chiptool 和 metapac - 以 hpm-data 为例2024-08-23T01:51:00+00:002024-11-14T00:27:37+00:00https://andelf.github.io/2024/08/23/embedded-rust-peripheral-register-access-svdtools-chiptool-and-metapac-approachEmbedded Rust Peripheral Register Access: svd2rust, chiptool and metapac Approach.

本文是基础向文章, 介绍了 Rust 嵌入式开发中的外设寄存器访问问题,以及社区提供的解决方案。包括以下内容:

背景

在嵌入式开发中,我们经常需要访问系统外设寄存器,以配置外设的工作模式、读取传感器数据等。在 C 中, 我们通常使用宏定义和来访问外设寄存器,例如:

uint32_t temp = ptr->ADC16_CONFIG0;
temp |= ADC16_ADC16_CONFIG0_REG_EN_MASK
         |  ADC16_ADC16_CONFIG0_BANDGAP_EN_MASK
         |  ADC16_ADC16_CONFIG0_CAL_AVG_CFG_MASK
         |  ADC16_ADC16_CONFIG0_CONV_PARAM_SET(param32)
ptr->ADC16_CONFIG0 = temp;

其中 ptr 类型为 ADC_Type *ADC_Type 是一个结构体,包含了 ADC 模块的所有寄存器字段, 按照相应内存布局一一映射。字段往往定义为 volatile 类型,以确保编译器不会对其进行优化。

更原始的, 比如在 8051 等单片机上, 往往直接通过内存地址来访问外设寄存器或 SFR, 例如:

#define ADC16_CONFIG0 (*(volatile uint32_t *)0x4000_0000)
uint32_t temp = ADC16_CONFIG0;
temp |= ADC16_ADC16_CONFIG0_REG_EN_MASK
         |  ADC16_ADC16_CONFIG0_BANDGAP_EN_MASK
         |  ADC16_ADC16_CONFIG0_CAL_AVG_CFG_MASK
         |  ADC16_ADC16_CONFIG0_CONV_PARAM_SET(param32)
ADC16_CONFIG0 = temp;

虽然这种方式简单直接,但是不够安全,容易出现错误。例如,当字段名误用时,编译器往往不会报错,而是直接生成错误的代码。另外,当字段的位宽和偏移写错时,也会导致错误的配置. 对于嵌入式环境来说, 更难以调试. 究其原因, 一是因为 C 语言中的宏是朴素的文本替换, 缺乏类型检查, 二是因为 C 语言中的类型系统较弱, 隐式类型转换较多. 另外还有历史原因, C 语言中的指针操作较为灵活, 这种 struct + 宏定义的方式在各大芯片厂商的 SDK/HAL/LL 中被广泛使用.

在 Rust 中,我们同样可以通过类似的直接操作内存地址映射的方式访问外设寄存器。这种方式的优点是速度快,但缺点是不够安全,容易出现错误。为了解决这个问题,社区提供了 svd2rustchiptool 等工具工具来生成类型安全的外设寄存器访问代码.

由来

这里会绍一个虚拟的发展历程, 可能并不代表真实的历史发展过程, 也不代表新方案完全替换了旧方案.

源起 - unsafe & volatile memory access

在 Rust 中,我们可以通过 unsafe 代码块和 ptr::read_volatileptr::write_volatile 等函数来访问外设寄存器。例如:

let ptr = 0x4000_0000 as *mut u32;
unsafe {
    let temp = ptr::read_volatile(ptr);
    ptr::write_volatile(ptr, temp | 0x1234);
}

这种方式的优点是简单直接,但缺点是不够安全,且需要依靠开发者本身的经验和代码命名规范来确保字段, SET, MASK 等的正确性. 直觉上, 就是在 Rust 中写 C 的 feel.

Memory Mapped Register IO - MMIO

和上文提到的 C 结构体类似, Rust 中也可以定义类似的结构体来映射外设寄存器。例如:

#[repr(C)]
pub struct ADC {
    pub config0: Config0,
    pub config1: Config1,
    pub data: u32,
    // ...
}

let adc = 0x4000_0000 as *mut ADC;
unsafe {
    let rb = unsafe { &mut *adc };
    // calling method or write to `rb.config0`
}

和 C 不同的是, Rust 缺乏 bitfield 的语法糖, 也就是说, Rust 中的结构体字段访问无法直接精确到 bit, 至少也是 u8. 但这并不妨碍 Rust 社区创建各种好用的第三方 crate, 例如 bitfield, bit_field 等. 通过直接使用 bitfield 作为字段类型, 可以更加直观的访问寄存器字段. 同时还有 bitflags 等 crate 提供类似 C 中标志位操作的功能.

这种方式的安全性有所保证,也一定程度上支持 C 样式的代码直接翻译. 但缺点是需要手动定义结构体和字段类型, 工程量大, 且容易出错.

另外在实际使用中, 还需要处理 volatile 的问题. 避免编译器优化掉对寄存器的访问.

svd2rust

svd2rust 是一个由 Rust 社区提供的工具,用于将 SVD 文件转换为 Rust 代码。SVD 文件是一种 XML 格式的文件,用于描述芯片的外设寄存器。svd2rust 会根据 SVD 文件生成一个 Rust 模块(xxx-pac),包含了芯片的所有外设寄存器的访问代码. 具体来说就是

  • 每个外设映射为一个 periph::RegisterBlock 结构体提供寄存器访问
  • 每个寄存器字定义为一个 RegisterBlock 的字段(或成员函数), 通过 read, write, modify 方法来访问: “read proxy” and “write proxy”
  • 单个寄存器被定义为类似 bitfield 的结构体
  • 寄存器位的访问被分为 read, write, modify 方法, 其中 write, modify 通过闭包来传递具体的操作

svd2rust 寄存器访问示例

早期 svd2rust 实现直接使用了 MMIO struct 的方式, 生成的代码例如:

#[doc = r"ADC Register block"]
#[repr(C)]
pub struct RegisterBlock {
    #[doc = "0x00 - status register"]
    pub stat: STAT,
    #[doc = "0x04 - control register 0"]
    pub ctl0: CTL0,
    #[doc = "0x08 - control register 1"]
    pub ctl1: CTL1,
    // ...
}

访问时使用:

let rb = unsafe { &mut *pac::ADC0::PTR }; // `pac` 是生成的模块

let val = rb.stat.read().adc_stat().bits();
rb.ctl0.write(|w| w.adc_en().set_bit().adc_start().set_bit());
rb.ctl0.modify(|_r, w| w.adc_en().clear_bit());

后来 svd2rust 在一次更新后, 将所有字段的 pub 属性去掉, 转而使用 const fn 来访问寄存器字段. 例如:

///Register block
#[repr(C)]
pub struct RegisterBlock {
    statr: STATR,
    ctlr1: CTLR1,
    ctlr2: CTLR2,
    // ...
}
impl RegisterBlock {
    ///0x00 - status register
    #[inline(always)]
    pub const fn statr(&self) -> &STATR {
        &self.statr
    }
    ///0x04 - control register 1/TKEY_V_CTLR
    #[inline(always)]
    pub const fn ctlr1(&self) -> &CTLR1 {
        &self.ctlr1
    }
}

访问时使用:

let rb = unsafe { &mut *pac::ADC0::ptr() }; // `pac` 是生成的模块

let val = rb.statr.read().adc_stat().bits();
let flag_val = rb.adc_stat().read().status().bit_is_set();
rb.ctlr1().write(|w| w.adc_en().set_bit().adc_start().set_bit());
rb.ctlr1().modify(|_r, w| w.adc_en().clear_bit());

这样做的好处是更好地隐藏了寄存器字段的具体实现,在嵌入式 Rust 中往往会大量使用 unsafe, 其中缺乏必要的检查, 通过隐藏字段的具体实现, 可以减少错误的发生. 同时, 通过 const fn 提供的字段访问, 可以很好地支持 “内存重叠字段”, 例如在 USB 外设中, 不同模式下, 同一个寄存器地址的字段可能有不同的含义, 通过 const fn 可以很好地支持这种情况.

使用 svd2rust 生成 pac 库

svd2rust 工具可以通过简单的命令行调用来生成 pac 库. 直接 cargo install 即可安装.

但实际使用过程中, 往往有很多的额外工作, 例如:

去哪里寻找 SVD 文件?

大部分情况下 SVD 文件可以从芯片的 CMSIS pack, 芯片厂商的 SDK 中找到. 去芯片厂商的网站翻一翻也许能找到. SVD 文件为 IDE 的调试功能提供了外设寄存器视图, 所以在对应的 IDE 或 IDE 扩展中也能找到. 同时, 直接联系厂商, 也许能得到帮助.

比如:

  • 常见的 Cortex-M MCU 一般会提供 CMSIS packs, 可以搜索芯片型号下载
  • CH32/GD32 等国产芯片的 SVD 文件可以在 MounRiver Studio IDE 的安装目录找到
  • HPMicro MCU 的 SVD 文件, 在官方 hpm_sdk

SVD 文件的质量如何?

SVD 文件往往由芯片厂商提供, 有些是由社区维护的, 质量参差不齐, 经常能见到格式报错, 字段错误等问题. 直接使用 svd2rust 工具执行转换也会提示报错信息, 当错误不够直观时候, 可以通过 xmllint 工具检查:

xmllint --schema svd/CMSIS-SVD.xsd --noout XX32XXX.svd

同时 svdtools 提供一套基于 YAML 格式的 SVD patch 工具, 可以用来修复 SVD 文件中的错误, 修改字段, 新增外设等等.

对于 svdtools 补丁工作流的使用, 可以参考 stm32-rs, 或者规模较小的 ch32-rs. 基本思路是拿到官方 SVD -> 修正格式错误(这个没得洗, 毕竟 xml 库都读不进去的话没有办法处理) -> 创建 patch 文件 -> svdtools apply -> patch 后的 SVD 文件 -> svd2rust -> pac.

SVD 文件的版权问题?

SVD 文件往往是芯片厂商提供的, 有些芯片厂商会在 SVD 文件或对应下载包中加入版权信息, 有些则没有. 一般来说, 用于开发者开发软件, 一般不会有问题, 但考虑到 pac 库发布需要, 最好联系芯片厂商, 以确认是否可以使用, 以及是否可以把 SVD 源文件包含在 pac 库中.

找不到 SVD 文件怎么办?

如果厂商没有提供 SVD 文件, 也可以通过手动编写 SVD 文件, 但这需要对芯片的外设寄存器有一定的了解, 以及对 SVD 文件格式有一定的了解. 一般来说, 从芯片手册中可以找到寄存器的描述, 以及寄存器地址, 位宽等信息.

直接以 YAML 格式编写 SVD 文件, 也是一种选择, 请参考 svdtools 的文档.

chiptool

chiptool 是一个由 Embassy 社区提供的工具,用于生成 Rust 外设寄存器访问代码, 主要用于 stm32-data, 为 Embassy 框架提供 STM32 所有 MCU 的外设寄存器访问支持. 相关背景可以参考项目首页, 其中有详细的介绍. 要点如下:

  • chiptool 实际上是 svd2rust 的一个 fork, 使之更适用于创建 metapac 式的 pac 库, 即厂商的一系列不同芯片的所有外设寄存器都放在一个库中. 这样做的好处是可以更好地复用代码和元数据信息
  • chiptool 没有使用 owned struct 的方式, 避免滥用 ownership, 提供更宽松的使用方式
  • chiptool 没有使用字段的 read/write proy, 这样字段本身作为类型(repr(u32))可以直接拿来保存寄存器值 - 一个常见场景是拿到中断 flags 值, 依次判断, 修改, 最后写回寄存器, 用来清除中断标志
  • chiptool 没有使用 MMIO 结构体, 而是直接保存外设地址
  • 提供了单个 YAML 文件表示一个外设的处理方式
  • 提供更方便的 transform 支持, 用于合并寄存器块, 字段, enum 类型, 创建 cluster, array 等

chiptool 寄存器访问示例

具体使用方法和 svd2rust 基本类似, bit field 访问方法略有不同, 通过 set_xxx 使用, 总体上更简洁:

let r = pac::ADC0;
let val = r.statr().read().0; // 读取寄存器值
let flag_val = r.adc_stat().read().status(); // 读取寄存器字段

r.ctlr1().write(|w| w.set_adc_en(true)); // 设置寄存器字段

r.ctlr1().modify(|w| w.set_adc_en(false)); // 修改寄存器字段, 闭包不再需要传递 `r` 参数, 读出的值直接通过 `w` 访问

使用 chiptool 生成 pac 库

chiptool 提供了简单的命令行接口来生成外设寄存器块定义(RegisterBlock):

# 从 SVD 文件提取所有外设信息
chiptool extract-all --svd soc.svd --output tmp

# 为单个外设生成 Rust 代码
chiptool gen-block --input tmp/UART.yaml -o src/uart.rs

生成的代码仅包含外设类型定义(如 UART 结构体),不包含具体实例(如 UART1、UART2)。要完整使用这些外设,还需要添加外设实例的地址信息, 使用 ::from_ptr 方法从地址构造实例.

相比之下, chiptool 更适合于生成 metapac 风格的 pac 库, 这也就意味着它的门槛更高, 需要更多的元数据信息, 以及更多的工作量.

曾经唯一的参考资料是 Embassy 项目维护的 stm32-data. 在它的基础上, 我裁剪并维护了 ch32-datahpm-data, 都可以作为 chiptool + metapac 工作流的参考.

管理多个, 乃至某一厂商所有 MCU 的外设寄存器访问代码, 需要对整个芯片系列有一定的了解, 以及对外设寄存器的共性和差异有一定的认识. 需要来回阅读参考手册和原始 SVD 文件, 以及对生成的代码进行测试.

而 svd2rust 目前需要额外的脚本或工具才能更好支持单个 pac 库对应多个芯片的情况, 例如 form 工具可以拆分 inline mode 到子 mod 文件.

metapac 的设计与实现

这里将介绍 metapac 的创建步骤, 设计思路与具体实现细节, 方便读者理解 metapac 的流程, 并搞定自己的 metapac 库. 目前的规范一般是 -data repo 用于存放元数据和生成代码, -metapac crate 用于最终发布.

stm32-data 整个流程较复杂, 包含从多个数据源获取的元数据, 包括 SVD 文件, STM32CubeMX 数据文件, 官方 SDK 头文件, ST-MCU-FINDER 数据等, 然后从 SVD 提取外设寄存器描述 YAML 文件, 通过若干 crate 配合, 完成数据的整合, 生成 pac 库.

而一些其他厂家的 MCU 可能缺乏如此丰富的格式化元数据(json/xml/etc.), 可能需要手动维护.

所以针对这种情况 ch32-datahpm-data 基于 stm32-data 的逻辑, 做了简化流程处理, 尽可能适合手工维护. 例如针对不同 MCU family 的外设情况, 增加了 include 支持, 方便层级化管理外设.

hpm-data 的难度相对更小一些, HPMicro 的 MCU 系列较少, 且外设跨度较小. 同时官方还提供了一些标准的元数据(官方 pinmux tool), 可以通过爬虫的方式拿到.

以下内容以 hpm-data 为例, 介绍 metapac 设计思路与具体实现细节.

项目目录结构介绍

  • d 脚本, 封装各命令
  • data/ MCU Family, 外设寄存器元数据目录
  • hpm-data-serde/ MCU 元数据的 serde 结构定义, lib
  • hpm-data-macros/ proc-macro lib, 从结构体转 Rust 代码的依赖, 不需要定制, 从 stm32-data 复制即可
  • hpm-data-gen/ 所有元数据的解析和生成工具, 从 data 目录读取, 生成到 build/data 目录
  • hpm-metapac-gen/ 最终的 metapac 生成工具, 从 build/data 目录读取, 生成到 build/hpm-metapac 目录
    • res/ 最后 metapac 的模板文件, 包括 Cargo.toml, build.rs, METADATA 常量结构体类型定义等等

hpm-data-serde/ 并不是唯一的数据类型结构体定义, 它只用于保存到 build/data 目录的格式. 在 hpm-metapac-gen/src 下还有第二份, 用于从 build/data 下的 json 解析. 在 hpm-metapac-gen/res/src 下还有第三份, 用于在最终的 pac 代码中提供 METAPDATA 类型定义.

这是整个项目结构最绕的部分, 新手容易迷失在结构体定义报错中, 往往新增字段需要改三个地方. 但通过这种方式, 可以很好地分离数据定义, 数据解析, 数据生成, 以及最终的代码类型. 例如在最终的 METADATA 中, 很可能为了考虑嵌入式环境和常量类型的特点, 所有的字符串都会被转换为 &'static str, 所有的数组都会被转换为 [u32; N] 等等.

元数据准备

首先确定好需要做哪些目标芯片的 PAC, 如果范围较广, 需要提前预留扩展性(比如多核的情况, 不同子架构的情况).

Chip Family

不同的芯片系列列表数据可以从厂商网站获取, 也可以从多个芯片手册中获取. 创建 data/chips/CHIP_NAME.yaml. 芯片名称的具体的粒度可以根据芯片的外设共性来划分. 主要型号之后的额外后缀往往包含芯片的具体封装(package: QFN, BGA 等), 以及不同的温度等级, 电压等级, 批次等. 元数据的字段参考 -data-serde 内的定义即可. 我们把这个文件定义为单个芯片 pac 的所需全部数据入口:

这里以 HPM5361.yaml 为例:

name: HPM5361
family: HPM5300 Series
sub_family: HPM5300, Single-core, Full Featured
packages:
  - name: HPM5361xCBx
    package: LQFP100
    pins: 100
  - name: HPM5361xEGx
    package: QFN48
    pins: 48
memory:
  - address: 0x00000000
    kind: ram
    name: ILM
    size: 128K
  - address: 0x00080000
    kind: ram
    name: DLM
    size: 128K
  - address: 0xf0400000
    kind: ram
    name: AHB_SRAM
    size: 32K
  - address: 0x80000000
    kind: flash
    name: XPI0
    size: 1M
cores:
  - name: RV32-IMAFDCPB # D25
    ip-core: Andes D25F
    peripherals: []
    interrupts: []
    include_peripherals:
      - "../family/COMMON.yaml"
      - "../family/HPM5300.yaml"
      - "../family/HPM5300_GPTMR23.yaml"
      - "../family/HPM5300_UART4567.yaml"
      - "../family/HPM5300_ADC1.yaml"
      - "../family/HPM5300_DAC.yaml"
      - "../family/HPM5300_OPAMP.yaml"
      - "../family/HPM5300_MCAN.yaml"
      - "../family/HPM5300_Motion.yaml"
      - "../family/HPM5300_PLB.yaml"
      - "../family/HPM5300_Secure.yaml"
    include_interrupts: "../interrupts/HPM5361.yaml"
    include_dmamux: "../dmamux/HPM5361.yaml"
    gen_dma_channels:
      HDMA: 32

除了芯片的基本信息, 还包括了芯片的内存布局, 而在 cores: key 下, 是外设列表, 中断列表, DMA 通道列表等等. 这里 cores 实现为一个列表, 以支持多核异构芯片, 虽然在 HPMicro 的 MCU 中并没有这种情况.

其中 include_peripherals:, include_interrupts:, include_dmamux: 是外设, 中断, DMAMUX 描述的直接引用, 这是对 stm32-data 的改进, 以支持更好的外设复用和手工维护. gen_dma_channels: 表示 DMA 通道的数量, 用于生成 DMA 控制器和 channel 的元数据, 这些元数据可能会在 hal 实现中用到. 尤其是 Embassy 这种异步框架, DMA 通道的管理是一个重要的部分.

外设元数据

外设的元数据是 pac 库的核心, 也是最复杂的部分. 一般来说, 一个外设的元数据包括:

  • 外设的基本信息, 包括名称, 描述, 寄存器等
    • 寄存器块的定义, 包括寄存器地址, 寄存器名, 寄存器描述等
    • 寄存器字段的定义, 包括字段名, 字段位宽, 字段描述等
    • 寄存器字段的值定义, 包括字段值名, 字段值描述等, 通过 enum 提供
  • 外设的中断信号
  • 外设的引脚信号, 包括引脚复用情况
  • 外设的时钟信号, 使能复位信号等
  • 外设的 DMA 请求信息

其中寄存器的信息我们可以从 SVD 里获取, 通过 chiptool 提供的 chiptool extract-peripheral 子命令, 可以方便地从一系列 SVD 中生成对应外设的 YAML 文件. 之后的工作就是手工维护这些 YAML 文件. 对于不同芯片使用相同的外设, 可以通过文件 diff 的方法来判断是否同一外设.

其中 YAML 文件格式例子如下, 相比 SVD 的 XML 更简单明了, 便于维护:

block/UART:
  description: UART0.
  items:
    - name: IIR2
      description: Interrupt Identification Register2.
      byte_offset: 12
      fieldset: IIR2
    - name: Cfg
      description: Configuration Register.
      byte_offset: 16
      fieldset: Cfg
    # .... other register items
fieldset/IIR2:
  description: Interrupt Identification Register2.
  fields:
    - name: INTRID
      description: Interrupt ID, see IIR2 for detail decoding.
      bit_offset: 0
      bit_size: 4
    - name: FIFOED
      description: FIFOs enabled These two bits are 1 when bit 0 of the FIFO Control Register (FIFOE) is set to 1.
      bit_offset: 6
      bit_size: 2
    # .... other fields
# ... other fieldsets
enum/RX_IDLE_COND:
  description: IDLE Detection Condition.
  bit_size: 1
  variants:
    - name: RXLINE_LOGIC_ONE
      description: Treat as idle if RX pin is logic one
      value: 0
    - name: STATE_MACHINE_IDLE
      description: Treat as idle if UART state machine state is idle
      value: 1

其中对外设寄存器描述的优化是整个工作最麻烦耗时的地方, 例如对字段值 enum 的优化, 对寄存器 array 的优化等. 优化修改的好处是显而易见的, 例如如下两种代码风格对比:

// set PWM1_CMP7 mode
use hpm_metapac as pac;
use pac::pwm::vals;
pac::PWM1.cmpcfg7().modify(|w| {
    w.set_cmpmode(0); // output compare
    w.set_cmpshdwupt(1); // on modify
});

// vs
pac::PWM1.cmpcfg(7).modify(|w| {
    w.set_cmpmode(vals::CmpMode::OUTPUT_COMPARE);
    w.set_cmpshdwupt(vals::ShadowUpdateTrigger::ON_MODIFY);
});

可见, 通过 enum 的方式, 可以更好地表达寄存器字段的含义, 免去额外注释, 也更容易理解, 更类型安全.

寄存器之外的其他信息一般需要从芯片手册中获取. hpm-data 在实现中, 大量使用了 hpm_sdk 头文件中的常量定义, 通过正则解析的方式动态填写在外设结构定义中. 另外前面提到的 pinmux tool 也提供了重要的引脚复用信息. 都通过 -data-gen 工具解析, 合并 Chip Family 信息后生成到 build/data 目录.

然后在 family/ 中创建对应的外设版本引用就可以继而被 CHIP_NAME.yaml 的 include 引用.

# part of family/HPM5300_UARTs.yaml
- name: UART0
  address: 0xF0040000
  registers:
    kind: uart
    version: v53
    block: UART
  # the following are filled by `-data-gen` tool
  # pins:
  # sysctl:
  # interrupts:
  # dma_channels:

data-gen 工具

-data-gen 工具会扫描 data/chips/ 下所有 CHIP_NAME.yaml 文件, 处理 include_x: 引用, 根据对应的外设信息, 从 sdk 头文件中提取常量定义, 填充上述的 pins, sysctl, interrupts, dma_channels 等字段, 最终生成到 build/data 目录. 这个工具是整个 metapac 生成流程的核心, 也是最复杂的部分. 在 stm32-data 中, 也是这个工具从各种数据来源中提取生成结构化数据.

hpm-data-gen
├── Cargo.toml
└── src
    ├── main.rs # 主程序入口
    ├── dma.rs # DMA 通道信息提取
    ├── interrupts.rs # 中断信息提取
    ├── pinmux.rs # 引脚复用信息提取
    ├── iomux.rs # 引脚复用信息提取, 提出 sdk 常量
    ├── pins.rs # 引脚数量, GPIO port 等信息提取
    ├── registers.rs
    ├── sysctl.rs # 时钟, GROUP 使能信息提取
    └── trgmmux.rs # 处理全局 TRGMUX 信号

相关逻辑请参考 hpm-data 项目, 通过依次执行以上流程, 完成了外设元数据的准备. 最终元数据如下:

  • build/data/chips/CHIP_NAME.json - 对应每个芯片的元数据
  • build/data/registers/periph_ver.json - 对应每种外设的寄存器信息

metapac 生成

-metapac-gen 工具会扫描 build/data/ 下所有的芯片和外设寄存器数据, 生成最终的 metapac 代码. 结合 res/ 下项目模板, 最终输出到 build/hpm-metapac 目录. 这个工具是整个 metapac 生成流程的最后一步, 直接输出一个 crate 目录作为结果.

  • 处理外设寄存器信息, 生成 periph_ver.rs 文件
  • 处理芯片信息, METADATA 结构, metadata_xxxx.rs 文件, 通过编译时 feature flag 选择引用具体文件
  • 输出 -metapac crate

芯片信息的处理主要包括 METADATA 的处理, 最终所有芯片名 feature gate 和外设版本的映射关系, 中断结构体, memory.x, device.x 等信息的生成.

METADATA 是区别于 metapac 和传统 svd2rust PAC 的一个重要特点, 用于提供芯片的元数据信息, 例如芯片的内存布局, 中断表, 外设及版本信息等. 通过 "metadata" feature gate 启用. 这个信息在 HAL 驱动中可能会用到, 例如 Embassy driver 需要动态创建 DMA 类型等.

外设寄存器信息的代码生成相关逻辑主要是调用 chiptool 完成, 通过一个简单的 IR (intermediate representation) 结构, 生成对应的 Rust 代码.

题外: chiptool 的这个从 YAML 定义生成 .rs 代码的逻辑其实非常有用, 使用场景不仅限于 pac 库, 例如一些公共 IP 外设的定义, 二进制协议的定义, I2C 传感器寄存器格式的定义等等. 甚至诸如 RISC-V CSR 字段的定义都有可能使用到这种机制. 希望这种格式成为 Rust 嵌入式的某种标准. yaml2pac 就是这样一个尝试, 实现单个文件的代码输出. 社区也有类似的工作 embassy-rs/chiptool#17.

hpm-metapac 的扩展内容

HPMicro 提供了多个系列的高性能 RISV-V MCU, 包括丰富的外设资源和高速时钟, 其中较为复杂的是 SYSCTL 的资源管理, 引脚的 IOMUX, 以及 TRGMUX 外设互联等. 这些外设涉及到大量的 CLUSTER 寄存器(即外设的寄存器块通过二级, 三级索引的方式使用, 更好地组织资源), 而 chiptool 目前对这种 CLUSTER 索引支持并不完善, 无法解析具体索引名字, 例如 CPU0 时钟设置 SYSCTL.CLOCK[CLK_TOP_CPU0].MUX, 在 chiptool 只能识别为 pac::SYSCTL.clock(0).read().mux(), 丢失了其中最终要的 <dimIndex> 信息. 其在 SVD 的原始定义如下:

<dimIncrement>0x4</dimIncrement>
<dimIndex>cpu0,cpx0,rsv2,rsv3,.... </dimIndex>

相同情况的还有 IOC, GPIOM 等外设, 为了处理这种情况, hpm-data 对 pac 库做了扩展, 单独将必备的索引信息, 以 pub const NAME: usize 的方式提供.

这些 consts 通过 hpm-data-gen 解析, 最终由 hpm-metapac-gen 生成为对应 pac 的子 mod:

  • hpm_metapac::clocks:: 下的所有时钟,用于 SYSCTL.CLOCK
  • hpm_metapac::resources:: 下的所有 SYSCTL 资源
  • hpm_metapac::pins:: 下的所有 GPIO 及其 PAD,用于 IOC
  • hpm_metapac::iomux:: 下的所有 IOMUX 设置(FUNC_CTL)
  • hpm_metapac::trgmmux:: 下的所有 TRGM 常量定义

对于 PAC 库来说, 不仅仅是提供给 HAL 驱动使用, 而是同时能给最终用户一个方便安全的寄存器访问接口. 对于某些设计良好的外设, 寄存器访问更直接有效. 所以上面的常量定义其实很有必要, 也为 HAL 的实现提供了额外信息: (这里为了方便作为 index 使用, 统一用了 usize 类型. 丢失了部分类型安全, 不过加一个 enum 也很容易, 这里主要是等待上游 chiptool 实现 cluster 支持.)

use hpm_metapac as pac;
use pac::{iomux, pins};

pac::IOC
    .pad(pins::PA25)
    .func_ctl()
    .modify(|w| w.set_alt_select(iomux::IOC_PA25_FUNC_CTL_PWM1_P_1));

pac 库的其他内容

上述一节其实已经介绍了 PAC 库在标准的外设寄存器访问定义之外还有哪些内容, 这里再总结一遍:

  • 中断静态结构体定义, enum 定义 - 用于在 -rt 库中使用, 链接到中断处理函数
  • device.x 定义中断处理函数的链接符号, 和中断表结构体结合使用
  • Peripherals owned struct, 用于通过 ownership 机制管理外设资源 - 仅 svd2rust
  • CorePeripherals owned struct, 用于管理核心外设资源, 一般是内核定时器中断处理器等 - 仅 svd2rust + Cortex-M
  • memory.x 定义内存布局 - 实际上由于平台的多样性, 不一定非要由 pac 来提供内存布局定义, 比如某些可自由配置 FLASH/RAM 的芯片, 更适合最终应用提供
  • 各种 METADATA 信息 - 仅适用于本文提到的 metapac

附录: svd2pac

Infineon 是对 Rust 嵌入式方案支持比较友好的公司之一(非爱好者友好). 他们也提供了一个基于 svd2rust 的工具 svd2pac, 用于生成 Infineon MCU 的 PAC 库. 详细设计综合了 svd2rust 和 chiptool 的优点, 适合于单个芯片的 PAC 生成. 使用方法极其类似 chiptool 的闭包方式. 不过由于项目在 Infineon 之外使用较少, 对完整的 SVD 特性支持还不完善.

具体改进:

  • 整体使用方式类似 chiptool
  • 寄存器访问应该是不安全的,因为它们相当于 C FFI
  • 不使用 owned Peripherals 结构体,因为带有 ownership 的寄存器会妨碍编写低级驱动程序(Low Level Drivers, LLD)
  • 不使用宏,以便于调试(相比 ral 而言)
  • 较少的外部依赖

总结及对比

  • C 中的外设寄存器访问, 方式简单直接, 但容易出错, 不够类型安全, Rust 基于自己的类型系统, 可以提供更好的类型安全, 但需要额外的工具支持
  • svd2rust 适合于单个芯片的 PAC 生成, 通过 svd2rust 命令行工具, 可以快速生成单个芯片的 PAC 库, 目前也是社区最常用的方式
  • chiptool 适合于多个芯片的 PAC 生成, 通过 chiptool-data 仓库, 可以为多个芯片生成 PAC 库, 适合于厂商的多个芯片系列的 PAC 生成. 同时具有 METADATA 的特性, 用于提供芯片的元数据信息

除此之外 ral / ral-registers 也提供了另一种基于宏的外设寄存器访问方案.

]]>
andelf
机场 24x24 像素显示单元 Airport 24x24 Dot Matrix RGB Character Display Unit2024-08-19T13:42:00+00:002024-08-20T07:41:40+00:00https://andelf.github.io/2024/08/19/airport-24x24-dot-rgb-character-display之前在咸鱼 App 上收了 3 个 24x24 像素的机场 RGB 点阵显示单元, 大概是这么个东西:

Front

这种显示单元在机场的航站楼里面经常见到,用来显示航班信息, 一个单元显示一个汉字. 常见是红色或黄色单色字符, 特点是显示效果非常醒目.

Airport Screen

听商家说, 这一批屏幕来自于浦东机场. 大概是机场航班信息显示屏幕的一部分. 如上图, 一个汉字正好使用一个显示单元. 字体边缘清晰, 亮度高.

简单搜索发现相关似乎 IC 资料能搜到, 猜测驱动应该不难, 所以当时就收了下来. 垃圾佬的特色就是赌.

介绍

整个显示单元成色还可以, 但设备常年在较恶劣使用环境下工作, 有不少陈年灰, 需要清理.

正面稍有岁月痕迹, 外壳有些许运输划痕, 但屏幕成色很好. 屏幕隐约能看到大块的像素点.

Back

背后是巨大的散热金属片, 用于给 RGB 背光驱动板散热. 说明 RGB 背光板功耗不小, 提醒我们不要直接用单片机驱动. 从背面的文字可以确定屏幕型号是 KD54008-L025. 同时插座文字可以确定屏幕的上下方向. 左右两侧有三个 PH2.0 接口, 用于背光级联驱动和背光电源. PCB 上有标注, 但被散热片遮挡不容易看到. 背光板上有三个电位器可以调整 R, G, B 的亮度.

Back Light Driver PCB

拆掉后可以看到背光 RGB 灯珠, 分十六组, 使用移位寄存器驱动, 即完全改变一片背光板的颜色, 需要通过移位16次.

LCD Screen Teardown

屏幕是两层静态 TN, 每一层都是 24x24 像素, 相同. 这种屏幕的特点是响应速度快, 高对比度, 叠两层对比度更高. LCD 驱动板分上下两块 PCB 板, 每块板四个 LCD 驱动芯片 LC7931 级联. TSSOP20 封装的是 74HC245, 用于级联信号驱动能力. 上下两块驱动板的布局近似, 但移位方向和屏幕像素连接稍有不同, 基本上是镜像对称的. 这里放上板的图:

LCD Driver PCB

每块驱动板背后有两个 10pin 接口, 用于级联和电源. 10pin 接口是超薄接插连接器, 不是常见的型号, 但可以用 MX1.25-10P 超薄接头兼容.

折腾这种东西像是在复习自己当年的数字电路知识, 高低电平, 时钟, 包括移位寄存器, 锁存器等等.

电路部分

掏出家家必备的万用表开始, 一顿测. 背光驱动板因为接口有标注, 可以直接给驱动信号. LCD 驱动板稍微复杂一点, 但你只需要一个周末的下午. 有一些常用的经验:

  • 如果外壳或安装孔接地, 可以很容易确定地线和电源
  • 从已知芯片和资料比较完善的芯片开始, 检测信号引脚, 例如 74HC245
  • 级联式组合的芯片走线一般都比较有规律, 可以大致猜测

背光和LCD驱动是两部分独立的电路,分别处理。

LCD 驱动板

单个单元有上下两块驱动板, 分别用于上下两半屏幕. 两块驱动板的布局近似, 但移位方向和屏幕连接稍有不同, 基本上是镜像对称的.

以上板为例(01A):

  • 4 片级联 LC7931, 用于驱动屏幕, 三洋的 80-channel Liquid-crystal Display Driver
  • 1 片 HC245A , 即熟悉的 74HC245, Octal 3-State Noninverting Bus Transceiver, 三态 8 位总线收发, 用于驱动信号和级联信号的 buffer, 增加级联信号驱动能力
  • 两个超薄 10pin 板对线接插连接器, 不是常见的型号, 能近似兼容的信号是 MX1.25-10P 超薄接头(必须是超薄)

通过分析74HC245三态8路总线收发芯片的使用情况,可以快速判断该PCB的供电和信号线路。74HC245的使能信号和方向信号均接地,说明它被当作一个普通的缓冲芯片使用,用于增加级联信号驱动能力.

74HC245的8路信号输入输出分别引向其他部分。由于LTC7931支持级联, 需要4条信号线控制, 因此可合理猜测这8路信号是用于TC7931的级联输入输出信号.

LTC7931是 80 通道液晶显示驱动芯片,用于驱动LCD的的像素点。引脚较多, 需要慢慢对应到接插件端口上. LC7931 手册很详细, 是影印版的 PDF. 我们常把这种显示单元称为 “段码屏”, 屏幕被分成了很多 “segment”, 具体组织方式可以是规则矩阵排列, 也可以是类似数码管, 比如常见的七段数码管就是一种 segment display.

忽略74HC245, 简化电路示意图如下:

SCH

主要是级联和接插件的信号输入输出。上下两块驱动板的四个接口的GND均位于显示单元外侧,电源输入位于靠模块中间一侧。

具体的像素布局需要在代码中一位一位修改尝试, 这里我结合了代码和万用表, 屏幕布局大概是:

  • 屏幕上半部分像素 width=24, height=12, 由 4 片 LC7931 驱动, 级联方式
  • 每片 LC7931 驱动 72 个像素点(segment), 80-bit 输出的高八位为 NC
  • 72 个像素点正好是 6 列, z 字形排列, 12 行
  • 屏幕下半部分与此镜像对称, 所以成了 80-bit 输出的低八位 NC
  • 上下两块驱动板的布局通过跳线电阻选择移位方向, 所以大概是:
(Back View)
+--CN0------------CN1--+
| A1 -> A2 -> A3 -> A4 |
|                      |
........................
|                      |
| B1 -> B2 -> B3 -> B4 |
+--CN0------------CN1--+

其中每个 Ax, Bx 表示单片 LTC7931 驱动的 72 个像素点, 也就是 6 列 12 行的像素点.

CLKSR 是移位寄存器时钟, 最高 1MHz, 计算可以得到, 80 segment 驱动, 一个屏幕 8 片 LC7931, (1 MHz / (80 * 8) = 1562 Hz) 理论最高一个单元屏幕刷新率, 3 个级联的话就是 320 Hz. 速度还可以. 对于机场静态文字来说, 这个速度是足够的.

背光电路

背光板上的 16 组 RGB LED, 每组若干高亮三色 LED, 通过移位的方式逐渐驱动, 一个单元有三个连接器. 很常见的 PH2.0.

  • CN1: 电源输入, 5V, GND, 实测单个单元最高电流可达 1A 以上, 常见单片机板子 5V 直接驱动可能过流烧坏电路!!
  • CN2: 驱动信号输出, 用于级联
  • CN3: 驱动信号输入
  • 3 个电位器旋钮, 分别用来控制 R, G, B 的亮度, 方便调整颜色一致性

CN2, CN3 都是 8pin PH2.0 接插连接器. CN3 输入驱动信号如下:

自上到下 信号 说明
1 CLK 背光移位寄存器时钟
2 LATCH/LOAD 背光移位寄存器锁存
3 EN 背光输出使能, 低电平有效
4 R 红色
5 G 绿色
6 B 蓝色
7 GND
8 NC  

电路引出

得知了接口的信息, 就可以准备转接板了. 我这里选用了常见的单面洞洞焊接板, 引出电源及 10 pin 接口到 2.54mm 排针.

Coding Time

MCU 这里选择 RPi Pico(RP2040), 开发框架选择 Rust Embassy. 它为嵌入式环境提供了方便简洁的 async/await 支持. 官方对 STM32, RP2040, NRF 等常见芯片提供了支持. 我目前正在做的 ch32-hal 项目提供了 WCH 32 位单片机的 Embassy 支持, hpm-hal 项目提供了 HPMicro 的 32 位单片机的 Embassy 支持.

我们以 Embassy async task 的方式编写屏幕相关驱动.

从官方项目仓库直接找到 examples 目录, 复制出来搞定项目模板, 开始写代码.

背光驱动

先从简单的开始, 背光的移位和锁存可以快速验证, 甚至本例中, 我是用跳线来回短接模拟时钟测试的.

LCD 像素驱动

相对来说 LCD 的驱动需要处理像素位置等信息, 额外还有 M 信号用于刷新 LCD, 相对复杂一些.

LCD M 信号的 AC 刷新, 可以使用 PWM 输出, 也可以偷懒直接用 GPIO:

#[embassy_executor::task]
async fn lcd_ac_driver(pin: AnyPin) {
    let mut pin = Output::new(pin, Level::Low);
    let mut ticker = Ticker::every(Duration::from_millis(10));
    loop {
        ticker.next().await;
        pin.toggle();
    }
}

移位式像素屏幕的显示内容驱动其实有完整的一个套路, 例如 WS2812 矩阵, 还有这种 LCD segment 驱动. 用 Framebuffer 是最简单的方式. Framebuffer 中字节的内容建议在空间允许的情况下, 尽可能接近最终传输输出的数据, 而不要为了方便像素写入逻辑使用复杂的发送时像素映射运算.(高速移位时候避免复杂指令对于多数低端单片机来说是必要的)

// 3 display units
// 10 byte per chip, 24 chip, 12 upper chip, 12 lower chip
pub struct Pixel24x24 {
    buf: [u8; 10 * 8 * 3],
}

impl Pixel24x24 {
    pub fn new() -> Self {
        Self { buf: [0; 10 * 8 * 3] }
    }

    pub fn set_pixel(&mut self, x: u16, y: u16, on: bool) {
        let x_index = x / 6;
        let y_index = y / 12;

        // zig-zag shape
        let chip_index = if y_index == 0 { (12 + x_index) } else { (x_index) };

        let start_index = 10 * (chip_index as usize);

        let mut chip_buf = if y_index == 0 {
            &mut self.buf[start_index..start_index + 9]
        } else {
            &mut self.buf[start_index + 1..start_index + 10]
        };

        let chip_x = x % 6;
        let chip_y = y % 12;

        let chip_n = chip_y + chip_x * 12;
        let byte_index = (chip_n / 8) as usize;
        let bit_index = chip_n % 8;

        if on {
            chip_buf[byte_index] |= 1 << bit_index;
        } else {
            chip_buf[byte_index] &= !(1 << bit_index);
        }
    }
}

随后实现 embedded-graphicsDrawTarget trait:

impl OriginDimensions for Pixel24x24 {
    fn size(&self) -> Size {
        Size::new(24 * 3, 24)
    }
}
impl DrawTarget for Pixel24x24 {
    type Color = BinaryColor;

    type Error = core::convert::Infallible;

    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
    where
        I: IntoIterator<Item = Pixel<Self::Color>>,
    {
        for Pixel(coord, color) in pixels {
            if self.bounding_box().contains(coord) {
                self.set_pixel(coord.x as u16, coord.y as u16, color.is_on());
            }
        }
        Ok(())
    }

    fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
        if color.is_on() {
            self.buf.fill(0xff);
        } else {
            self.buf.fill(0);
        }
        Ok(())
    }
}

完成 Framebuffer 之后, 驱动只需要把 FB 的内容移位输出即可:

#[inline]
fn shift_out_lsbf(p: &mut Output, clk: &mut Output, data: u8) {
    for i in 0..8 {
        if data & (1 << i) != 0 {
            p.set_high();
        } else {
            p.set_low();
        }
        Delay.delay_us(100);
        clk.set_low(); // falling edge shift data out
        Delay.delay_us(100);
        clk.set_high();
    }
}

由此, 就可以用 embedded-graphicsDrawTarget trait 来驱动 LCD 了.

Char Display

进阶 - Field Sequential RGB Driving

在群里经 wenting 大佬的点拨, 发现它完全可以用 Field Sequential RGB Driving, 也就是分时复用的 RGB 驱动方式, 通过快速切换 R, G, B 三种颜色的亮度来合成出各种颜色. 它与传统的像素并行驱动显示技术不同,采用时间分割方法来处理颜色。

首先显示所有像素的红色成分,然后是绿色,最后是蓝色。这些颜色的显示通常以非常高的频率交替进行,以便人眼无法察觉到颜色的切换,而是感知到这些颜色的混合效果。由于人眼具有持续性的视觉特性(视觉暂留),不同颜色的快速切换可以在观众的视觉中自然地混合起来,从而形成完整的图像。

本文配图即为这种分时复用 RGB 驱动方式的效果.

其实这个拆机折腾早在 2023 年末即完成, 只不过迟迟一直没有总结.

后来考虑到 5V 输出更稳定, 单独做了一块 CH32X033(X035) 的板子, 方便焊接排线驱动, 灵感来自 DALL-E 的一次输出, 即结合可焊接 pad 和 2.54mm 排针, 方便使用.

PCB Board Design

]]>
andelf
HPM RISC-V MCU 中断处理简介: 直接地址模式和向量模式 - Interrupt Handling for HPMicro RISC-V MCU / Andes RISC-V2024-06-17T07:30:00+00:002024-06-17T12:43:43+00:00https://andelf.github.io/2024/06/17/hpmicro-riscv-interrupt-handlingInterrupt Handling for HPMicro RISC-V MCU(Andes RISC-V IP Core) - Direct Address Mode and Vector Mode.

最近在折腾 HPMicro 的 RISC-V 系列 MCU 的 Rust 支持 hpm-hal, 由于需要处理中断, 所以对其中断控制器的工作原理有了一些了解. 虽然 HPMicro 的文档相对于国内其他厂商已经很出色了, 但对于一些细节还是有些模糊, 比如中断的向量模式和具体中断软件处理步骤. 虽然阅读 hpm_sdk 代码可以了解到一些细节, 但很容易迷失在条件编译的海洋中.

这里以 HPM5300EVK 为例, MCU 为 HPM5361, IP Core 为 Andes D25(F). HPM6xxx 系列的 IP Core 为 Andes D45, 但是中断控制器的实现是一样的.

  • 本文不涉及多核情况. 每个核心各自有一个 PLIC,其工作机制无本质区别.
  • 本文不涉及 Supervisor / User 模式, 仅讨论 Machine 模式下的中断处理.
  • 本文混合使用 HPM RISC-V MCU 和 Andes IP Core 两个名词, 对于中断处理来说, 他们是通用的.
  • 本文使用 hpm-metapachpm-hal 作为代码示例.

基础介绍

RISC-V 的中断分为核心本地中断(Core Local Interrupts, 即各种缩写中 “CLxxx” 的由来)和外部中断(External Interrupts). 异常也是一种中断, 但是异常是由指令执行引起的. 异常和中断通过 mcause 寄存器的最高位来区分. 通过 mstatusmie 寄存器开启和关闭中断.

异常

我们掏出 riscv-rt 源码看看具体定义:

extern "C" {
    fn InstructionMisaligned(trap_frame: &TrapFrame);
    fn InstructionFault(trap_frame: &TrapFrame);
    fn IllegalInstruction(trap_frame: &TrapFrame);
    fn Breakpoint(trap_frame: &TrapFrame);
    fn LoadMisaligned(trap_frame: &TrapFrame);
    fn LoadFault(trap_frame: &TrapFrame);
    fn StoreMisaligned(trap_frame: &TrapFrame);
    fn StoreFault(trap_frame: &TrapFrame);
    fn UserEnvCall(trap_frame: &TrapFrame);
    fn SupervisorEnvCall(trap_frame: &TrapFrame);
    fn MachineEnvCall(trap_frame: &TrapFrame);
    fn InstructionPageFault(trap_frame: &TrapFrame);
    fn LoadPageFault(trap_frame: &TrapFrame);
    fn StorePageFault(trap_frame: &TrapFrame);
}

#[doc(hidden)]
#[no_mangle]
pub static __EXCEPTIONS: [Option<unsafe extern "C" fn(&TrapFrame)>; 16] = [
    Some(InstructionMisaligned),
    Some(InstructionFault),
    Some(IllegalInstruction),
    Some(Breakpoint),
    Some(LoadMisaligned),
    Some(LoadFault),
    Some(StoreMisaligned),
    Some(StoreFault),
    Some(UserEnvCall),
    Some(SupervisorEnvCall),
    None,
    Some(MachineEnvCall),
    Some(InstructionPageFault),
    Some(LoadPageFault),
    None,
    Some(StorePageFault),
];

riscv-rt 通过静态数组 __EXCEPTIONS 定义了异常处理函数列表, 通过 mcause 寄存器的值来索引. TrapFrame 是一个结构体, 用于保存异常发生时的寄存器状态. 由汇编代码保存后压栈传递. 所有函数由链接脚本提供了一个 loop {} 死循环的默认实现, 用户可以在代码中覆盖.

中断

extern "C" {
    fn SupervisorSoft();
    fn MachineSoft();
    fn SupervisorTimer();
    fn MachineTimer();
    fn SupervisorExternal();
    fn MachineExternal();
}

#[doc(hidden)]
#[no_mangle]
pub static __INTERRUPTS: [Option<unsafe extern "C" fn()>; 12] = [
    None,
    Some(SupervisorSoft),
    None,
    Some(MachineSoft),
    None,
    Some(SupervisorTimer),
    None,
    Some(MachineTimer),
    None,
    Some(SupervisorExternal),
    None,
    Some(MachineExternal),
];

如上代码是中断处理函数的定义, 处理方式一致, 也是通过静态数组 __INTERRUPTS 索引. 但是中断处理函数不需要传递 TrapFrame, 因为中断发生时, 由中断处理函数来保存寄存器和恢复状态.

其中我们需要关注的是 MachineExternal 函数, 它负责在机器模式下处理外部中断(即 MCU 的外设中断). 当发生外部中断时, 在 MachineExternal 函数中, 需要先读取 PLIC 的中断挂起状态, 确定具体的中断源, 然后调用相应的中断处理函数进行处理.

中断入口

riscv-rt 默认使用直接模式(Direct Mode)处理中断, 即将 mtvec 寄存器设置为统一的中断入口函数地址。 在这种模式下, 发生中断时会直接跳转到该函数执行. 中断入口函数需要读取 mcause 寄存器的值, 判断是异常还是中断, 然后调用相应的异常或中断处理逻辑. 具体实现中, 入口地址为汇编代码, 负责调用 Rust 函数, 并执行 mret 指令返回.

(这部分实现 riscv-rt 经常修改, 今天汇编改 Rust, 明天 Rust 改宏, 大后天可能又换个位置…, 实际上区别不大, 依然很难用)

而 Rust 部分比较直观:

pub unsafe extern "C" fn start_trap_rust(trap_frame: *const TrapFrame) {
    extern "C" {
        fn ExceptionHandler(trap_frame: &TrapFrame);
        fn DefaultHandler();
    }

    let cause = xcause::read();
    let code = cause.code();

    if cause.is_exception() {
        let trap_frame = &*trap_frame;
        if code < __EXCEPTIONS.len() {
            let h = &__EXCEPTIONS[code];
            if let Some(handler) = h {
                handler(trap_frame);
            } else {
                ExceptionHandler(trap_frame);
            }
        } else {
            ExceptionHandler(trap_frame);
        }
        ExceptionHandler(trap_frame)
    } else if code < __INTERRUPTS.len() {
        let h = &__INTERRUPTS[code];
        if let Some(handler) = h {
            handler();
        } else {
            DefaultHandler();
        }
    } else {
        DefaultHandler();
    }
}

这样当我们需要处理外部中断时候, 只需要覆盖 MachineExternal 函数即可, __INTERRUPTS 通过链接符号获得函数入口地址.

#[no_mangle]
extern "C" fn MachineExternal() {
    let irq_no = pac::PLIC.claim();

    // handle irq_no here!

    pac::PLIC.complete(irq_no);
}

一般情况下, svd2rust 生成的 pac crate 会自带一个 __EXTERNAL_INTERRUPTS 表, 我们在 MachineExternal 中读取当前外部中断号, 然后跳转处理即可.

部分 RISC-V 核心实现可能会有一些特殊的处理, 以具体型号参考文档为准.

HPM RISC-V MCU 中断处理 - 直接地址模式

这里介绍 HPM RISC-V MCU 中断处理的两种模式, 包括传统的直接地址模式和 Andes IP Core 特有的向量模式.

riscv-rt 支持通过编译选项选择中断处理模式, 但向量模式实现极不通用, 直接地址模式是最常见的, 也是几乎被所有 RISC-V 实现支持的.

直接地址模式大致流程如下:

  • 中断模式启用:
    • mstatusMIE 位用于开启全局中断
    • mieMEXT 位用于开启外部中断, MTIMER 位用于开启 MTIME 中断…
    • mtvec 写入中断处理函数地址, 低位置 0(无影响)
  • 中断处理入口 - riscv-rt 提供
    • mcuase 可以获得当前是异常还是中断, 中断号等信息
    • 从静态数组中读取对应的处理函数地址, 调用中断处理函数中处理具体的中断
    • mret 返回
  • 外部中断处理函数 - 链接符号覆盖
    • 读取 PLIC, 获取当前中断号
    • 处理中断
    • 完成中断, 通知 PLIC (PLIC.claim)

GPIO 外设 - 外部中断

对于外部中断来说, 处理 MachineExternal 即可.

需要注意的是 mtvec 的设置在 riscv-rt 中完成, 通过 xtvec::write(_start_trap as usize, xTrapMode::Direct); 写入函数地址和中断模式即可. 但 Andes RISC-V IP Core 的 mtvec 低位地址无效, 它使用额外的自定义 CSR 来选择中断模式. 这里 mtvec 只用于写入函数入口地址.

mtvec 设置后, 还需要额外设置 mstatus, mie CSR 寄存器, 开启全局中断, 和外部中使能.

unsafe {
    riscv::register::mstatus::set_mie(); // enable global interrupt
    riscv::register::mie::set_mext(); // enbale external interrupt
}

这里以 PA09 GPIO0 中断为例(开发板板载按钮), 设置外设中断:

// 省略 GPIO 配置
pac::GPIO0.pl(0).set().write(|w| w.set_irq_pol(1 << 9)); // falling edge
pac::GPIO0.tp(0).set().write(|w| w.set_irq_type(1 << 9)); // edge trigger
pac::GPIO0.ie(0).set().write(|w| w.set_irq_en(1 << 9)); // enable interrupt

然后针对具体的外设中断号通过 PLIC 启用中断:

unsafe {
    hal::interrupt::GPIO0_A.set_priority(Priority::P1); // PLIC.priority
    hal::interrupt::GPIO0_A.enable(); // PLIC.targetint[0].inten
}

这里我们直接覆盖 MachineExternal 函数即可.

#[no_mangle]
extern "C" fn MachineExternal() {
    let claim = pac::PLIC.claim(); // 获取当前 interrupt id

    defmt::info!("claim = {}", claim);

    if claim == hal::interrupt::GPIO0_A.number() {
        // GPIO0_A();
        // write 1 to clear
        pac::GPIO0.if_(0).value().write(|w| w.set_irq_flag(1 << 9)); // 清除对应外设的中断标志
    }

    pac::PLIC.complete(claim); // 通知 PLIC 处理完毕
}

这样就完成了对 PA09 GPIO0 的中断处理.

MCHTMR - MTIME 中断

以上举例的是 MCU 的外设中断, 这里再介绍下 MTIME, 即 MCHTMR 中断的处理. 假定 mstatus::set_mie() 已经设置, 即全局中断已经开启.

let val = pac::MCHTMR.mtime().read();
let next = val + 24_000_000; // 24Mhz default

pac::MCHTMR.mtimecmp().write_value(next); // + 1s

unsafe {
    riscv::register::mie::set_mtimer();
}

这里我们直接设置了一个 1 秒之后的中断. 然后在 MachineTimer 中处理即可.

#[no_mangle]
extern "C" fn MachineTimer() {
    // // disable mtime interrupt
    //  unsafe {
    //        riscv::register::mie::clear_mtimer();
    //}

    let val = pac::MCHTMR.mtime().read();
    let next = val + 24_000_000; // 24Mhz

    pac::MCHTMR.mtimecmp().write_value(next);
}

中断处理函数 MachineTimer 中可以直接 mie::clear_mtimer() 关闭 MTIME 中断, 也可以设置比较寄存器, 设置下次中断时间.

HPM RISC-V MCU 中断处理 - 向量模式

中断向量模式在 HPM 官方 Datasheet 中介绍较为简略, 同时还需要参考 Andes RISC-V IP Core 的文档.

  • AndeStar V5 Platform-Level Interrupt Controller Specification (PLIC IP Core 文档)
  • AndeStar V5 System Privileged Architecture and CSR (CSR 文档)

经过相关试验, 得到如下结论:

  • 中断模式启用:
    • mstatusMIE 位用于开启全局中断
    • mieMEXT 位用于开启外部中断, MTIMER 位用于开启 MTIME 中断…
    • mtvec 写入中断表地址, mtvec 低 2 位无效 - 与标准 RISC-V 行为不一致
    • 自定义 CSR mmisc_ctl(0x7D0) 和 PLIC 设置位 PLIC.FEATURE.VECTORED 用于选择向量模式
    • 中断表地址必须位于 FLASH(XPI) 或 ILM, 无法放在 DLM, 编写自定义链接脚本时候需要注意
  • 中断表
    • 中断表索引为中断号, 4 字节内存地址表, 必须 4 字节对齐
    • 外设中断(外部中断)编号从 1 开始
    • 中断表索引 0 为异常处理和 Core Local 中断, 即上面提到的 __EXCEPTIONS__INTERRUPTS
    • 中断表 mtvec[N] 长 1024, 对于 Supervisor 模式和 User 模式, 中断表为 mtvec[1024+N], mtvec[2048+N]
  • 中断处理函数 - 外部中断
    • 此模式下 PLIC.CLAIM 寄存器值无效, 需要当前函数名获得, 或者通过 mcause 读取
    • 处理中断
    • 写入中断号到 PLIC.CLAIM 通知 PLIC 处理完毕
    • mret 返回
  • 中断处理函数 - Core Local 中断
    • 在向量模式下, 内部中断和异常处理函数位于向量表 0 位置
    • 通过读取 mcause 寄存器值, 获取异常或中断原因, 然后调用对应的异常或中断处理函数
    • mret 返回

Patch riscv-rt _setup_interrupts

riscv-rt 中的中断处理默认是直接地址模式, 通过编译选项 v-trap 可以切换到向量中断处理模式, 但是实现无法兼容 HPM RISC-V MCU 的向量中断模式. 传统 RISC-V 标准实现的向量中断模式, 指的是跳转指令的向量表, 即中断向量每个中断号对应一条 jump 指令, 跳转到对应的中断处理函数.

而 Andes IP Core 的中断向量是一个内存地址表, 每个中断号对应一个函数的内存地址. 这样的好处是直接跳转, 没有跳转指令的内存偏移限制.

万幸是 riscv-rt 提供了扩展点, 可以通过覆盖 _setup_interrupts 函数来实现自定义中断处理相关设置.

#[no_mangle]
pub unsafe extern "Rust" fn _setup_interrupts() {
    extern "C" {
        static __VECTORED_INTERRUPTS: [u32; 1];
    }

    let vector_addr = __VECTORED_INTERRUPTS.as_ptr() as u32;
    // FIXME: TrapMode is ignored in mtvec, it's set in CSR_MMISC_CTL
    riscv::register::mtvec::write(vector_addr as usize, riscv::register::mtvec::TrapMode::Direct);

    // Enable vectored external PLIC interrupt
    // CSR_MMISC_CTL = 0x7D0
    unsafe {
        asm!("csrsi 0x7D0, 2");
        pac::PLIC.feature().modify(|w| w.set_vectored(true));
    }
}

__VECTORED_INTERRUPTS 是在 hpm-metapac 中定义的向量模式中断表, 通过 extern 方式从链接器过程获取地址.

此时我们可能需要自定义链接脚本, 将 riscv-rt 中的额外无效符号丢弃. 不过目前暂时不需要.

中断处理函数 - 内部中断和异常

考虑到向量表 0 位置用于处理处理器内部中断和异常, 我们希望能复用 riscv-rt 中的异常处理函数定义表. 在 hpm-metapac 的实现中, 我定义向量表第一个位置为 CORE_LOCAL 中断处理函数(通过代码生成工具自动处理):

#[link_section = ".vector_table.interrupts"]
#[no_mangle]
pub static __VECTORED_INTERRUPTS: [Vector; 73] = [
    Vector { _handler: CORE_LOCAL },
    Vector { _handler: GPIO0_A },
    Vector { _handler: GPIO0_B },
    Vector { _handler: GPIO0_X },
    Vector { _handler: GPIO0_Y },
    Vector { _handler: GPTMR0 },
    Vector { _handler: GPTMR1 },
    // ......
}

这样就可以通过链接符号直接添加内部中断处理函数:

#[no_mangle]
unsafe extern "riscv-interrupt-m" fn CORE_LOCAL() {
    // 使用这样的方式, 可以链接非 `pub` 向量表
    extern "C" {
        static __INTERRUPTS: [Option<unsafe extern "C" fn()>; 12];
    }

    let cause = riscv::register::mcause::read();
    let code = cause.code();

    defmt::info!("mcause = 0x{:08x}", cause.bits());
    if cause.is_exception() {
        loop {} // let it crash for now
    } else if code < __INTERRUPTS.len() {
        let h = &__INTERRUPTS[code];
        if let Some(handler) = h {
            handler();
        } else {
            DefaultHandler();
        }
    } else {
        DefaultHandler();
    }
}
#[allow(non_snake_case)]
#[no_mangle]
fn DefaultHandler() {
    loop {}
}

由于没有了 riscv-rt 的中断入口函数, 我们需要自己处理寄存器压栈和恢复, 以及中断结束的 mret 指令. extern "riscv-interrupt-m" fn 是一个特殊的 ABI, 它会自动处理所有寄存器的保存和恢复,并在中断处理函数返回时执行 mret 指令. 需要 Nightly Rust 和 #![feature(abi_riscv_interrupt)] feature 启用.

使用这种方式, 可以在不 fork riscv-rt 的情况下, 实现与传统 riscv-rt 中断处理方式类似的功能.

这里我踩了一个不小的坑, 当开启向量模式后, PLIC.CLAIM 不再提供中断号, 但中断处理函数依然需要写入它来通知 PLIC 处理完毕. 因此, 需要通过读取 mcause 寄存器获取中断号, 然后写入 PLIC.CLAIM 寄存器来通知 PLIC 中断已经处理完毕.

中断处理函数 - 外部中断

#[no_mangle]
unsafe extern "riscv-interrupt-m" fn GPIO0_A() {
    // let claim = pac::PLIC.claim(); // WRONG!!!
    let mcause = mcause::read().bits();

    defmt::info!("button pressed!");

    // write 1 to clear
    pac::GPIO0.if_(0).value().write(|w| w.set_irq_flag(1 << 9));

    compiler_fence(core::sync::atomic::Ordering::SeqCst);
    pac::PLIC.complete(mcause as u16);
}

总结

本文中对 PLIC 操作的实现位于 hpm-halsrc/internal/interrupt.rs.

riscv-rt 的实现不够通用, 但疯狂打 patch 还是能将就的. 如果我已经自定义了向量表, 中断处理, 链接脚本, 那么原本属于它的只剩下几句 .data .bss 和 FPU 初始化汇编了. 并不是很好的方式.

向量模式中断总体实现更简洁明了, 少一层跳转, 其中 extern "riscv-interrupt-m" fn 可以通过过程宏的方式处理, 例如 #[interrupt].

本文适用于其他 Andes IP Core 的 RISC-V MCU, 但是具体实现细节请参考具体型号的文档.

本文适用于其他语言. 但是需要注意 ABI 和寄存器的处理.

欢迎关注 hpm-hal 的进展, 以及 hpm-metapac 的实现 hpm-data.

]]>
andelf
10.2 寸黑白红三色墨水屏价签拆解及驱动过程简记 - 10.2 inch 3-Color BWR ESL2023-12-17T11:54:00+00:002023-12-18T16:30:06+00:00https://andelf.github.io/blog/2023/12/17/10-2-inch-3-color-esl书接很久以前的 这里. 最近机缘巧合又收了一个 10.2 寸的黑白红墨水屏价签. 很新, 屏幕保护膜都没撕, 又可见倒闭的公司库存.

ESL 10in2

可惜后盖上的贴纸不见了, 不知道是哪家公司的. 先拆拆看.

拆解

目前市面上大大小小价签很多种, 拆解价签的方法大同小异. 拆价签翻车的案例太多, 尤其大屏幕, 一旦翻车就是一顿外卖的损失. 所以拆解前一定要做好功课, 了解屏幕的结构, 以及拆解的方法. 卡扣一般会特别紧, 需要较薄的刀片, 但又不能太薄, 否则可能会受伤. 比如这个:

ESL Teardown Toolkit

另外加上软质塑料片, 这玩意文具店很多, 用来拆解屏幕背面和 PCB 的双面胶.

ESL Back Remoted

撬开后电池盖, 注意卡扣位置. 然后取掉电池. 方便之后取出 PCB.

然后就是拆卡扣, 从屏幕背面的一个开口处开始, 用刀片, 慢慢撬一圈卡扣, 注意不要伤到自己和屏幕边缘.

ESL Back Teardown Start

ESL Back Teardown Knot Removed

ESL Back Teardown Removing Tape

卡扣分离后, 这时候可以用软质塑料片, 伸进去, 慢慢拆开 PCB 和后盖的双面胶.

PCB 也是用双面胶贴在屏幕背后的, 这一步是最容易翻车的, 一定要慢慢来. 塑料片伸进去横向推, 一点一点的推开 PCB 和背板的双面胶.

ESL Back Teardown PCB Removed 1

ESL Back Teardown PCB Removed 2

PCB 丝印: Endor Telink1020 2021-07-22 change U5, 可见这是 BLOZI(保资) 的价签. 但是在今天, 他家官网都是挂的. 难道价签厂家也倒闭了…

24pin 屏幕排线丝印: HINK-E102A01-A1, 搜索可以找到全网唯一参考资料1, 虽然是个 CSDN, 但提供了不少线索, 里面说明了驱动 IC 是 SSD1677, 微雪 3in7 也使用了相同驱动 IC, 但那是一个黑白屏幕.

如果没有找到对应的驱动 IC, 那可能就需要参考 wenting 的方法逆向了. 请自行学习.

驱动

拆解完成后就可以尝试驱动了. 一般来说, 24pin 就是 AIO(All-In-One) 串口屏了, 驱动版都是通用的. 这里随便找了一个驱动板(咸鱼), 使用 RPi Pico(RP2040 MCU) 来驱动. 开发框架使用 Rust embassy.

Rust embedded-graphics 提供了非常方便的 Framebuffer API(注意, 没有屏幕旋转支持).

传统情况下, 我们只需要实现一个 Display trait, 就可以使用 embedded-graphics 的各种绘图 API 了. 但这里, 我们再抽象一级, 直接预留一个 update_frame(raw: &[u8]) 接口, 直接接收 Framebuffer::data() 作为参数. 这是在经历了 epd 项目之后, 尝试七八种屏幕之后发现最合适最通用的方法.

先写个骨架, 一切 SPI 串口 EPD 都可以这样搞, 唯一需要注意的是部分驱动 IC 的 BUSY 使用反逻辑:

struct EPD10in2<'a> {
    spi: Spi<'a, SPI0, Blocking>,
    dc: Output<'a, AnyPin>,
    busy: Input<'a, AnyPin>,
}

impl EPD10in2<'_> {
    fn send_command(&mut self, cmd: u8) {
        self.dc.set_low();
        self.spi.blocking_write(&[cmd]);
    }

    fn send_data(&mut self, data: &[u8]) {
        self.dc.set_high();
        self.spi.blocking_write(data);
    }

    fn send_command_data(&mut self, cmd: u8, data: &[u8]) {
        self.send_command(cmd);
        self.send_data(data);
    }

    pub fn busy_wait(&mut self) {
        loop {
            if self.busy.is_low() {
                info!("busy out");
                break;
            }
        }
    }

    // ...
    pub fn init(&mut self) {}
    pub fn update_frame(&mut self, raw: &[u8]) {}
    pub fn refresh(&mut self) {}
}

BWR 驱动 - 三色

微雪驱动使用了自定义黑白 LUT 和 4 阶灰度的 LUT 来驱动, 但个人经验是, 自定义 LUT 方法不适合三色 BWR 屏幕. 除非确定 LUT 来自厂家调教. 三色屏幕建议使用出厂的 OTP LUT(One-time-programming LUT). 这样可以保证屏幕的寿命, 也直接使用厂商调教过的颜色效果. 否则电子墨水屏在 LUT 表错误的情况下, 极容易永久性损坏, 例如我手头有若干永久性残影的屏幕.

当然, 的确是可以自己调教三色 LUT 逻辑, 相关论文有不少, 例如 Zeng, W.; Yi, Z.; Zhou, X.; Zhao, Y.; Feng, H.; Yang, J.; Liu, L.; Chi, F.; Zhang, C.; Zhou, G. Design of Driving Waveform for Shortening Red Particles Response Time in Three-Color Electrophoretic Displays. Micromachines 2021, 12, 578. https://doi.org/10.3390/mi12050578. 请沿着引文链自行探索.

一般来说, 三色墨水屏在墨囊黑白粒子之外额外加入了第三种颜色的粒子, 例如红色, 黄色. 彩色粒子的带电量和粘度(粒子物理运动特性)和黑色粒子可以通过较弱电压区分. 驱动过程大概是: 清屏, 激活(让黑色和彩色粒子尽可能分层而不是黏在一起), 然后利用较弱电压, 使得彩色粒子在屏幕上浮动, 形成彩色图像.

通读 SSD1677 的数据手册, 对照微雪的驱动代码, 找到核心修改点. 几乎所有 EPD 驱动 IC 的手册都是极其含糊, 这个也不例外. 其中一些关键词, 可能是需要你通读过其他同类型驱动 IC 才能理解.

SSD1677 LUT Mapping

SSD1677 有如上两种模式, 一种是 BWR 三色, 一种是黑白两色. 按照不同方式使用 LUT.

SSD1677 Display Update Control 2

SSD1677 Write Display Option

这两个含糊的 Command 描述文档, 隐藏了 BWR 驱动的细节.

  • Display Mode 1 即 BWR 三色模式, Display Mode 2 为黑白模式
  • Display Update Control 2 命令的 0x99 和 0x91 分别可以加载不同模式的 OTP LUT
    • 0xC7, 0xCF 决定了最终刷新使用的 Display Mode
  • Write Display Option 命令中设置了 WS(LUT) 不同时间片对应的 Display Mode

那么这里, 我们直接加载 Display Mode 1 的 OTP LUT, 然后使用 Display Update Control 2(with Display Mode 1) 命令刷新.

由此修改 init() 函数:

    pub fn init(&mut self) {
        self.send_command(0x12); // Soft reset
        Delay.delay_ms(20_u32);

        self.send_command_data(0x46, &[0xF7]);
        self.busy_wait();

        self.send_command_data(0x47, &[0xF7]);
        self.busy_wait();

        // Driver output control
        // 0x27F = 639
        self.send_command_data(0x01, &[0x7F, 0x02, 0x00]);

        // set gate voltage
        self.send_command_data(0x03, &[0x00]);
        // set source voltage
        self.send_command_data(0x04, &[0x41, 0xA8, 0x32]); // POR

        // set data entry sequence
        self.send_command_data(0x11, &[0x03]);

        // set border
        self.send_command_data(0x3C, &[0x03]);

        // set booster strength
        self.send_command_data(0x0C, &[0xAE, 0xC7, 0xC3, 0xC0, 0xC0]);

        // set internal sensor on
        self.send_command_data(0x18, &[0x80]);

        // set vcom value
        self.send_command_data(0x2C, &[0x44]);

        // setting X direction start/end position of RAM
        // 640 -> 639 => 0x27F
        // 960 -> 959 => 0x3BF
        self.send_command_data(0x44, &[0x00, 0x00, 0xBF, 0x03]);
        self.send_command_data(0x45, &[0x00, 0x00, 0x7F, 0x02]);
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command_data(0x37, &[0x00; 10]); // Use Mode 1 !!!

        // Load Waveform !!!
        // 0x91, Load LUT with Mode 1
        self.send_command_data(0x22, &[0x91]);
        self.send_command(0x20);
        self.busy_wait();

        // Display Update Control 2
        self.send_command_data(0x22, &[0xCF]);
    }

除了屏幕大小设定的修改, 最核心的用 !!! 标注.

补齐写 RAM 函数和屏幕刷新函数:

    pub fn update_bw_frame(&mut self, buf: &[u8]) {
        // self.send_command_data(0x4E, &[0x00, 0x00]);
        // self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command(0x24);
        self.send_data(buf);
    }

    pub fn update_red_frame(&mut self, buf: &[u8]) {
        // self.send_command_data(0x4E, &[0x00, 0x00]);
        // self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command(0x26);
        self.send_data(buf);
    }

    pub fn refresh(&mut self) {
        let mut delay = Delay;
        self.send_command(0x20); // Master activation
        delay.delay_ms(100_u32); //must
        self.busy_wait();
    }

在屏幕大小正确设定的前提下, 0x4E/0x4F(RAM 当前 X/Y) 可以只写一次, 之后就不需要了, 自动增长.

为了测试效果, 我们找一张 LLM 生成的图. 由于屏幕是 960x640, 而一般 LLM 生成的图是正方形, 需要进行缩放. 这里可以使用 Context Aware Image Resizing(CAIR) 算法, 或者传统直接缩放.

考虑到我们的屏幕只有三种颜色, 无法体现图片的丰富彩色和灰度细节, 需要使用抖动 (Diffusion Dithering) 来模拟灰度. 然后提取 BW frame 和 Red frame, 分别写 RAM. 相关任务可以通过 ImageMagick 完成:

convert ~/Downloads/_34f9c9ae-d1c2-47a9-ab55-089ffc7cb626.jpeg -size 960x640 -dither FloydSteinberg -remap 3color.gif out.gif

这里 3color.gif 是只包含红黑白三色的索引色图, 用来提供调色板. out.gif 是经过抖动处理的图片.

之前突然想到, Rust 的过程宏不就是在编译期执行的吗? 于是就写了个过程宏, 用来自动加载图片, 提取 BW/Red frame. 项目在 text-image.

全部刷新大概需要 20 秒左右, 最终效果如下:

BWR Image with Dithering

BW 驱动 - 黑白双色

通过前面的描述, 其实大家都会发现, 首先 SSD1677 本身就支持三色和双色驱动两种模式, 且三色屏幕的彩色粒子和黑色粒子如果一同处理, 那完全是可以把三色屏幕当做双色屏幕来驱动的.

方法1: 依然使用三色模式, 只不过 RED RAM 永远置空. 这是最简单的, 不需要修改任何代码, 但需要忍受长大 20 秒的刷新时间.

方法2: 启用驱动 IC 的双色模式?

这里主要介绍双色模式, 它的好处是, 刷新速度可以调教到更快, 并且有可能支持灰度显示, 以及快速局部刷新. 黑白双色模式最主要的是驱动像素到新的状态, 需要拿到前一状态和目标状态, 然后执行对应的波形. 这需要驱动 IC 有对应的支持.

这里是驱动 IC 手册中含糊没有介绍清楚的部分:

SSD1677 LUT

可见 LUT 表和 上一篇文章 中的类似, 但似乎缺乏核心的 “AB” 概念? 其实不然. 我们大致按照 Display Mode 1 整理得到格式是:

  • VS - L0(LUT0) - for BLACK
  • VS - L1(LUT1) - for WHITE
  • VS - L2(LUT2) - for RED (R=1, B/W=0)
  • VS - L3(LUT3) - for RED (R=1, B/W=1), LUT3=LUT2
  • VS - L4(LUT4) - reserved
  • TP / RP - time period / repeat
  • FR - Frame Rate

而按照 Display Mode 2, 经过测试, 发现 LUT 是:

  • VS - L0(LUT0) - Black to Black
  • VS - L1(LUT1) - Black to White
  • VS - L2(LUT2) - White to Black
  • VS - L3(LUT3) - White to White
  • (其他部分一致)

可见, 这里其实是有新旧 AB 转换的概念的, 实测发现在 Display Mode 2 下, 0x24 B/W RAM 表示当前(目标/NEW)显示状态, 0x26 Red RAM 则是上一(OLD)状态. 那么看起来就可以实现快速刷新了. 只需要我们在写入新内容到 NEW RAM 同时, 把旧内容写入 OLD RAM. 驱动 IC 将自动使用 OLD/NEW 信息执行对应的波形, 完成像素位的状态转换.

那么是不是有种方法可以让驱动 IC 自动完成这个过程呢? 答案是肯定的. 这个功能在不同驱动 IC 的叫法不同, 比如在 UCxxxx 系列手册中, 叫做 N2OCP (New to Old Copy), 在 SSD1677 中, 叫做 “RAM ping-pong”.

翻看上面 0x37 Write Display Option 命令的 F[6] 位, 可以看到 “RAM Ping-Pong for Display Mode 2” 的描述. 且同时告知, 只有 Display Mode 2(黑白双色) 支持这个功能.

简单对 init() 函数做修改, 这里只贴出修改的部分:

    pub fn init(&mut self) {
        // ....

        // Display Option
        #[rustfmt::skip]
        self.send_command_data(0x37, &[
            0x00,
            0xFF, //B
            0xFF, //C
            0xFF, //D
            0xFF, //E
            // 0x0F, RAM ping-pong disable
            // 0x4F, RAM ping-pong enable
            0x4F, //F, RAM ping-pong enable. only in Display Mode 2
            0xFF, //G
            0xFF, //H
            0xFF, //I
            0xFF, //J
        ]); // MODE 2

        self.send_command_data(0x22, &[0x99]); // Load LUT with Mode 2
        self.send_command(0x20);
        self.busy_wait();

        // Display Update Control 2
        self.send_command_data(0x22, &[0xCF]);
    }

废话不多说, 我们编写一张快速刷新的波形, 前置条件是需要屏幕是纯白的, 也就是刚刚清屏后的状态. 这可以通过 OTP LUT 实现. 这里只关注我们需要的黑色和白色状态.

  • 对于 Black to Black 和 White to White, 什么都不做
  • Black to White, 加 VSL 电压, 若干周期
  • White to Black, 加 VSH 电压, 若干周期

经过测试, 我们得到了如下 LUT, 简洁到惊人. 0x0F 是个人测试的值, 即 15 个周期. RP=0 表示只重复一次. 相关内容和上一篇文章介绍的大同小异.

    pub fn configure_partial_update(&mut self) {
        #[rustfmt::skip]
        const LUT: &[u8] = &[
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
            0b10_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
            0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT5, reserved
            // TP[xA, xB, xC, xD], RP
            0x0F,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//7
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//9
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            // FR
            0x22,0x22,0x22,0x22,0x22
        ];
        self.send_command_data(0x32, LUT);
    }

而此时只需要写入 0x24 B/W RAM, 就可实现快速刷新, 实测大概 1s 左右即可完成刷屏. 且无闪动.

BW 驱动 - 灰度大法

看着 0x0F 这个时间周期值, 是不是手痒. 没错, 我们可以尝试调整这个值, 从而实现灰度显示. 灰度显示对于阅读器类应用意义重大, 矢量字体的抗锯齿渲染, 以及图片的灰度显示, 都可以大大提升用户体验.

这里假设我们需要实现 16 级别灰度, 正好对应 0x00 ~ 0x0F.

  • 可以刷屏 16 次, 每次刷入 1/16 的灰度层次, 朴素但是可行
  • !! 刷屏 4 次, 分别是 8, 4, 2, 1 个周期, 从而实现 16 级灰度, 相当于只用了 4 倍刷新时间

废话不多说, 直接上代码:

    /// Level 0 to 15
    fn configure_gray_update_level(&mut self, level: u8) {
        #[rustfmt::skip]
        let lut: &[u8] = &[
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
            0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//5
            // TP[xA, xB, xC, xD], RP
            level,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//7
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//9
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            // FR
            0x22,0x22,0x22,0x22,0x22
        ];
        self.send_command_data(0x32, lut);
    }

    pub fn refresh_gray4_image(&mut self, buf: &[u8]) {
        for level in (0..4).rev() {
            // level: (8, 4, 2, 1)
            self.configure_gray_update_level(1 << level);
            self.send_command(0x24);

            for chunk in buf.chunks(4) {
                let mut n = 0;
                for b in chunk {
                    if b & (0x10 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (1 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                }
                // 0xFF is white, 0x00 is black
                self.send_data(&[n]);
            }

            self.refresh();
        }
    }

    pub fn refresh_gray2_image(&mut self, buf: &[u8]) {
        for level in [1, 0] {
            // level: 9, 5
            self.configure_gray_update_level(1 << (level + 2) + 1);
            self.send_command(0x24);

            for chunk in buf.chunks(2) {
                let mut n = 0;
                for b in chunk {
                    if b & (0b01_00_00_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_01_00_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_00_01_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_00_00_01 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                }
                // 0xFF is white, 0x00 is black
                self.send_data(&[n]);
            }

            self.refresh();
        }
    }

以上代码中, refresh_gray4_imagerefresh_gray2_image 分别是 4 级和 2 级灰度的刷新函数. 这里对灰度的每一位的权重映射做了微调. 格式兼容 embedded-graphics 中的 Framebuffer 和 Image 类型, 可以直接使用. 其中的 bit 操作, 其实基本上是 Github Copilot 写的, 我只是稍微修改了下边界情况和编译错误, 实现不是最优, 但好理解.

显示效果, 这里以互联网 UGC 时代的化石, 徐静蕾手写体为例:

Gray4 for Font Renderring

你猜我抗锯齿字体怎么渲染的? 没错, 还是 text-image 过程宏, 使用方法:

let (w, h, raw) = text_image::text_image!(
    text = "北京市发布持续低温黄色预警\n北京市发布道路结冰橙色预警\n\n-12.5℃ \n-16℃ -- -7℃\n相对湿度 36%\n东北风 1级",
    font = "./徐静蕾手写体.ttf",
    font_size = 48.0,
    line_spacing = 0,
    inverse,
    Gray4,
);

info!("w: {}, h: {}", w, h);

epd.set_partial_refresh(Rectangle::new(Point::new(128, 160), Size::new(w, h)));
epd.refresh_gray4_image(raw);

当然这里用到了 “局部刷新” 函数, 我们这就介绍.

局部刷新

局部刷新是指, 只刷新屏幕的一个矩形部分, 而不是整屏刷新. 这在阅读器类应用中, 是非常重要的功能. 包括弹出式菜单, 局部 UI 元素等都可以用到. 在日历天气中, 局部刷新可以只刷新时间或天气的一小部分, 而不是整屏刷新, 从而大大提升观感.

实际上局部刷新只需要找驱动 IC 手册中的 RAM X/Y Start/End 关键词即可, UCxxxx 系列的驱动 IC, 也会直接提供 Partial Update 的相关命令. 这里一笔带过直接上代码:

    fn clear_as_bw_mode(&mut self) {
        // set X/Y ram counter
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);

        const NBUF: usize = 960 * 640 / 8;
        self.send_command(0x24);
        for i in 0..NBUF {
            self.send_data(&[0xFF]); // W
        }

        // reset X/Y ram counter
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);
        self.send_command(0x26);
        for i in 0..NBUF {
            self.send_data(&[0xFF]); // Red off
        }
    }
    pub fn set_partial_refresh(&mut self, rect: Rectangle) {
        // clear old buf
        self.clear_as_bw_mode();
        let x0 = (rect.top_left.x as u16);
        let x1 = rect.bottom_right().unwrap().x as u16;
        let y0 = rect.top_left.y as u16;
        let y1 = rect.bottom_right().unwrap().y as u16;

        self.send_command_data(0x44, &[(x0 & 0xff) as u8, (x0 >> 8) as u8, (x1 & 0xff) as u8, (x1 >> 8) as u8]);
        self.send_command_data(0x45, &[(y0 & 0xff) as u8, (y0 >> 8) as u8, (y1 & 0xff) as u8, (y1 >> 8) as u8]);
        // set X/Y ram counter
        self.send_command_data(0x4E, &[(x0 & 0xff) as u8, (x0 >> 8) as u8]);
        self.send_command_data(0x4F, &[(y0 & 0xff) as u8, (y0 >> 8) as u8]);
    }

需要注意的是:

  • 在开启局部刷新前需要处理好 OLD/NEW RAM 的状态, 这里选择直接清空
  • X/Y RAM counter 需要设置正确
  • 部分驱动 IC 要求 gate 或 source 边界以 8 对齐, 需要参考手册, 并提前对图片进行裁剪补齐

结语

这里只是简单介绍了如何驱动这块价签, 以及如何实现局部刷新和灰度显示. 其中若干技术可以混用, 例如局部刷新 + 灰度显示, 局部刷新 + 三色显示等等. 屏幕也可以在不同状态下重新初始化, 最终实现的效果, 取决于你的想象力.

原理有了, 剩下的就是点子了. 之前做了一个 Bing Image Creator 加随机每日诗词的小工具. 效果还不错, 但一堆粗糙脚本未整理.

一些可能用到的技术再提一遍:

  • Context Aware Image Resizing(CAIR) - PhotoShop 或一些图形学算法库
  • Diffusion Dithering - ImageMagick / PhotoShop
  • 各种 Text to Image 模型
  • 我的 text-image 过程宏, 粗糙, 但支持各种图片加载, 字体抗锯齿渲染
  • Rust Embedded 我推荐 embassy 框架, 至少它够统一, 而且是 async-first 的
    • Rust embedded-graphics 提供了 Framebuffer API, 以及各种图形绘制 API
  • 本项目粗糙代码, 未整理 commit
]]>
andelf
使用 Rust 语言 Embassy 嵌入式框架实现 STM32WL LoRa 数据传输2023-01-23T04:00:00+00:002024-06-17T12:43:43+00:00https://andelf.github.io/blog/2023/01/23/stm32wl-lora-with-rust-embassy

2024-06 补充: 相关内容已经过期, 且 lora-rust 项目长达 1 年多是时间删去 CN470 支持后没有再加回来. 本文仅作为历史记录.

之前参与了 eet-china.com 的开发板测评活动, 申请的板子是 STM32WLE5 易智联 Lora 评估板(LM401-Pro-Kit), 正好 Rust Embassy 框架对 STM32WL 系列及其 SubGhz 有不错的支持, 所以打算用这套技术栈进行开发尝试.

本文主要介绍如何使用 Rust 语言的 Embassy 嵌入式框架实现 STM32WL LoRa 数据传输. 过年回老家, 随身带的东西不多, 只有一个迷你 BMP280 (大气压温度)传感器模块, 所以本文使用 BMP280 传感器数据作为例子.

门槛率高, 还是从点灯开始搞起.

最终相关代码位于 Github: andelf/lm401-pro-kit.

介绍

快递于 [[2023-01-08]] 收到, 里面的评估板, 天线, 数据线均是两份, 方便开发使用.

开发板介绍

LM401-Pro-Kit 是基于 STM32WLE5CBU6 的 Lora 评估板. 支持 SubGHz 无线传输. LM401 模组内嵌高性能 MCU 芯片 STM32WLE5CBU6, 芯片内部集成了 SX1262. 开发板板载 ST-Link(上传下载程序, UART 转 USB). ST-Link 通过跳线帽和模块核心部分连接, 方便单独供电使用模块. 开发板提供了若干 LED 状态灯, 复位按钮和一个用户按钮.

日常屯的(吃灰)板子也有大几十上百了, 拿到新板子, 需要查资料, 看手册, 电路图, 读例程, 找到一些核心信息, 其中一些信息可能需要读例程的 C 代码库才能获得, 这里列出整理的部分:

  • MCU: STM32WLE5CBU6
    • 架构: Cortex-M4
    • 主频: 48MHz, 通过 MSI 提供
    • FLASH 128K,RAM 48K
    • 核心外设: SX1262 via SPI3
  • LM401: CN470-510MHZ
  • 板载
    • ST-Link 下载器
    • 用户按钮 PA0
    • LED blue PB5, green PB4, yellow PB3
    • 射频开关:
      • FE_CTRL1 PB0
      • FE_CTRL2 PA15, LM401 未使用
      • FE_CTRL3 PA8
    • UART RX/TX
      • PA2 TXD, USART2_TX, LPUART1_TX
      • PA3 RXD, USART2_RX, LPUART1_RX

预备知识

  • 对 Rust 的基础了解
  • 对 STM32 的基础了解

使用 Rust 嵌入式开发大概大概有如下几层(只是粗略分类, 实际项目使用中, 可能会混合使用):

  • 直接使用 PAC 库操作寄存器, PAC 库通过 svd2rust 工具从 .svd 文件生成
  • 使用 HAL 库, 例如 stm32f4xx-hal, stm32l0xx-hal, stm32wlxx-hal 等, 融合 embedded-hal 生态
  • 使用 Rust 嵌入式框架, 例如 embassy

Embassy 框架是基于 Rust 语言的嵌入式异步框架. 考虑到相关框架还在开发中, 本文的代码仓库使用的是最新的 embassy master 分支. Commit hash 为 f98ba4ebac192e81c46933c0dc1dfb2d907cd532, 通过 Cargo.toml 中设置依赖 path 的方式引入. 其他可选方案还可有 git submodule 或 直接 git 依赖远程版本等.

绕开 C HAL/BSP 库开发, 是需要踩不少坑的, 例如, RCC 时钟初始化, 需要查阅 BSP 代码才能确认, 48MHz 主时钟通过 MSI range11 获得, 而 embassy 对应 MCU 的示例代码使用的是 HSE, 这些都给 Rust 嵌入式开发带来一定的门槛.

软件环境准备

安装 Rust 工具链, 本文使用 rustup nightly. 请参考 https://rustup.rs/ .

安装 Rust thumbv7em-none-eabi target, 对应 Cortex-M4:

rustup target add thumbv7em-none-eabi

安装 Rust 嵌入式开发烧录/运行工具 probe-run, 也可以使用 OpenOCD 或其他烧录工具:

> cargo install probe-run
...(install log)
> probe-run --list-chips | grep STM32WL
STM32WL Series
        STM32WLE5J8Ix
        STM32WLE5JBIx
        STM32WLE5JCIx
        STM32WL55JCIx

检查发现支持列表没有 STM32WLE5CBU6, 不过可以拿 STM32WLE5JCIx 替代, 问题不大.

安装任意串口调试工具, 这里我使用 picocom. 其他可以使用的替代有 PuTTy, Teraterm 等等.

通过 USB 数据线连接开发板, 通过 picocom 连接串口, 通过 probe-run 烧录程序.

测试连接

> lsusb
Bus 001 Device 008: ID 0483:374b STMicroelectronics STM32 STLink  Serial: xxxx

从 Blinky 开始 Embassy 应用开发

考虑到从初识 Rust 嵌入式开发直接跨越到 LoRa 无线传输门槛较高, 我们从简单的点灯例子开始:

创建项目 - 初始化 Rust 嵌入式项目模板

我们直接依赖 embassy 的 master 分支进行开发, 为方便调试, 直接 clone 到本地用相对路径引入依赖:

git clone [email protected]:embassy-rs/embassy.git
# or
git clone https://github.com/embassy-rs/embassy.git

# 在同层目录直接创建我们的项目, 起板子名就可以. 相当于一个 BSP 模板可以扩充

cargo new --lib lm401-pro-kit

# 进入项目目录, 以下命令均在此执行
cd lm401-pro-kit

Rust 嵌入式项目的初始设置需要请参考项目代码

  • .cargo/config.toml
    • 设置编译器 target 到 thumbv7em-none-eabi
    • 设置 cargo run 的执行方式为调用 probe-run ...
    • [target.'cfg(all(target_arch = "arm", target_os = "none"))']
      runner = "probe-run --chip STM32WLE5JCIx"
      
      [build]
      target = "thumbv7em-none-eabi"
      
  • build.rs
    • 设置 link.x/memory.x 链接过程中所用配置, 编译过程中由 embassy 自动按照芯片选择生成
    • 添加 defmt 链接参数支持
  • Cargo.toml
    • 添加 embassy 相关依赖, 并通过 features 设置相关参数
    • 添加项目依赖, defmt, cortex-m 相关等
    • 设置编译参数 opt-level = "z", 最小化编译二进制大小
    • # part of Cargo.toml
      [dependencies]
      # ...
      embassy-stm32 = { version = "0.1.0", path = "../embassy/embassy-stm32", features = [
          "nightly",
          "defmt",
          "stm32wle5cb",
          "time-driver-any",
          "memory-x",
          "subghz",
          "unstable-pac",
          "exti",
      ] }
      # ...
      [profile.dev]
      opt-level = "z" # Optimize for size.
      
      [profile.release]
      lto = true
      opt-level = "z" # Optimize for size.
      
    • defmt 是一个非常好用的 Rust 嵌入式调试打印, 对 STM32(ST-Link) 有很好的支持.
    • stm32wle5cb 用于选择 STM32WLE5CBU6 的芯片配置, subghz 用于选择 SubGHz 驱动.
    • memory-x 自动生成链接所需的 memory.x 文件(FLASH, SRAM 的大小和内存位置).
  • 未避免编译报错, 还需要清空 src/lib.rs 项目初始文件, 用 #![no_std] 替代

几乎所有的 Rust 嵌入式项目都是 no_std 的, 这意味着无法简单地使用所有带内存分配类型. 本例中, 我们使用 heapless crate 中提供的栈分配类型来替代 String.

注意到, 创建项目时候使用了 cargo new --lib, 相当于我们创建的是一个 library 项目. 这不需要担心, cargo run 会自动识别 src/bin/xxx.rs 为 “可执行” 二进制目标. 通过 cargo run --bin xxx 即可运行对应程序. 也可以通过 examples/xxx.rs 的方法管理多个可执行二进制目标.

Blinky 点灯 - 初识 Rust Embassy

我们先通过一个最简单的闪灯例子来熟悉 Rust Embassy 的使用. 创建 src/bin/blinky.rs.

// blinky.rs
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::{Duration, Timer};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    info!("Hello World!");

    let mut led = Output::new(p.PB4, Level::High, Speed::Low);

    loop {
        info!("high");
        led.set_high();
        Timer::after(Duration::from_millis(1000)).await;

        info!("low");
        led.set_low();
        Timer::after(Duration::from_millis(1000)).await;
    }
}

#![no_main] 用于告诉 Rust 编译器, 我们不使用 Rust 提供的 main 函数做程序入口. #[embassy_executor::main] 是一个宏, 用于包装 async fn main() 函数, 由 embassy-executor 提供了一个 futures runtime, 所以可以使用 asyncawait 语法. 底层实现中, .await 通过 STM32 的 WFE/SEV 等待指令和中断唤醒指令实现, 实现了程序逻辑在等待时候的低功耗. Spawner 还可以用来启动其他 async fn 函数, 实现了多任务的功能.

#![feature(type_alias_impl_trait)] 在 embassy 中被广泛使用, 需要开启. Embassy 中经常能看到形如 irq: impl Peripheral<P = T::Interrupt> + 'd 的类型签名.

let p = embassy_stm32::init(Default::default()); 直接初始化了所有的外设, 并返回一个 Peripherals 对象. 通过 Rust 的 move 语义保证不同外设使用之间不会出现竞争.

let mut led = Output::new(p.PB4, Level::High, Speed::Low); 创建了一个 Output 对象, 用于控制 PB4 引脚. Output 对象是一个 Pin 的 trait, 通过 set_highset_low 方法可以控制引脚电平. 这里会自动完成对 GPIOB PB4 的所有初始化和设置, 包括外设时钟使能, 状态设置等.

info!, warn! 等都是 defmt 的宏, 用于通过 ST-Link 提供的 Debug 通道打印调试信息. 强烈推荐使用, 否则嵌入式开发中, 只能用串口打印信息.

Timer::after(Duration::from_millis(1000)).await 是一个异步等待 1 秒的方法, 通过 embassy-time crate 实现. 在 Cargo.toml 中的 time-driver-any feature 选择了任意可用 timer 实现, 默认是 TIM2, 由 embassy-stm32 提供给 embassy-time.

确保板子连接正常, 直接运行:

> cargo run --bin blinky
    Finished dev [optimized + debuginfo] target(s) in 0.32s
     Running `probe-run --chip STM32WLE5JCIx target/thumbv7em-none-eabi/debug/blinky`
(HOST) INFO  flashing program (14 pages / 14.00 KiB)
(HOST) INFO  success!
────────────────────────────────────────────────────────────────────────────────
0.000000 DEBUG rcc: Clocks { sys: Hertz(4000000), apb1: Hertz(4000000), apb1_tim: Hertz(4000000), apb2: Hertz(4000000), apb2_tim: Hertz(4000000), apb3: Hertz(4000000), ahb1: Hertz(4000000), ahb2: Hertz(4000000), ahb3: Hertz(4000000) }
└─ embassy_stm32::rcc::set_freqs @ ./embassy/embassy-stm32/src/fmt.rs:125
0.000113 INFO  Hello World!
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:14
0.000552 INFO  high
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:19
1.001157 INFO  low
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:23
2.001811 INFO  high
  • 二进制编译成功后, 由 probe-run 烧录到 MCU 并执行, 持续获取 defmt 打印信息
  • rcc: Clocks 调试时钟信息由 embassy-stm32embassy_stm32::rcc::set_freqs 打印
  • 所有 defmt 打印内容在 cargo run dev 模式下均附加了代码行, 非常方便
  • defmt 打印内容均带有时间戳, 该时间戳由 STM32 SYSTICK 提供(所以如果使用了 SYSTICK, 有可能导致时间戳异常)
  • 最终的 main 函数显示为 blinky::____embassy_main_task::{async_fn#0}, 由 #[embassy_executor::main] 宏生成

UART 打印 - 时钟和外设初始化

defmt 固然方便, 但很多时候依然需要用到 UART, 通过串口获取调试信息或收集数据. LM401-Pro-Kit 正好通过 ST-Link 提供了到 USART2 的访问.

Blinky 例子中, 由 defmt 调试信息可知, 我们使用的系统时钟只有 4MHz, 但 STM32WL 的最大时钟频率是 48MHz. 所以需要通过初始化 init() 方法设置时钟参数:

// sys clk init, with LSI support
let mut config = embassy_stm32::Config::default();
config.rcc.enable_lsi = true;
config.rcc.mux = embassy_stm32::rcc::ClockSrc::MSI(embassy_stm32::rcc::MSIRange::Range11); // 48MHz
let p = embassy_stm32::init(config);

Embassy UART 使用非常简单, 可以单独用 UartTx/UartRx 只初始发送/接收部分. 这里是一个发送 Hello world 和 MCU 内部 “时间” 的简单示例:

// USART2 tx
use embassy_stm32::dma::NoDma;
use embassy_stm32::usart::UartTx;
use embassy_time::Instant;
use heapless::String;

// Default: 115200 8N1
let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Default::default());

let mut msg: String<64> = String::new();
let i = Instant::now();
core::write!(msg, "Hello world, device time: {}\r\n", i.as_millis()).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();

UartTx 初始化时需要传入 USART2, PA2, 分别对应 USART2 外设和 TX 引脚, DMA 通道是可选的. 默认串口参数是 115200 8N1. 外设初始化会自动处理对应引脚的 AF 设置.

串口打印需要字符串拼接格式化, 由于 no_std, 标准库的 String 类型不可用, 这里使用 heapless::String, 初始化时候需要指定分配大小. core::write! 即标准库中的 write!, core:: 前缀是为了避免和 defmt::write! 名字冲突.

完整代码请参考 代码仓库.

执行代码确认, 可以看到系统时钟被正确设置为 48MHz.

> cargo run --bin uart
0.000000 DEBUG rcc: Clocks { sys: Hertz(48000000), apb1: Hertz(48000000), apb1_tim: Hertz(48000000), apb2: Hertz(48000000), apb2_tim: Hertz(48000000), apb3: Hertz(48000000), ahb1: Hertz(48000000), ahb2: Hertz(48000000), ahb3: Hertz(48000000) }
└─ embassy_stm32::rcc::set_freqs @ /Users/mono/Elec/embassy/embassy-stm32/src/fmt.rs:125
0.000011 INFO  Hello World!
└─ uart::____embassy_main_task::{async_fn#0} @ src/bin/uart.rs:21
0.000064 INFO  tick

在另一命令行打开串口监视工具, 查看串口输出:

> picocom -b 115200 /dev/tty.usbmodem11103
Hello world, device time: 1000
Hello world, device time: 3002
Hello world, device time: 5005
Hello world, device time: 7008
Hello world, device time: 9011
Hello world, device time: 11013
....

I2C 访问 BMP280

硬件准备

  • BMP280 传感器模块 1 个
  • 杜邦线若干根, 用于连接传感器模块和开发板

BMP280 是来自 Bosch 的气压传感器, 通过 I2C 接口读取气压和温度数据, 所以需要在板子上找到未被占用的 I2C SCL/SDA 引脚资源, 通过查阅芯片手册, 最后选择了空闲的 I2C2, SCL pin PA12, SDA pin PA11. 开发板上一排跳线帽正好提供了 VCC, GND.

接线:

+--------+          VCC GND
| BMP280 |           |   |
|      VCC>----------+   |
|      GND>--------------+
| [.]  SCL>-------------------->PA12
|      SDA>-------------------->PA11
|        |          (LM401-Pro-Kit)
+--------+

BMP280 访问

Rust Embassy 完美兼容 embedded-hal 相关生态, 相关外设类型均支持对应的 embedded-hal trait,

考虑到 BMP280 的使用略微复杂, 需要初始化, 读取校准数据, 测量后还需要通过校准数据计算最终测量结果. 所以 BMP280 直接寻找对应驱动即可. 但 Rust 嵌入式生态有个问题, 弃坑项目太多. 寻找第三方依赖时候需要注意阅读代码, 查看依赖版本, 必要时更新.

这么说, 其实是之前我有个弃坑项目里面有个 BME280 驱动库, BME280 和 BMP280 基本兼容, 只是多了湿度测量. 驱动代码使用 embedded-hal 提供的 trait 类型访问设备, 完成传感器初始化和测量. 稍微改了改, 直接 Copy embedded-drivers: bme280.rs 到项目 src/ 下使用即可.

修改 src/lib.rs 增加:

pub mod bme280;

代码实现

创建 BMP280 传感器项目 src/bin/i2c-bmp280.rs. 完整代码请参考 代码仓库, 以下只选择关键部分介绍.

// BMP280 init
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::Hertz;
use embassy_time::Delay;
use lm401_pro_kit::bme280::BME280;

let irq = interrupt::take!(I2C2_EV);
let i2c = I2c::new(
    p.I2C2,
    p.PA12,
    p.PA11,
    irq,
    NoDma,
    NoDma,
    Hertz(100_000),
    Default::default(),
);

let mut delay = Delay;

let mut bmp280 = BME280::new_primary(i2c);
unwrap!(bmp280.init(&mut delay));

Embassy 中访问设备时, 一般会需要中断, 虽然理论上阻塞访问外设时不需要中断. 但是为了保证接口的一致性, 一般都会要求提供中断参数. interrupt::take! 用于获取对应中断对象.

BME280::new_primary 直接使用设备主地址 0x76 访问 I2C 总线上的 BMP280.

初始化设备时候由于需要软复位, 需要传递 Delay 对象, 用于延时(delay_ms). 默认的 embassy_time::Delay 使用循环比较 “设备当前时间” 的方法实现.

unwrap! 宏由 defmt 提供, 等价于 .unwrap() 调用, 但是会在 panic 时候通过 defmt 打印信息.

完成设备初始化后, 可以访问传感器信息:

let raw = unwrap!(bmp280.measure(&mut delay));
info!("BMP280: {:?}", raw);

传感器执行测量时候, 按照手册, 依然需要延时, 所以也同样需要传递 Delay 对象. BME280::measure 方法返回 Measurements 类型, 为了方便调试使用, 用 derive macro 增加了 defmt 支持, 可以直接做格式化参数:

#[derive(Debug, defmt::Format)]
pub struct Measurements {
    /// temperature in degrees celsius
    pub temperature: f32,
    /// pressure in pascals
    pub pressure: f32,
    /// percent relative humidity (`0` with BMP280)
    pub humidity: f32,
}

执行代码:

> cargo run --bin i2c-bmp280
0.000011 INFO  I2C BMP280 demo!
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:23
0.009314 INFO  measure tick
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:45
0.051652 INFO  BMP280: Measurements { temperature: 23.689554, pressure: 88391.13, humidity: 0.0 }
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:48

temperature: 23.689554, pressure: 88391.13 传感器数据正常.

LoRa 传感器数据传输

LoRa 是一种无线传输协议, 适合长距离(km), 少量数据传输. 尤其适合传感器数据. 因为手头没有 LoRaWAN 基站, 所以暂时没法测试 LoRaWAN. 这里使用 LoRa 调制模式点对点传输 BMP280 传感器数据.

详细实现请参考 代码仓库 里的 src/bin/subghz-bmp280-tx.rssrc/bin/subghz-bmp280-rx.rs.

硬件准备

LM401-Pro-Kit x2, 天线, 数据线.

其中一个开发板作为传感器采集端, 按照上一示例链接到 BMP280 传感器模块, 另一个作为接收端, 两个开发板之间通过 LoRa 无线传输数据. 接收端通过 UART 与电脑连接, 通过串口调试工具查看传感器数据.(实际上也可以直接通过 ST-Link + defmt 获取数据)

射频开关 RadioSwitch

使用开发板射频功能, 需要处理射频开关逻辑. 相关逻辑从 BSP C 代码获得. 可以直接作为 BSP 的工具类型, 写入到 src/lib.rs 中:

use embassy_stm32::{
    gpio::{AnyPin, Level, Output, Pin, Speed},
    peripherals::{PA15, PA8, PB0},
};

pub struct RadioSwitch<'a> {
    ctrl1: Output<'a, AnyPin>,
    ctrl2: Output<'a, AnyPin>,
    ctrl3: Output<'a, AnyPin>,
}
impl<'a> RadioSwitch<'a> {
    pub fn new_from_pins(ctrl1: PB0, ctrl2: PA15, ctrl3: PA8) -> Self {
        Self {
            ctrl1: Output::new(ctrl1.degrade(), Level::Low, Speed::VeryHigh),
            ctrl2: Output::new(ctrl2.degrade(), Level::Low, Speed::VeryHigh),
            ctrl3: Output::new(ctrl3.degrade(), Level::Low, Speed::VeryHigh),
        }
    }
    pub fn new(
        ctrl1: Output<'a, AnyPin>,
        ctrl2: Output<'a, AnyPin>,
        ctrl3: Output<'a, AnyPin>,
    ) -> Self {
        Self {
            ctrl1,
            ctrl2,
            ctrl3,
        }
    }
    pub fn set_off(&mut self) {
        self.ctrl3.set_low();
        self.ctrl1.set_low();
        self.ctrl2.set_low();
    }
}
impl<'a> embassy_lora::stm32wl::RadioSwitch for RadioSwitch<'a> {
    fn set_rx(&mut self) {
        self.ctrl3.set_low();
        self.ctrl1.set_high();
        self.ctrl2.set_low();
    }

    fn set_tx(&mut self) {
        self.ctrl3.set_high();
        self.ctrl1.set_low();
        self.ctrl2.set_low();
    }
}

非常简单的 GPIO 操作, GPIO 的强类型 PAn/PBn/.. 可以通过 .degrade() 方法转换为 AnyPin 类型, 方便使用.

let mut rfs = lm401_pro_kit::RadioSwitch::new_from_pins(p.PB0, p.PA15, p.PA8);

LoRa 数据报文定义

为简单展示, 传感器节点只负责发送, 接受节点只接受 LoRa 报文, 不回传 ACK 信号.

报文格式为 24 字节:

设备地址 设备时间戳 温度 大气压 checksum
b”MM” u32 u64 f32 f32 u16

其中设备地址使用 STM32 系列的 chip id 实现, 保证一定的唯一性:

// Device ID in STM32L4/STM32WL microcontrollers
pub fn chip_id() -> [u32; 3] {
    unsafe {
        [
            core::ptr::read_volatile(0x1FFF7590 as *const u32),
            core::ptr::read_volatile(0x1FFF7594 as *const u32),
            core::ptr::read_volatile(0x1FFF7598 as *const u32),
        ]
    }
}

let chip_id = chip_id();
let dev_addr = chip_id[0] ^ chip_id[1] ^ chip_id[2];

设备时间戳直接读取 Instant::now() 并转为 millis. 保证每个数据报文的差异性. checksum 校验和字段通过计算 [2..22] 所有字节之和得到. 所有数据字段均按照大端序列化(BigEndian).

SubGhz 初始化

LM401 的射频功能由 STM32WLE5 内置的 SX1262 提供, 设备内部通过 SPI3(SUBGHZSPI) 访问. SX1262 初始化需要较多参数, 且发送端接收端若干参数需要一致.

这里选用 490.500MHz, LoRa SF7, 4/5 编码率, 125kHz 带宽, 24 字节数据长度. 接收端和发送端设置一致.

参数定义:

use embassy_stm32::subghz::*;

const DATA_LEN: u8 = 24_u8;
const PREAMBLE_LEN: u16 = 0x8 * 4;

const RF_FREQ: RfFreq = RfFreq::from_frequency(490_500_000);

const TX_BUF_OFFSET: u8 = 128;
const RX_BUF_OFFSET: u8 = 0;
const LORA_PACKET_PARAMS: LoRaPacketParams = LoRaPacketParams::new()
    .set_crc_en(true)
    .set_preamble_len(PREAMBLE_LEN)
    .set_payload_len(DATA_LEN)
    .set_invert_iq(false)
    .set_header_type(HeaderType::Fixed);

// SF7, Bandwidth 125 kHz, 4/5 coding rate, low data rate optimization
const LORA_MOD_PARAMS: LoRaModParams = LoRaModParams::new()
    .set_bw(LoRaBandwidth::Bw125)
    .set_cr(CodingRate::Cr45)
    .set_ldro_en(true)
    .set_sf(SpreadingFactor::Sf7);

// see table 35 "PA optimal setting and operating modes"
const PA_CONFIG: PaConfig = PaConfig::new()
    .set_pa_duty_cycle(0x4)
    .set_hp_max(0x7)
    .set_pa(PaSel::Hp);

const TX_PARAMS: TxParams = TxParams::new()
    .set_power(0x16) // +22dB
    .set_ramp_time(RampTime::Micros200);

设备初始化, 部分内容从 BSP C 代码转换得到:

let mut radio = SubGhz::new(p.SUBGHZSPI, NoDma, NoDma);

// from demo code: Radio_SMPS_Set
unwrap!(radio.set_smps_clock_det_en(true));
unwrap!(radio.set_smps_drv(SmpsDrv::Milli40));

unwrap!(radio.set_standby(StandbyClk::Rc));

// in XO mode, set internal capacitor (from 0x00 to 0x2F starting 11.2pF with 0.47pF steps)
unwrap!(radio.set_hse_in_trim(HseTrim::from_raw(0x20)));
unwrap!(radio.set_hse_out_trim(HseTrim::from_raw(0x20)));

unwrap!(radio.set_regulator_mode(RegMode::Smps)); // Use DCDC

unwrap!(radio.set_buffer_base_address(TX_BUF_OFFSET, RX_BUF_OFFSET));

unwrap!(radio.set_pa_config(&PA_CONFIG));
unwrap!(radio.set_pa_ocp(Ocp::Max60m)); // current max
unwrap!(radio.set_tx_params(&TX_PARAMS));

unwrap!(radio.set_packet_type(PacketType::LoRa));
unwrap!(radio.set_lora_sync_word(LoRaSyncWord::Public));
unwrap!(radio.set_lora_mod_params(&LORA_MOD_PARAMS));
unwrap!(radio.set_lora_packet_params(&LORA_PACKET_PARAMS));
unwrap!(radio.calibrate_image(CalibrateImage::ISM_470_510));
unwrap!(radio.set_rf_frequency(&RF_FREQ));

中断信号量处理, 由于发送接收循环需要涉及到中断处理, 这里直接用 Signal 类型的信号量处理中断:

use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;

static IRQ_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
let radio_irq = interrupt::take!(SUBGHZ_RADIO);
radio_irq.set_handler(|_| {
    IRQ_SIGNAL.signal(());
    unsafe { interrupt::SUBGHZ_RADIO::steal() }.disable();
});

这样, 在 async fn main() 中使用 IRQ_SIGNAL.wait().await 就可以随时等待中断信号量.

SubGhz 发送端

首先拼接报文, 这里直接手动拼接组合字节:

let mut payload = [0u8; 24];
let now = Instant::now();
let measurements = unwrap!(bmp280.measure(&mut delay));

payload[0] = b'M';
payload[1] = b'M';

payload[2..6].copy_from_slice(dev_addr.to_be_bytes().as_slice());
payload[6..14].copy_from_slice(now.as_millis().to_be_bytes().as_slice());
payload[14..18].copy_from_slice(measurements.temperature.to_be_bytes().as_slice());
payload[18..22].copy_from_slice(measurements.pressure.to_be_bytes().as_slice());
let checksum = payload[2..22]
    .iter()
    .fold(0u16, |acc, x| acc.wrapping_add(*x as u16));
info!("checksum: {:04x}", checksum);
payload[22..24].copy_from_slice(checksum.to_be_bytes().as_slice());

然后开始发送:

rfs.set_tx();
unwrap!(radio.set_irq_cfg(&CfgIrq::new().irq_enable_all(Irq::TxDone)));
unwrap!(radio.write_buffer(TX_BUF_OFFSET, &payload[..]));
unwrap!(radio.set_tx(Timeout::DISABLED));

radio_irq.enable();
IRQ_SIGNAL.wait().await;
rfs.set_off();

let (_, irq_status) = unwrap!(radio.irq_status());
if irq_status & Irq::TxDone.mask() != 0 {
    defmt::info!("TX done");
}
unwrap!(radio.clear_irq_status(irq_status));

总结起来发送过程需要如下步骤:

  • 打开射频发送开关
  • 设置中断, 开启 TxDone
  • 写入数据 buffer
  • 开始发送, 不使用 Timeout
  • 开启中断
  • 等待中断信号量
  • 关闭射频开关
  • 检查中断状态
  • 清理中断状态

SubGhz 接收端

这里是接收端逻辑, src/bin/subghz-bmp280-rx.rs, 其中配置部分和发送端相同:

let mut buf = [0u8; 256];

rfs.set_rx();
unwrap!(radio.set_irq_cfg(
    &CfgIrq::new()
        .irq_enable_all(Irq::RxDone)
        .irq_enable_all(Irq::Timeout)
        .irq_enable_all(Irq::Err)
));
unwrap!(radio.read_buffer(RX_BUF_OFFSET, &mut buf));
unwrap!(radio.set_rx(Timeout::from_duration_sat(Duration::from_millis(5000))));

radio_irq.unpend();
radio_irq.enable();

IRQ_SIGNAL.wait().await;
led_rx.set_low();
let (_, irq_status) = unwrap!(radio.irq_status());
unwrap!(radio.clear_irq_status(irq_status));

if irq_status & Irq::RxDone.mask() != 0 {
    let (_st, len, offset) = unwrap!(radio.rx_buffer_status());
    let packet_status = unwrap!(radio.lora_packet_status());
    let rssi = packet_status.rssi_pkt().to_integer();
    let snr = packet_status.snr_pkt().to_integer();
    info!(
        "RX done: rssi={}dBm snr={}dB len={} offset={}",
        rssi, snr, len, offset
    );
    let payload = &buf[offset as usize..offset as usize + len as usize];
    // Parse payload here
}

发送步骤如下:

  • 打开射频接收开关
  • 设置中断, 开启 RxDone, Timeout, Err
  • 设置读入 buffer
  • 开始接收, 这里使用 Timeout 5 秒
  • 清理未处理中断状态, 否则会有观察到空中断
  • 开启中断
  • 等待中断信号量
  • 检查中断状态, 清理中断状态
  • 通过 rx_buffer_status 获取 buffer 状态
  • 通过 lora_packet_status 获取报文 rssi, snr 信息

运行结果

发送端上电之后, 每2秒采集一次传感器数据并发送.

接收端上电之后, 持续接收数据并同时打印在 defmt 调试和串口输出.

> cargo run --bin subghz-bmp280-rx --release
1.226162 INFO  begin rx...
3.292868 INFO  RX done: rssi=-42dBm snr=14dB len=24 offset=0
3.292969 DEBUG got BMP280 node raw=[0x4d, 0x4d, 0x72, 0x2e, 0x67, 0x28, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x58, 0x3a, 0x41, 0xad, 0x10, 0xa2, 0x47, 0xac, 0x8c, 0x2a, 0x5, 0xa]
3.293173 INFO  dev addr=722e6728 dev tick=22586 temp=21.633121'C pressure=883.4433hPa
3.299479 INFO  stats: Stats { status: Status { mode: Ok(StandbyRc), cmd: Ok(Avaliable) }, pkt_rx: 2, pkt_crc: 0, pkt_len_or_hdr_err: 0, ty: LoRaStats }
3.299622 INFO  begin rx...

串口输出, CSV 格式:

> picocom -b 115200 /dev/tty.usbmodem11203
addr=722e6728,rssi=-44,snr=14,temperature=16.304043,pressure=87621.96
addr=722e6728,rssi=-44,snr=14,temperature=16.306524,pressure=87621.96
addr=722e6728,rssi=-44,snr=13,temperature=16.309006,pressure=87621.83
addr=722e6728,rssi=-45,snr=13,temperature=16.311487,pressure=87621.81
addr=722e6728,rssi=-45,snr=13,temperature=16.313969,pressure=87621.66

总结

Rust Embassy 是一个非常好的嵌入式 Rust 开发框架, 通过它可以快速开发嵌入式应用. Rust Embassy 把 async, await 关键字带到了 Rust 嵌入式开发中, 其还有丰富的多任务支持, 多种同步元语支持. 通过它们, 我们可以很方便的开发多任务应用.

但它依然是一个很早期的框架, 还不够完善, 例如目前在 STM32WL 上缺乏 ADC 支持. 文档不够丰富, 部分库函数会随着开发进度有所变更, 给维护项目带来不小的困难.

在开发过程中, 往往能看到 move 语义, ownership, 类型系统等 Rust 的特性, 虽然这些特性在嵌入式开发中并不是必须的, 但是它们确实能带来更好的开发体验. 例如 move/borrow 保证对设备资源的唯一访问所有权. 通过类型安全的寄存器类型访问避免 C 语言中错误的寄存器访问, 经过 Rust 编译器优化后, 和 C 中的 bit mask 写法是等价的. 通过 “associated types” 保证设备和对应引脚的状态匹配.

Rust Embassy 隐藏了大部分嵌入式设备细节, 开发者不需要过多的关注设备初始化细节, 应用代码短小.

实际使用过程中, 也遇到了一些坑, 例如在写一个 PWM 例子时候, embassy_time::Delay 怎么都不工作, 添加了若干 debug 打印之后才发现, embassy_time::Delay 内部使用 embassy_time::Instant 实现, 默认情况下会使用 TIM2. 而选择的 PWM 输出 pin 正好是 TIM2_CH2, 两者互相干扰, 导致 Delay 不工作. 目前类型系统还不能保证 DelayPwm 不会使用同一个 TIM 设备. 最终的解决方法是使用 cortex_m::delay::Delay, 这是一个基于 SYSTICK 的实现.

本位未介绍 Embassy 的多任务功能, 在代码仓库里有一个简单的按钮控制闪灯频率的例子 src/bin/button-control-blinky.rs. 多任务的时候需要有 .await 调用让出时间片.

参考资料

]]>
andelf
施阿姨的互联网 - Interview with Aunt Shi2021-02-04T12:03:00+00:002023-01-23T16:59:59+00:00https://andelf.github.io/blog/2021/02/04/interview-with-aunt-shi这是几年前田野调查时候的一篇访谈稿。最近的聊天室热,让我想起了这个有意思的阿姨。

在一个小村子的路口小卖店,看到一位略残疾腿脚不方便的阿姨极其熟练地单指打字聊天,操作着在线聊天室服务端软件, 同时管理虚拟摄像头播放的擦边球女主播视频。可想当时我有多震惊。于是有了这遍访谈。

正文

title: 施阿姨的互联网
时间: 7月22日 下午
地点: XQ村村口小卖店
访谈人: Mono
被访谈人: 施阿姨
整理汇总: Mono
整理时间: 7/26
版本: v3

施阿姨。XQ村路口小卖店老板,56岁,似乎残疾。中午买水时见到她对着两台电脑的屏幕熟练操作,很是惊异,于是下午去做问卷和访谈。

访谈时,他的老公刚要外出。她卧坐在椅子上,眼睛时不时回头看两台电脑的屏幕(一台台式机,一台笔记本,均WinXP操作系统),打开的是视频聊天室的应用程序,能看到长长的在线用户列表和正在直播的视频窗口,她时不时用右手单手中指打出一句话和聊天室的网友互动,打字速度还挺快。每隔一会她还切换到一个类似老虎机到网络赌博程序,去查看状态。

访谈中了解到,施阿姨今年56岁,村里的普通农户。她弟弟在上海市的一家医院里工作,收入还可以,各方面也有一定的人脉,给施阿姨棒过不少忙。施阿姨的丈夫今年59岁,似乎不太会说话,不在家里做主的样子,没几分钟就出门去村北的蔬菜大棚里做工。丈夫的父母都已去世,施阿姨的母亲还健在,每天在家里给施阿姨和她老公做饭,平日里他们两口回家时间都略晚。施阿姨的儿子在上海上班。

早在80年代末,施阿姨就开始做裁缝,用缝纫机帮人加工衣服,她自豪地说,最多时候一年能有一万多收入,96年家里建的3层楼房就是用自己做衣服的钱盖的。但是95年后,人们的消费习惯逐渐发生变化,更多直接购买品牌衣服而不是买布料找裁缝加工,施阿姨的裁缝生意一天不如一天。

这时候,施阿姨借用了亲戚家在村路口的地,盖了一间屋子做小卖店,当时卖香烟很赚钱,但香烟需要烟草局的许可,施阿姨等了很久也没消息,一两年后听说上海市区比她晚申请烟草专卖许可的都办了下来,就托弟弟去崇明县烟草局去质问,之后才办了下来,但这时候烟草利润已经没有当年高,生意依旧一般。近几年施阿姨的小卖店每年大概有6000左右的收入。

同时在95年左右,施阿姨的股骨头出了问题,具体哪年做的置换已记不得,右腿(猜是)无法抬起正常走路,残疾人助力车停在小卖店里。阿姨说她当时申请残疾证也是费了好大周折,最后不得已通过弟弟出面,找人花了一万多才办了下来。谈到此,施阿姨感叹说,其实上面政府的政策是好的,残疾(人)补助不少,民政部门每月还发粮油,但到了下面,就不为老百姓考虑了,明明家里有钱开着车天天浪的人办下来了残疾证,真正残疾的人却办不下来。

施阿姨的互联网+,要从她弟弟说起,她弟弟家境宽裕,早在九十年代就置办了电脑,后来因更新换代,把旧电脑淘汰给了她,而她只有初中文化水平,且不会普通话(打字拼音完全不会),所以电脑就放在自己的小卖店里,供自己儿子使用。

后来村里有小孩看到她这里有电脑,就来玩,给她按小时计费,在她的电脑上装了很多游戏,也因此电脑经常中病毒出故障。那时候相当于只有一台电脑的黑网吧。后来施阿姨觉得对这些小孩的家长有愧,同时电脑也经常坏,就停下了黑网吧的生意。儿子后来出去上学,也用不到电脑,所以电脑就闲置了一段时间。(2000年左右)

而后,村里有一个在中学教书的王老师,看到施阿姨家里有电脑能上网,就给施阿姨介绍了QQ,教她网上聊天。一开始施阿姨不会打字,就用语音或者半天打一个字(因为普通话不会,所以拼音输入往往找不到字),同时也买了摄像头。

施阿姨是一个很爱学习东西的人。为了克服打字困难,她找来自己的外甥,将常用字和拼音写下来,挂在电脑背面的墙上,每天对着学习。没过几月,王老师再和施阿姨聊天,惊讶于施阿姨流畅的打字速度(虽然只是用一个指头敲键),敬佩不已。

此外,施阿姨的电脑每次出故障或是有问题,都是邻居家一个学电脑的小伙子解决的,小伙子很热心,说阿姨你会用就行了,这些我给你修。但是阿姨不愿意,比如装系统的时候,就要看着,一步步把步骤记下来,自己摸索。施阿姨还说,自己还会拆装电脑,电脑不亮的时候,什么显卡内存啊都拆了下来然后擦擦灰装起来就能好。(我当时听到这些也是惊到了)

07、08年股市疯涨的时候,施阿姨在弟弟的鼓动下,学起了炒股,当时也是每天盯K线,她以试探的心态投了2万,最多的时候涨到了6、7万,但后来被套牢。也就再没去折腾炒股。

说起视频聊天室,施阿姨说是一开始因为聊天打字太慢,语音视频聊天更方便,所以买了摄像头、耳机话筒。后来QQ上有一个网友,上海的,让她下载个软件,说带她去视频聊天室玩玩,她觉得很新鲜。一开始,都是别人教她怎么下载,注册,进入聊天室。在聊天室里可以排麦(聊天室同时一般只有很少的人可以语音,所以其他人需要按顺序排麦克风,由聊天室房主负责管理),唱歌,送花(虚拟道具)等等。最好玩的时候,一晚上她们一帮人能唱三四十首歌。

后来施阿姨就自己一个去各种聊天室逛,也逐渐萌生了自己做房主开聊天室的念头。她在网上聊天室里认识了一个71岁的老阿姨,在这方面是高手,经验丰富,这位老阿姨教施阿姨申请到了聊天室房间(需要一笔钱,才能拥有自己的房间),还协助施阿姨配置了新的电脑(视频聊天室需要转编码,需要性能稍微好的电脑)。

刚开始的时候,施阿姨的聊天室没多少人。而这类视频聊天室的模式是这样:网站提供视频直播服务器和视频直播软件,集中管理视频直播室的帐号和虚拟道具。虚拟道具即聊天室中用于相互赠送的鲜花、跑车等礼物。虚拟道具需要从网站的平台充值获取。每个聊天室都需要房主(在聊天室内被称为老大)来开启,房主需要向聊天室平台一次性缴纳费用,获得开聊天室的资格。聊天室中所有人的充值消费房主从中抽成,获得收益。部分视频聊天室还有在线赌博功能,虚拟货币流水更高。

视频聊天室往往具有集聚效应,即原本热闹的聊天室会更热闹。所以吸引参与者成了聊天室房主的首要任务。所以有的聊天室房主就会通过色情内容吸引成员,增加用户量。而施阿姨一开始是让老阿姨帮忙“挂聊天室”,赚取人气。所谓“挂聊天室”其实是用“马甲”帐号,从网上下载漂亮女主播的视频片段,然后通过模拟摄像头软件,在自己的聊天室内虚拟出一个漂亮的女主播,同时用帐号和房间内的用户互动,拉高人气。

那位71岁的老阿姨在这方面更有经验,帮施阿姨挂没多久后,聊天室的人气就来了,每天高峰大概有几百人在线。施阿姨也能通过道具充值提成获取一些收益。但施阿姨说,她和别人不一样,她获得到的金币(其中某平台,100万金币能换60元人民币)最后又通过各种方式返给了聊天室的参与者,发发小礼物等等,所以她的聊天室人气不错,大家都觉得她是个实在人,都叫她老大,或者老阿姨。

这时候来看,施阿姨更多把这样一个打监管擦边球的、具有获利性质的网络赌博平台当作了自己另一个社交圈子交流的工具。她在网上会和各种年龄段的人闲聊,比如和同时残疾人的网友聊聊政府补助的事情一类。

但较早较广接触网络,明显让施阿姨有了更大的眼界。谈起网络色情,阿姨说,有些小伙子来问她聊天室有没有不穿衣服女人那种视频,她说,都是人身上长得有什么好看的。谈起网络病毒,阿姨讲了她的一次经历,曾经有网友给她发了个文件,叫聚会照片,她打开后,桌面就弹出一个披长头发流血的女人照片,当时把她吓得半死,而且电脑之后也无法启动了。找来邻居家小伙子修好了电脑之后,她从此再也不接陌生人的文件,熟人也要看是不是本地的才会去看。

她家附近有个邻居也很好奇,但是人经常在上海住,想找机会让她教怎么开视频聊天室。而其他邻居对她的评价是,就知道买电脑玩电脑。

很多时候在考虑,互联网的即时、多媒体的社交方式到达农村到底会催生一种什么样的亚文化现象?例如前段时间很火爆的“快手”App,山东老阿姨吃日光灯管、年轻小伙子用鞭炮炸裤裆、残疾姑娘晒自己的打扮、搞怪装精神病的各种丑角、维修农机的小伙子自制各种新奇玩意、叠扑克牌、叠银币等等等等。实际上大部分是基于交流和“被注意到”的需求,当然也不排除部分也被一些小集团控制,编排节目,制作视频,炒作环节一应俱全的产业链。

(快手App已经是全国网络流量前几名的应用了,它的早期推广方式是和华为、酷派、步步高等国产手机在县城及以下的营销网点合作,直接预装。)

施阿姨也许只是孤例,纯流水帐记录,供大家思考。为何视频聊天室这种似乎已经在大中城市过气很久的互联网应用,却可以占有部分三四线城镇及农村的市场,得到生存。

参考:两个网站 ht tp: / /w ww.99 dzr.com/ 大自然娱乐、 ht tp:/ /w ww.78 90 xy.com/ 丰彩人生,还有个未知来源的游戏大厅,实际上是类似在线老虎机的样式,不停地转动切换下一个中奖的商标,可以看到下注量其实很高,单个商标下注在百万金币左右。网站均是未备案,注册地在二线城市。

]]>
猫·仁波切
Play with 2.13 inch E-Ink display2021-01-14T17:24:00+00:002021-01-22T07:05:30+00:00https://andelf.github.io/blog/2021/01/14/play-with-2-13-inch-e-ink-display故事从买屏幕说起。

无聊逛咸鱼,发现有便宜的电子墨水屏,这玩意正常价格大几十,而咸鱼一片 2.13 寸模块只需要 15 块钱人民币。

是的,还等啥,先来几片凑个包邮。

了解到之所以这么便宜,是因为实际上是拆机屏,拆的是电子价签。来自一茬又一茬倒闭的超市和便利店。

背景

电子墨水屏,也叫 E-ink, 墨水屏(瓶),电子纸,也简写为 EPD(Electronic Paper Display)。

和你用来压泡面的 Kindle 的屏幕是一个东西。只不过你的 Kindle 屏幕更大素质更高,而超市价签要小很多,相对低成本。

E-Ink technology

如上来自维基,原理一目了然。

不需要扯什么 gate 什么的,每个像素是一个小胶囊,顶部是公共 \(V_{com}\) 电压,透明。 底面是驱动芯片在屏幕每一行每一列像素的输出电压,可正可负(相对于 \(V_{com}\)),胶囊内部是对电场方向有反应的带电颜色微粒。 不同电压不同时长作用下,胶囊顶部上的微粒分布情况不同,肉眼看到的像素深浅就不同。

屏幕的驱动芯片,和我们常见的 IC 芯片那种黑色块带引脚是不同的,屏幕驱动芯片一般和屏幕一起封装, 对应屏幕的行列有输出。对外暴露接口,物理上一般是排线。

准备开搞

屏幕模块到手。显示“微雪电子”,那当然是不可能的,这只是因为默认用了微雪的示例代码出厂测试。 墨水屏的特点就是断电画面驻留,也可以说保持显示状态不需要供电。

Boards

合影是一只凑单的 STM32F4 板子(本例未用到),一只 ESP8266(就准备用它来驱动屏幕了),和屏幕模块(主角)。

一共需要八根线驱动。熟悉的 SPI + CS + DC 式,和多数 SPI 接口的 LCD / TFT-LCD 屏幕接口类似。

不同的是多了一个 BUSY pin,这是墨水屏特有的输出信号,表示屏幕正在刷新,其他操作需要 MCU 延后。

告知该模块兼容微雪 2.13 寸黑白屏幕 v1 版。参数如下:

  • 尺寸: 2.13 inch
  • 外形尺寸(裸屏):59.2mm × 29.2mm × 1.05mm
  • 显示尺寸:48.55mm × 23.71mm
  • 工作电压:3.3V/5V
  • 通信接口:SPI
  • 点距:0.194*0.194
  • 分辨率:250*122
  • 显示颜色:黑、白
  • 灰度等级:2
  • 局部刷新 :0.3s
  • 全局刷新 :2s
  • 刷新功耗 : 26.4mW(typ.)
  • 待机功耗 :<=0.017mW

查询模块手册,得知该显示屏驱动芯片是 IL3895, 来自 Good Display(大连佳显)。 屏幕排线上有型号 HINK-E0213A04-G01(HINK-E0213-G01).

ESP8266

ESP8266 算是较推荐的墨水屏之友,IO 接口不多但够用,带 WiFi 功能, 适合做这类显示屏小制作。 可以轻易找到各类天气时钟等代码。

推荐编程环境 Arduino. 省事。需要安装 ESP8266 Board Support 库.

我这里的 ESP8266 板子是一只 NodeMCU DevKit 兼容板。最普通不过,但比较麻烦的是它的管脚标签和标准的 ESP8266 GPIO 之间有一个映射关系。

NodeMCU GPIOs (ref: https://www.electronicwings.com/nodemcu/nodemcu-gpio-with-arduino-ide)

搞清楚映射关系,写代码就不会错了。

官方有出售专门的 ESP8266 驱动板,集成了 ESP8266, 只需要按照相同的接线,即可使用官方例程。

接线方式:

EPD board NodeMCU pin ESP8266 pin description
BUSY D1 GPIO5 屏幕刷新忙
RES/RST D4 2 复位
DC D2 4 Data/Command 信号
CS D8 15 片选
CLK/SCK D5 14 SPI 时钟
DIN/SDA D7 13 SPI MOSI

常识 VCC = 3.3V, GND 接地。

屏幕官方例程

虽然不是官方正版,只是个拆机屏模块,但好在屏幕型号一致,全兼容微雪官方例程。

官方例程解压后子目录复制到 Arduino libraries 目录。

然后就可以直接从 Arduino 的 File->Examples 菜单打开例程。

例程压缩包中的 src/, extras/ 目录,其实就是官方驱动,所有的不同型号屏幕的例程,都依赖驱动库。

该款屏幕的例程是 waveshare-e-Paper/epd2in13-demo.

设备选择 NodeMCU 或 Generic EPS8266, 编译上传例程。

屏幕噌噌闪动几下,清屏后,开始执行例程。

Official Demo

屏幕右下角的时间显示,秒位在不停变动。

例程中可以看到基础绘图,中英文数字显示,时间显示(局部刷新功能)。照着改改可以整出不少好玩的。 再加上 ESP8266 的 WiFi 功能,想象力足够。

End of get start

至此,屏幕跑通。画画图,改改文字,皆大欢喜。🤪


以下是干货部分。需要知识预备:

  • 二进制位运算知识
  • 数字电子基础
  • 电路基础
  • 计算机图形学基础概念

中文显示

看到例程中直接有中文显示语句,暗爽不是?

Paint_DrawString_CN(140, 60, "你好abc", &Font12CN, BLACK, WHITE);
Paint_DrawString_CN(5, 65, "**电子", &Font24CN, WHITE, BLACK);

于是改成“你好世界”,然而只见“你好”不见“世界”。

是的,官方例程(驱动)里没有完整字库,只有测试时候屏幕上出现的那几个汉字。所以需要加字库!

我们能接触到的绝大多数屏幕,都是点阵屏。 所谓字库,就是字符编码到图形象素点的映射。所以这里要增加缺失的字型,怎么整?

先看看官方怎么实现的。

当然,字库还有其他意思,在手机维修界,字库也指 ROM 芯片。 这是历史遗留问题了,当年字库都存在专门的芯片里。 字库和字库芯片在很多场合不区分。这里叫字库,其实是字模,即汉字的模型,对应的二进制数据。

官方驱动字库格式

找到驱动目录 ~/Documents/Arduino/libraries/esp8266-waveshare-epd.

src/ 目录下,找到若干 font*.cpp, font*.h 文件就是字库了。

例如 font12CN 字体,微软雅黑 12:

const CH_CN Font12CN_Table[] =
{
    /*--  文字:  你  --*/
    /*--  微软雅黑12;  此字体下对应的点阵为:宽x高=16x21   --*/
    {"你",
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1D,0xC0,0x1D,0x80,0x3B,0xFF,0x3B,0x07,
    0x3F,0x77,0x7E,0x76,0xF8,0x70,0xFB,0xFE,0xFB,0xFE,0x3F,0x77,0x3F,0x77,0x3E,0x73,
    0x38,0x70,0x38,0x70,0x3B,0xE0,0x00,0x00,0x00,0x00},
    // ...
    {"A",
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0E,0x00,0x1F,0x00,0x1F,0x00,
    0x1F,0x00,0x3B,0x80,0x3B,0x80,0x71,0x80,0x7F,0xC0,0x71,0xC0,0xE0,0xE0,0xE0,0xE0,
    0xE0,0xE0,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
};

cFONT Font12CN = {
  Font12CN_Table,
  sizeof(Font12CN_Table)/sizeof(CH_CN),  /*size of table*/
  11, /* ASCII Width */
  16, /* Width */
  21, /* Height */
};

明显看到中文字库分两部分,一部分是字型表 Font12CN_Table, 一部分是配置结构体 Font12CN. 字型表即字的象素点二进制表示。因为中文字符较多,为节省空间字库只有例程所需汉字, 所以每条记录第一个元素是中文汉字,用于索引。 而对于英文字库来说,字符是连续的,且总体占用空间较小,一般使用连续字节块表示。

从配置结构我们可以得知,该字型宽 16 位,高 21 位,即两个字节表示一行象素,一共 21 行。 我们可以写个脚本展示下:

char = [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1D,0xC0,0x1D,0x80,0x3B,0xFF,0x3B,0x07,
    0x3F,0x77,0x7E,0x76,0xF8,0x70,0xFB,0xFE,0xFB,0xFE,0x3F,0x77,0x3F,0x77,0x3E,0x73,
    0x38,0x70,0x38,0x70,0x3B,0xE0,0x00,0x00,0x00,0x00]
# group by 2 -> to 0-1 -> padding with 0
lines = ["%08d%08d" % (int(bin(l)[2:]), int(bin(r)[2:])) for (l,r) in zip(char[::2], char[1::2])]

print('\n'.join(lines).replace('0', '.').replace('1', '*'))

得到命令行下输出:

................
................
................
................
...***.***......
...***.**.......
..***.**********
..***.**.....***
..******.***.***
.******..***.**.
*****....***....
*****.*********.
*****.*********.
..******.***.***
..******.***.***
..*****..***..**
..***....***....
..***....***....
..***.*****.....
................
................

一个汉字被解析了出来,以点阵的方式展示。上方和下方的 0, 用于行间距。 在实际应用中可以省去,节约空间。

而配置中的 ASCII Width 11 表示该中文字体中的 ASCII 字符宽度。因为半角全角的关系, 中文字体中的半角英文字符(ASCII)相对来说宽度都不足一字,为了显示效果,不留过多字间距, 丢弃多余位不用,所以这里单独有一个配置项。

char = [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0E,0x00,0x1F,0x00,0x1F,0x00,
     ...:     0x1F,0x00,0x3B,0x80,0x3B,0x80,0x71,0x80,0x7F,0xC0,0x71,0xC0,0xE0,0xE0,0xE0,0xE0,
     ...:     0xE0,0xE0,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00]
lines = ["%08d%08d" % (int(bin(l)[2:]), int(bin(r)[2:])) for (l,r) in zip(char[::2], char[1::2])]

print('\n'.join(lines).replace('0', '.').replace('1', '*'))

# outputs:
"""
................
................
................
................
................
....***.........
...*****........
...*****........
...*****........
..***.***.......
..***.***.......
.***...**.......
.*********......
.***...***......
***.....***.....
***.....***.....
***.....***.....
................
................
................
................
"""

生成字库

搞明白了原理和格式,接下来就是生成所需的字库了。网上有非常多的 Windows 下小工具可以做。 但我这么肝,就自己写了。代码来自之前给 TFT-LCD 写的抠字模小脚本儿。

原理很简单,用 Pillow/PIL 即可。先把需要的字画在图片上,然后读取象素点,移位生成对于字节表示。 最后最好直接生成 C 代码,就万事大吉。

而字体选择,一般使用点阵字体而非矢量字体,矢量字体在渲染的时候边缘都是带灰阶的, 如果忽略灰阶直接二值化,会导致最终字型锯齿严重,丑。

为了方便处理,这里选用开源的等宽字体文泉驿点阵字体 Unibit. 以中文点阵最常见的 16x16 输出。正好两个字节宽,16 行高,一个汉字 32 字节。 英文字符也正好是中文字符宽度的一半。

当然你可以随便从系统找个中文字体。需要注意的是,为了在黑白(无灰度)屏幕上达到最好效果,需要位图字体(bitmap font, raster font, pixel font), 否则矢量字体在渲染的过程中会有灰阶边缘,最终屏幕效果锯齿明显。

# 安装依赖

pip3 install Pillow

# 用于字体缩放的依赖库
brew install libraqm
from PIL import Image, ImageDraw, ImageFont
import PIL.features

# brew install libraqm
assert PIL.features.check('raqm'), "libraqm required"

# 画布大小
size = (320, 16)

# 黑白格式
FORMAT = '1'
BG = 0
FG = 1

# Y offset, 多数字体有自带行间距,可以用此参数消除行间距
YOFF = 0  # or -1


CHARS = "晴天卧槽可以了!你好世界最怕你一生碌碌无为,还安慰自己平凡可贵。雾霾"
CHARS = ''.join(list(set(CHARS)))

im = Image.new(FORMAT, size, BG)

font = ImageFont.truetype("Unibit.ttf", size=16, index=0)


draw = ImageDraw.Draw(im)

# 代码段用于检查字体渲染
# draw.text((0, YOFF), CHARS, font=font, fill=FG, language='zh-CN')
# im.save('font.png')
# im.show()

draw.rectangle([(0, 0), size], fill=BG)

for i, c in enumerate(CHARS):
    charmap = []
    draw.text((0, YOFF), c, font=font, fill=FG)

    for y in range(16):
        v = 0
        for x in range(0, 16):
            b = im.getpixel((x, y))
            v = (v << 1) + b

        charmap.append(v >> 8)
        charmap.append(v & 0xFF)

    draw.rectangle([(0, 0), size], fill=BG)
    print("{", end='')
    print('"{}", {}'.format(c, ', '.join(map(lambda c: "0x%02x" % c, charmap))), end="")
    print("},")

直接输出 C 代码片段:

{"一", 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
{"生", 0x01, 0x00, 0x11, 0x00, 0x11, 0x00, 0x11, 0x00, 0x3f, 0xfc, 0x21, 0x00, 0x41, 0x00, 0x81, 0x00, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x00, 0x00},
{"碌", 0x00, 0x00, 0x01, 0xf8, 0xf8, 0x08, 0x20, 0x08, 0x21, 0xf8, 0x40, 0x08, 0x78, 0x08, 0x4b, 0xfe, 0xc8, 0x20, 0x4a, 0x22, 0x49, 0x74, 0x48, 0xa8, 0x79, 0x24, 0x4a, 0x22, 0x00, 0xa0, 0x00, 0x40},
{"碌", 0x00, 0x00, 0x01, 0xf8, 0xf8, 0x08, 0x20, 0x08, 0x21, 0xf8, 0x40, 0x08, 0x78, 0x08, 0x4b, 0xfe, 0xc8, 0x20, 0x4a, 0x22, 0x49, 0x74, 0x48, 0xa8, 0x79, 0x24, 0x4a, 0x22, 0x00, 0xa0, 0x00, 0x40},
{"无", 0x00, 0x00, 0x3f, 0xf0, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x7f, 0xfc, 0x04, 0x80, 0x04, 0x80, 0x04, 0x80, 0x08, 0x80, 0x08, 0x80, 0x10, 0x84, 0x20, 0x84, 0x40, 0x7c, 0x80, 0x00},
{"为", 0x01, 0x00, 0x21, 0x00, 0x11, 0x00, 0x11, 0x00, 0x01, 0x00, 0x7f, 0xf8, 0x02, 0x08, 0x02, 0x08, 0x02, 0x88, 0x04, 0x48, 0x04, 0x48, 0x08, 0x08, 0x10, 0x08, 0x20, 0x08, 0x40, 0x50, 0x80, 0x20},

使用字库

要想使用字体,我们需要新建一个字体 .h 文件,就叫 ch_font.h,在项目目录即可,不需要修改驱动库:


#include "fonts.h"

const CH_CN Font16CN_Table[] =
{
    {"一", 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
    {"生", 0x01, 0x00, 0x11, 0x00, 0x11, 0x00, 0x11, 0x00, 0x3f, 0xfc, 0x21, 0x00, 0x41, 0x00, 0x81, 0x00, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x00, 0x00},
    // ... 这里写入剩余字型
};

cFONT Font16CN = {
  Font16CN_Table,
  sizeof(Font16CN_Table)/sizeof(CH_CN),  /*size of table*/
  8, /* ASCII Width */
  16, /* Width */
  16, /* Height */
};

使用:

// ...
#include "cn_font.h"


void setup() {
    // ...
    Paint_DrawString_CN(140, 70, "卧槽可以了!", &Font16CN, BLACK, WHITE);

    EPD_2IN13_Display(BlackImage);
    // ...
}

// ...

效果大概是(忘记拍照了,这里是另外一个开源字体 Sarasa):

Chicken soup

是的,毒鸡汤。至此中文字体搞定,其他 CJK 字体同理。

而英文字体就更简单了,参考其他驱动中的 font* 文件即可。字型生成代码略改即可使用。

具体制作中可以混合中英文大小字体,还可以选择例如数码管字体等方案,完成布局。

Mixed Font

其他显示需求

图片显示

墨水屏最烂大街的应用,大概就是天气时钟了,各式各样,各种尺寸。

显示文字信息的事情,上面我们已经通过自定义字库搞定了, 发挥想象力可以搞出诸如数码管字体,手写字体,等各种适合在墨水屏上实现的显示效果。

但问题来了,我想搞个天气图标符号显示,比如,中国天气网 那样的。

weather

官方例程里其实有全屏图片显示例子,可以看到相关的调用函数 Paint_DrawBitMap。 同时驱动库里也提供了 Paint_DrawImage(buf, x_start, y_start, img_width, img_height) 函数用于在任意位置显示任意大小图片。

看一眼驱动库里 Paint_DrawImage 代码,好像哪里不对,只支持宽度象素是 8 倍数的图片。 改也简单,用 Paint_SetPixel 函数替换即可,简单的位运算。

现在问题是怎么生成一张用于显示的图片。其实和上面的字库非常类似,就是用二进制位去映射象素点。 然后生成 C 数组。甚至核心代码逻辑也差不多。

需要注意的是图片只能是黑白二值图。为方便库函数识别,也提前将图片宽度处理成 8 的整数倍。

图源,就取天气网那堆图标,原图是用 CSS offset 方式显示的,也就是所有图标在一张图片上,需要切下。

完整代码见 gist: crop-blue30.py. 这里贴要点

# RGBA 到 RGB 的转换
pix = r, g, b, a = im1.getpixel((x, y))
BG = 255 # 背景是白色
r = (BG * (255 - a) + r * a) // 255
g = (BG * (255 - a) + g * a) // 255
b = (BG * (255 - a) + b * a) // 255
a = 255 # alpha 通道置空,不透明度

im1.putpixel((x, y), (r, g, b, a))
# 图像二值化,
im1 = im1.resize((64, 64), Image.LANCZOS)
im1 = im1.filter(ImageFilter.SHARPEN)
im1 = im1.convert('1', dither=Image.NONE)

当然,你要是用 ImageMagick 或者 Photoshop 也一样可以。

然后把生成的二进制装入 C uint8_t[] 即可。

显示一个太阳:

image

因为二值化和缩放的关系,象素周围略有点腐蚀的感觉。 效果还行,考虑考虑界面元素布局,做个天气时钟足够了。

局部刷新

正常情况下在显示文字和图案的时候屏幕会连续从最黑到最白来回闪动几下,参考显示原理, 这里是为了避免残留墨水粒子影响显示效果,即消除残影的影响。 有使用过 Kindle 的同学对这点会比较清楚,一般是翻页若干次全部刷新一次。

官方例程中右下角有个时间显示,用到了局部刷新技术:

EPD_2IN13_Init(EPD_2IN13_PART);
Paint_SelectImage(BlackImage);
// ...
Paint_ClearWindows(140, 90, 140 + Font20.Width * 7, 90 + Font20.Height, WHITE);
Paint_DrawTime(140, 90, &sPaint_time, &Font20, WHITE, BLACK);

EPD_2IN13_Display(BlackImage);

首先以 EPD_2IN13_PART 方式重新初始化屏幕(不用清屏),然后就通过 Paint_ClearWindows 清除需要局部更新的矩形区域, 之后就可以使用各种绘图绘字符函数填写屏幕的这部分。最后调用 Display 上屏。

局部刷新的效果因屏幕体质不同各异,经常会遇到残影特别严重的时候。做小制作的时候也可以学习电纸书,局部刷新多次后全局刷新一次。

再看显示原理

以上,基本介绍完了墨水屏的常见功能。已满足绝大部分需求。

回头再看显示原理,有了一开始介绍的小胶囊阵列结构,驱动怎么通过搞定显示的呢?这里以 2.13 寸显示屏的驱动 IC IL3895 为例。

LUT

LUT, 即 Waveform Look Up Table(LUT), 是很多介绍电子墨水驱动文章的离不开的话题。

所谓的 LUT 功能,其实是驱动芯片的“可编程驱动电压波形”功能。即通过若干寄存器字节, 设置像素在不同状态转换情况下使用的底板电压高低的时序。该设置全局有效,针对全屏幕的任何一个像素的变动。 整个波形通过更新屏幕指令(MasterActivation = 0x20, Activate Display Update Sequence)触发, 此过程中 BUSY 信号有效,更新逻辑完成后, BUSY 信号结束。

例如数据手册中:

image

IL3895 芯片支持 10 个 phase 的波形,分别是 phase0A phase0B phase1A phase1B phase2A phase2B phase3A phase3B phase4A phase4B, 其中每个 phase 可以指定维持状态的时间周期数 TP. 每两个波形可以设置一个重复次数 RP. 在每个 phase, 都可以指定像素底板电压 VS 高低。不同电压级别驱动颜色微粒向不同方向运动,维持不同的时间,最终实现像素的黑白变化。

图表右侧的 XY=LL, XY=LH, … 表示不同的像素值变动情形。例如 HL 表示像素从白色变动到黑色, LL 表示像素在此次刷新中值没有变化。

以上的所有配置项按照固定格式,最终形成了 LUT 表,可以通过命令设置:

image

而例程中的 EPD_2IN13_Init(EPD_2IN13_FULL/PART), 其中最重要也是唯一的区别就是 FULL 和 PART 初始化时使用的 LUT 表不同。

const unsigned char EPD_2IN13_lut_full_update[] = {
    0x22, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x11,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E,
    0x01, 0x00, 0x00, 0x00, 0x00, 0x00
};

const unsigned char EPD_2IN13_lut_partial_update[] = {
    0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x0F, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};

初始化后,每次显示更新,都会执行对应的 LUT 表时序。也就是说,全局刷新情况下的屏幕从全黑到全白好几次清空屏幕的动作, 就定义在这个 FULL 对应的 LUT 中。有兴趣的小伙伴可以解析下 EPD_2IN13_lut_full_update.

正因为有了 LUT 定义,屏幕以 EPD_2IN13_PART 方式初始化时,新显示内容不再需要全屏刷新后再显示,直接在原始状态进行绘制。

这里以 EPD_2IN13_lut_partial_update 为例介绍下实际 LUT 执行时候发生的动作。简单得多,以至于整个表只有 3 个非空字节, 对应 phase0A, phase0B, 其余 phase 设置因对应的 TP(period) 为 0, 不生效。所以只有两个 phase 的波形。

/// LUT for partial update.
#[rustfmt::skip]
pub const LUT_PARTIAL_UPDATE: [u8; 30] = [
    // VS, voltage in phase n
    // <<VS[0A-HH]:2/binary, VS[0A-HL]:2/binary, VS[0A-LH]:2/binary, VS[0A-LL]:2/binary>>
    // <<VS[0B-HH]:2/binary, VS[0B-HL]:2/binary, VS[0B-LH]:2/binary, VS[0B-LL]:2/binary>>
    // HL: white to black
    // LH: black to white
    // e.g. 0x18 = 0b00_01_10_00
    0x18, 0x00, // phase 0
    0x00, 0x00, // phase 1
    0x00, 0x00,
    0x00, 0x00,
    0x00, 0x00, // phase 4
    // padding
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    // RP, repeat counter, 0 to 63, 0 means run time = 1
    // TP, phase period, 0 to 31
    // <<RP[0]_L:3/binary, TP[0A]:5/binary>>
    // <<RP[0]_H:3/binary, TP[0B]:5/binary>>
    0x0F, 0x01, // phase 0
    0x00, 0x00,
    0x00, 0x00,
    0x00, 0x00,
    0x00, 0x00, // phase 4
    // padding
    0x00, 0x00, 0x00, 0x00
];

如上,改写为 Rust 代码,加入详细注释,注释混搭假 Erlang 语法。

先看 VS 部分,即驱动电压部分。0x18 = 0b00_01_10_00, 按照格式拆出:

VS[0A-HL] = 01
VS[0A-LH] = 10

# 其他情况为 00

手册中有介绍 VS 格式:

00–VSS
01–VSH
10–VSL

分别是三种电压输出级别,其中 VSS 与顶版 VCOM 相等。相当于无电场存在。
VSH, VSL 分别会导致是两种不同的电场方向。

所以解读 phase0A 的配置即:

  • 当像素点从白转黑时(HL), \(V_{pixel}\) 电压为 VSH
  • 当象素点从黑转白时(LH), \(V_{pixel}\) 电压为 VSL
  • 其他情况下,\(V_{pixel}\) 电压为 VSS, 由芯片手册可知, VSS = VCOM, 相当于无变化

再来看 RP, TP 部分。需要将字节的高低位组合:

0x0F = 0b000_01111
0x01 = 0b000_00001

所以得到
RP[0] = 0b000_000 = 0
TP[0A] = 0b01111 = 15
TP[0B] = 0b00001 = 1

如上提到, TP 为 0 时表示该 phrase 无效,所以上面说只有 phase0A, phase0B 两个 phase 有效。 RP = 0 是表示重复 1 次。

总结以上那么该 LUT 的逻辑是:

  • 当像素点从白转黑时(HL), \(V_{pixel}\) 电压为 VSH, 持续 15 个周期,随后转 VSS, 持续一个周期
  • 当象素点从黑转白时(LH), \(V_{pixel}\) 电压为 VSL, 持续 15 个周期,随后转 VSS, 持续一个周期
  • 其他情况下,恒为 VSS
  • 以上逻辑执行 1 次
  • 单个周期文档中介绍时长为 \(T_{FRAME}\)

所以似乎明了,所谓局部刷新,就是在像素点不变的时候,不做任何操作。 在像素翻转的情况下,执行一次给电压操作,随后静置少量时间。

而全刷新,可以很明显看到,前几个周期都是全置黑全置白的统一操作,为的是清屏。 之后才是处理不同像素状态变更的波形。

Idea

持续电压 15 个周期,那么少一点会怎么样?

测试发现这样出图效果没那么黑,甚至是灰色。

所以似乎就有了在黑白墨水屏上实现灰度显示的方案。这里的黑白墨水屏,特指驱动手册中单个像素为 1 位的屏幕。即 1bpp(bit per pixel).

本文题头图,即为测试效果。

灰度显示的内容,另开坑讲。

framebuffer

A framebuffer (frame buffer, or sometimes framestore) is a portion of random-access memory (RAM) containing a bitmap that drives a video display. It is a memory buffer containing data representing all the pixels in a complete video frame. – Wikipedia

回到例程,可以看到屏幕初始化调用大概是:

EPD_2IN13_Init(EPD_2IN13_FULL/PART);
EPD_2IN13_Clear();

UBYTE *BlackImage = malloc(...);
Paint_NewImage(BlackImage, EPD_2IN13_WIDTH, EPD_2IN13_HEIGHT, 270, WHITE);
Paint_SelectImage(BlackImage);

// ...

EPD_2IN13_Display(BlackImage);

阅读对应函数得知,这里创建了一个 BlackImage 字节数组做墨水屏的 framebuffer. 所有的绘图操作都只对这个 framebuffer 进行, 不和设备进行交互,直到调用 Display 才会进行实际的设备交互。

framebuffer 在 Display 被调用时,按照驱动芯片所设定的方向,比如按行,按列的方式, 一个个字节被从 MCU 传输到驱动芯片的内部的“显存”中,随后执行 LUT 更新逻辑。 驱动芯片会对比像素点的当前状态和目标状态,执行对应的 LH, HL, LL, HH 波形。 最终像素出现在屏幕上,完成显示。

Next

之后抽时间写写灰度显示相关的折腾过程。

和 Rust embedded-grahics 驱动的情况。

参考

]]>
andelf
Story of Year 20032021-01-01T18:08:00+00:002021-01-01T19:28:40+00:00https://andelf.github.io/blog/2021/01/01/story-of-year-20032003 竟然没找到张像样的照片。那时候生活没那么数码。还在用胶片。

其实更主要的原因是,整个世纪初的前几年,不曾拍过几张照片。或者出游,也是拍别人。

想讲讲 2003 的故事。其实这么久,能记得的,也都模糊到失真了。

初三

那年初三。整个寒假在 Uncle Wang 家补课,说是补课,其实更像是托管。有英语和数学课。封闭管理。 他家用水不便,就记得那个寒假好像只洗了一两次头? 洗头这事,好说,当不洗的天数到达一定境界后,就已经感觉不到头发的存在了。

说起来万幸,还好有这一寒假的补课,不然中考大概率会考扯。

开学后就是紧张的中考前复习。依旧玩得昏天黑地。学校为了提高上线率,重新给两个实验班分了班, 二班一部分到我们一班,我班成绩靠后的去二班上课,两个班教学不同步,进度和教学内容都不太一样。

然后位置自己选,这可搞笑了,和一朋友自选搭了同桌,一起坐第一排。陕北方言把闲聊叫“谝”, 于是爱自习课说话的同学们各自封什么“谝王”,“谝圣”一类。形容的原话往往是,“那(ne)xxx太能谝了(liao4),纯粹一谝x”。

那时候兼任物理课代表。整个初三下近乎所有的课都是做卷子加讲卷子的无聊组合,卷子大概是科任老师自己找的, 比如物理,我们于是每个人要交几块钱的试卷费。收试卷费的任务就落到我头上。

于是一个百无聊赖的晚自习,拉着十几号人,去心灵在线(离学校最近的网吧),用试卷费请大家上网。 近乎包了网吧二楼。那时候玩啥的都有,与几个好友那段时间玩的最多是暗黑破坏神,我玩刺客。

所以试卷费就这么被我散掉了。后来物理老师总碎碎念这大几十块钱,还了他。

初三,整个初中没有比我们更资历老的学生了,所以你看周围人总多少带点痞气,放学回家路上逗逗学妹一类的。 那时候初中生男女之间的感情,大概基本上就是写纸条,送xx回家之类。 “送xx回家”这可能要以后细了说。

非典

突然有天,电视说什么 SARS, 什么非典,那时候口号最多的是叫“众志成城,抗击非典”。 倒是印象中没要求啥口罩,记得最深的是每天到教室那浓重的消毒水味。

于是乎网吧游戏厅台球室都关掉了,但朋友们间的小道消息说的是,因为中考前家长投诉关的。 然后黑网吧黑游戏厅成了同学们口口传的秘密,曾跟同学一起翘下午自习翻墙去一家黑游戏厅打 PS(就叫打索尼), 玩的是 FIFA, 但其实我不太会玩这类,被虐就是了。只是热闹。

非典对于小县城来说,也就茶余饭后的小话题,周围没有人真得了非典,那其实大家的消息源还是新闻联播。 依稀记得那年板蓝根的疯狂。但小县城,比板蓝根牛逼的让人啼笑皆非。比如,那时候县里时兴送领导求办事送免疫球蛋白, 或者干扰素。嗯,这个带劲儿。

因为非典的关系,我们那届毕业生没有毕业联欢晚会。很遗憾。之前一年的毕业联欢会上,还出了个小品, 自毁形象那种。

但突然非典就没了。大概也就是6月中考后,就再没有这个话题了。

隔壁班

那时候全校近乎每个班都有认识的人。曾自大地笑称,没有借不来的东西。

所以很多故事,也就从借东西开始发生。一借一还,看起来简单的事情,却不知,借的人或被借的人那一节课有没有各种胡思乱想。 一胡思乱想,诶,很多事就不一样了。

各种机缘巧合又错过,回头再看,真是捉弄人。

中考

中考比想像中来得快很多。所有人都是突然间发现就要中考了。也不知复习没复习完事。

不曾想到,那时候遇到的人儿,成了后来很长一段时间里的重要的人,也最终成了一不可说的大遗憾。

嗨,苦就苦在,等你想明白这一切,这一切却已经过去了。

以至于成了很长时间都解不开的结,也无从再去回头解释自己的行为逻辑。空留下那么多年和 SA 的来回邮件, 总回看,总内牛满面。

中考就是这样一件事。有时候会梦到在这故事的某一刻,却问的是你这几年还好吗。

高一

陌生的学校,陌生的环境,不多的几个老同学。

日常交际出问题,所以每周末就是单调和无聊。

就交际来说,现在看,可能自己是极被动的那种。总在等。但实际上又较依赖亲密关系的存在。

想起那时候的 IC 卡电话和校门外的话吧。某段时间里,也曾是常客。

这段高一,每年都会出现在梦里,那是种容易让人分不清现实的梦,总愿意在梦里永远呆下去不醒来。

]]>
andelf
Why?!2020-11-27T04:00:00+00:002020-11-27T08:27:15+00:00https://andelf.github.io/blog/2020/11/27/why-and-why“为什么呢,这次你这么坏,我却还是会想你。”

“总是这样,来不及相认就失散。”

]]>
andelf
Welcome to Jekyll!2020-11-16T06:54:24+00:002020-11-16T07:47:16+00:00https://andelf.github.io/blog/2020/11/16/welcome-to-jekyll是的我升级了.

是的我又回来了.

]]>
猫·仁波切
迷之一键部署2020-02-27T06:00:00+00:002020-11-16T08:12:52+00:00https://andelf.github.io/blog/2020/02/27/one-click搞 IT 技术相关的,尤其是后端技术和分布式相关的同学,怕是不只一次听过所谓的一键部署的提法,与此相似或者相关的,可能还包括自动化部署,一键上线/回滚,以及 CI/CD 相关的持续部署,持续集成,持续交付等等概念。

这里,不谈 CI/CD,只谈作为对用户产品的一键部署,无论是叫 one-click, single-click, one-key, one-press, one-button, one-command, single-command… 还是只是对外发布的某种快速部署工具(集)。部署,俗称上线,不过细究概念,上线似乎更侧重日常功能更新,而部署的概念,更侧重首次的初始环境搭建。无论何,传统上,部署都是运维(OP)同学的日常工作。

所谓一键,大都是虚指。做过运维的同学知道,部署怎么可能是一件容易的事情。考虑到现代服务端软件集的庞大,从数据库/缓存,到后端逻辑,到前端服务,到监控系统,到报警系统,各组件相互配合,才完成最终对用户提供服务。更不用说机房服务器环境,云主机环境,现代分布式服务的多样和复杂性,导致 trouble shooting 极其复杂,哪怕是饱经事故的老 OP,也不容易。

正因有了这些复杂性,可信赖的部署工具就更显得重要。好用的工具不只为虎添翼,还给萌新在黑夜里点了盏明灯,在产品拉新的层面更有极其重要的作用。垃圾的工具,处处是坑不说,让人骂娘的心都有,且有工具还不如没有工具。没有工具的时候,OP 拿着命令行一顿敲,不也成功部署了么?

是的,关于一键部署,这里要从命令行说起。

一键部署从哪来

命令行 OP

最原始的典型服务部署过程,不过是登录到服务器,通过 scp/wget 下载到最近的软件包,解压编译,必要时候还需要下载若干编译依赖,然后修改配置,最后启动程序完成部署。

潮一点的容器部署方式,拉几个容器镜像下来,加上参数 run 起来,也是略苦逼。

是命令,就有可能出错,相信多数 OP 都有一个命令行小本本,记录着常见操作需要执行的命令。

一键部署小脚本儿

看起来传统的命令行式部署很容易自动化,把这些命令集合在一起,于是就诞生了最原始的,一键部署脚本儿。脚本丢到服务器上,一执行,漫长或短暂的等待过去,服务 ready.

而容器化部署,也可以自动化成 docker-compose 等待,外部包一个处理配置的脚本,依旧好使。

随后 OP 同学为该脚本增加了更多的命令行参数,比如软件包版本号,部署路径,配置文件的某条常修改的参数,看起来这个时候,已经可以交付外部使用了。

一键部署命令行工具集

然而多数服务端软件比以上流程复杂得多,OP 们往往面对的是一个集群,每台机器的配置或命令都有所不同,且服务的启停过程往往具有某种依赖顺序。这时候,简单的命令堆叠小脚本就不能满足需求了。

开源届倒是提供了不少解决方案,比如 Ansible, Puppet, Chef 等待,以及若干虚拟化解决方案,实现了配置,执行,部署,交付的完整流程。此外还有若干轻量工具,靠更多的人工配置环境提供更高的自由度,例如 fabric.

这时候的一键部署,可能就是某个强大工具的配置文件,描述了部署的步骤,然后加上目标机器环境的配置文件,通过执行一条命令,完成整个环境检查,环境初始化,部署应用,启动应用的全流程。

例如 ansible-playbook -i inventory install.yml 这样一条命令,加载 install.yml 描述的安装流程,然后在 inverntory 指定的服务器列表上执行这些安装流程。

但是这样的工具有一个很大的缺点,那就是运行异常可能需要工具的专家介入。专家大概是能给工具写插件级别的。以 Ansible 为例,能讲清楚机器登陆时候,不同发行版报错原因以及解决方法的人,并不多。

对于容器化部署,那当然要诉诸于各种编排工具,不在此讨论了。

一键部署 UI

严格地说,命令行也是 UI 的一种,原教旨主义会告诉你,UI 就是 User Interface,就好比 explorer.exe 也是 SHELL 一样。这里说的 UI, 特指 GUI,大概包括说有的图形应用程序,尤其指浏览器 Web UI.

之前在推上有句吐槽,“每个傻逼的后端产品 PM,都有一颗给命令行写 GUI 的心“,这话放在一键部署领域,依旧是合适无比。

做好 UI 的第一步,大概是不做 UI.

你可以认为 UI 在大部分时候是伪需求,问问自己的内心:在已经有较完善的一键部署工具集或者脚本的情况下,为什么做 UI?

其实答案很简单,UI 不是给 OP 或者一线工程师用的。它目标是为了把一件事情的门槛降低到尽可能低,低到只是恰好理解这个服务是干什么有什么要素的人,也能操作。

然而这是不可能的。所以所谓的你看到到的一众 UI 只能将大部分晦涩的配置选项隐藏起来,然后让你填写一个机器列表然后”一键“部署。然后失败了,弹出简洁的“部署失败”四个大字,或是晦涩的冗长的没人会去看的错误日志。

迄今为止,见过可以说的上能用的一键部署 UI, 可能是 Ansible-Tower,即 Ansible AWX, 但它真的只是包装了 Ansible 的命令行,所以足够简洁,足够完备。然而它的 trouble shooting 难度依旧是 Ansible 级的。除非你对 Ansible 足够熟悉,否则还是找足够经验丰富的人去追查背后到底发生了什么错误。

历史的例外

大概会有若干种特殊情况。例如大型公司的自研(或致敬某开源项目的)内部系统,例如云平台服务商的某些自动部署工具等。共同点是,受控底层环境,用户有天然身份门槛。

所以这个时候,不乏内部优秀工具或是云平台优秀工具。

一键部署现状

然而该做不该做的东西,总是要做的。所以谈谈一键部署到底要做啥吧?这里主要谈分布式系统集群的一键部署。一个应用一个二进制文件一台机器,就真没必要折腾。

Inventory 管理

这里的 inventory, 泛指一切目标资产,比如服务器,云主机或者虚拟机。其中的属性信息繁杂,和后续的部署过程有着强依赖,比如机器上的配置,例如磁盘空间,内存,CPU 时间等。通过 inventory 的自动检查和初始化脚本,获取各种信息,为部署过程提供方便。

部分支持云主机的 Inventory 管理,还包括按需动态创建主机实例。

再广义些,还可能包括外部可用的服务资源,例如公共的 redis 服务。

Credential 管理

Credential 是指登陆机器进行操作的用户权限,比如 SSH 私钥,或是机器的用户名密码,或是云平台的访问所需密钥 KEY.

应用管理

所谓应用,就是将要部署的服务(往往包含多个不同子应用),它们之间通过特定的依赖关系互联,最终对外界提供服务。除核心服务应用之外,还可能包括监控应用(含报警应用),管理应用(adminstrative dashboard),工具应用(例如备份/恢复工具)等。

应用的管理,主要是应用元信息的管理,应用之间的依赖关系管理,应用对资源的关系,应用配置管理。

应用元信息的管理,往往是描述一个应用的诸如版本,二进制等等信息,往往是部署的第一步,先准备好将要部署的应用。其中涉及到产品二进制分发的问题,则是额外的话题了。

应用之间依赖,应用对资源的依赖关系的管理,往往通过配置管理的形态实现。

配置管理

配置管理不只包括应用配置管理,往往还包括部署的配置管理,但两者之间往往存在交叉融合的地方,比如说指定某个机器的上部署的应用 A 需要一个特殊的配置项。

应用配置管理

管理各应用的配置参数,最终可能以配置文件,应用启动的命令行参数,或是应用的环境变量等方式存在。其中部分配置暗含各应用各部署目标机器之间的互联关系,往往是动态生成得到。例如

  • 应用提供服务的端口号
  • 应用的数据目录
  • 应用的日志目录
  • 某应用有两套配置,一套用于生产环境,一套应用于测试环境
  • 某条配置项目按某种特定规律生成
  • 某配置项目随机生成,但要求全服务集群应用的改配置项必须相同

部署配置管理

部署配置管理其实就是所部署的应用到具体的部署目标之间的映射关系。例如:

  • 某台机器是监控机,需要部署所有监控模块
  • 某台机器需要打开端口 80 提供服务
  • 某台机器的某个目录部署应用 A
  • 某台机器的某个目录做应用 B 的数据目录

部署配置管理的高级形态,是声明式。即机器声明我支持某资源,由一键部署系统自动选择依赖关系。这也稍许牵扯到高级形态的部署。

状态数据管理

应用的正常运行,不只依赖配置,往往还有若干依赖数据。传统的静态依赖数据之外,就是动态数据了,往往以数据库或是数据目录的方式存在。

状态数据的管理,是整个部署届的难题。越是庞大的状态数据,在部署,环境变更,灾难恢复,以及迁移的时候就越成为问题。

现代分布式系统一般通过底层分布式数据库的方式解决状态数据管理,然而这带来了一个鸡生蛋蛋生鸡的问题,底层分布式数据库的部署,又是一个状态数据管理问题。

是的。我们还有分布式文件系统。233。

部署管理

部署管理,即管理具体的部署操作任务。往往通过任务队列的方式实现,是整个一键部署最核心的部分。

在部署过程中,尤其要提供相对较清晰的进度展示,并输出合理的操作日志供时候追查问题。

部署往往也包含了变更管理,即应用版本或某依赖更新后,再次触发部署。所以部署不应是一次性任务,而是可重入任务。

部署任务的触发一般提供手动和自动两种模式。所谓的“一键”就是这里点击的“部署”按钮。

而自动触发,就隶属持续部署或是持续交付的范畴了,一般通过某种触发器或是任务计划实现。

监控管理

服务的正常运行,离不开监控。在一键部署系统中,监控往往作为单独的应用存在,所以将之化解为另一个应用管理的问题。

监控往往包括监控数据的收集,监控数据的查询展示,日志收集等问题。高级形态还包括动态调试等,加入了诊断系统的功能。

监控系统一般还会提供通知(notification)的功能,报警或者状态日报信息通过通知系统发送给关注者。

服务管理

服务管理相对较简单,即一个部署完成的集群,其中服务的启动,停止,删库跑路等操作。其中还可能牵扯到服务存活检测,服务自启动,服务异常自动重启(保活)等知识点。

服务管理可以作为特殊的部署操作来实现,比如设定特定的部署动作,检查环境后启停对应服务。

PM 们提出的其他管理

如上是核心功能。然后 PM 往往会提其他需求。例如:

  • 权限管理:作为持久运行的变更系统,至少需要一个权限控制,尤其生成环境
    • 用户管理
    • 用户组管理
    • 用户和用户组的权限管理
  • 多应用管理:即整个一键部署工具作为通用平台

一键部署的将要到哪里去

一键部署,终将要被云平台或是云平台的容器编排消灭的吧。

但到时候,又是给容器编排做 UI 了。

]]>
猫·仁波切
Fix Ansible Tower: stdout capture is missing2016-12-07T08:33:20+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2016/12/07/fix-ansible-tower-stdout-capture-is-missingAnsible Tower will report stdout capture is missing when restoring from previous backup.

Or run from docker?

(得,不装 B 英语了)

长话短说,之前要把 Ansible Tower 拆到 Docker 里,结果发现总不能正常执行。任务界面会提示:

stdout capture is missing

检查发现是 celery 进程出错,用 root 启动 celery 倒是正常的。

最后发现是 docker 中的 supervisord 启动时缺乏部分环境变量,解决方法:

change supervisor/conf.d/tower.conf
ADD:
[program:awx-celeryd]
......
environment=HOME="/var/lib/awx",USER="awx"
......

是的,为找到原因,逆向了整个 Ansible Tower。

Ref: GitHub Issue

]]>
猫·仁波切
在 CircleCI 上使用 Rust(CircleCI meets Rust)2016-11-18T05:26:15+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2016/11/18/circleci-meets-rust最近由于频频遇到 travis-ci 的问题,主要是 Linux 资源排队、macOS 资源更需要排队,导致自动测试时间被拉长, 影响开发效率。

了解到 CircleCI 是不错的替代品,所以打算迁移 Rust 项目过去。当然说起来, CircleCI 的野心更大,是要来替代 jenkins 的。

目前官方支持语言其实都比较落后,包括 go 也只是 1.6 版本,但似乎不是问题,而且据介绍, CircleCI 2.0 支持自定义 build image,支持语言的版本当然不在话下。

每天面对各种 IaaS, PaaS,免不了写配置是,这也是 yaml 程序员的日常。

dependencies:
  pre:
    - curl https://sh.rustup.rs -sSf | sh

test:
  override:
    - cargo build
    - cargo test

如上。然而不 work。报错:

cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
warning: spurious network error (2 tries remaining): [12/-12] Malformed URL 'ssh://[email protected]:/rust-lang/crates.io-index'
warning: spurious network error (1 tries remaining): [12/-12] Malformed URL 'ssh://[email protected]:/rust-lang/crates.io-index'
error: failed to fetch `https://github.com/rust-lang/crates.io-index`

To learn more, run the command again with --verbose.

cargo build returned exit code 101

Action failed: cargo build

神了。原来, CircleCI 自作聪明在 .gitconfig 里修改了映射配置,强制用它自己的 ssh key 去访问 github,rewrite 了 https://github.com 的所有仓库。 这恰恰和 cargo 的 registry 机制冲突。所以报错。

CircleCI has rewrite https:://github.com to ssh://[email protected]: in .gitconfig. And this made cargo fail with above error message.

找到了原因,就可以搞了:

machine:
  pre:
    - sed -i 's/github/git-non-exist-hub/g' ~/.gitconfig

dependencies:
  pre:
    - curl https://sh.rustup.rs -sSf | sh

test:
  override:
    - cargo build
    - cargo test

嗯, Ugly but works.

]]>
猫·仁波切
折腾 Raspberry Pi + HomeKit 手记2016-09-16T12:34:43+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2016/09/16/play-homekit-with-ios-10-and-raspberry-pi9月14日凌晨苹果终于推送了 iOS 10 的更新。从之前发布会来看,并没有多少亮点,除了几天的新鲜感之外, 尤其是对于目前还在用上两代机型的我来说,2333。

两年前苹果发布 Swift 语言的同时,新增了 HomeKit,当时用工具 dump 过最老版本的 Swift 声明。传送门:HomeKit.swift。目前所有官方相关的资料位于 HomeKit - Apple

好消息是期待很久的 HomeKit 应用终于上线,屏幕上多了“家庭(Home)”应用,控制中心(从屏幕下方滑动)、 Siri 均对此有支持。 iOS 10 终于强化了推出已有两年智能家居平台,提供了官方 App,有不少硬件厂商支持。

简单说,HomeKit 就是苹果官方的智能家居平台解决方案,包括移动设备 SDK,智能家居硬件通信协议(HAP: HomeKit Accessory Protocol)、以及 MFi(Made for iPhone/iPod/iPad) 认证等等。通过 WiFi 或蓝牙连接智能家居设备(或 bridge 设备),也可以利用 Apple TV(4代) 或闲家中的置 iPad 实现设备的远程控制(HAP over iCloud)。

Home App 的维度划分:

  • Home: 家,和地理位置绑定,支持共享给好友控制。
  • Room: 房间,用于对设备进行分组。
  • Scene: 场景,一组对设备的配置,例如“起床”,那么可能的配置是打开卧室灯、窗帘、放段舒缓music等等。

众所周知苹果是卖数据线等硬件的公司(嗯,假设你数据线也坏过不少),HAP 协议部分是需要加入 MFi Program 才能获取文档,而且 MFi Program 无法以个人开发者身份加入。

好在有好心人逆向了 HAP 的服务端协议(对于智能硬件来说,硬件是服务端,手机App是客户端)。

对于折腾党来说,机会来了,自己动手改造家居!本文不涉及 App 开发,只涉及如何自制支持 HomeKit 的设备。

准备工作

设备列表:

  • iPhone 6P (iOS 10)
  • Raspberry Pi 3 (Debian jessie)

考察了两个比较靠谱的 HAP 实现:

  • https://github.com/KhaosT/HAP-NodeJS
  • https://github.com/brutella/hc (golang)

最终选择使用 golang 的 brutella/hc,准备环境。

需要保证树莓派和手机位于统一子网,因为 HAP 底层是基于 Apple mDNS(RFC 6762)。

brutella/hc 要求 golang >= 1.4,而 Debian jessie 版本较低, 需要配置 jessie-backports 源:

deb ftp://ftp.cn.debian.org/debian jessie-backports main contrib non-free

同时导入源的 GPG Key。方法参考 这里

安装好 golang 1.6.2,建立开发目录。

# 似乎直接 install golang 会出点小问题,所以折衷用了如下方法:
> sudo apt-get install -t jessie-backports golang-1.6 golang-1.6-go golang-1.6-src golang-1.6-doc
> sudo apt-get install -t jessie-backports golang

示例

跑通官方示例代码:

package main

import (
	"github.com/brutella/hc"
	"github.com/brutella/hc/accessory"
	"log"
)

func main() {
	info := accessory.Info{
		Name:         "Lamp",
		SerialNumber: "051AC-23AAM1",
		Manufacturer: "Apple",
		Model:        "AB",
	}
	acc := accessory.NewSwitch(info)

	acc.Switch.On.OnValueRemoteUpdate(func(on bool) {
		if on == true {
			log.Println("Client changed switch to on")
		} else {
			log.Println("Client changed switch to off")
		}
	})

	config := hc.Config{Pin: "00102003"}
	t, err := hc.NewIPTransport(config, acc.Accessory)
	if err != nil {
		log.Fatal(err)
	}

	hc.OnTermination(func() {
		t.Stop()
	})

	t.Start()
}

编译执行.

$ AppleHome> # current dir

$ AppleHome> go get
...

$ AppleHome> go build
...

$ AppleHome> ./AppleHome
...

随后打开手机的 Home App,添加设备,选择 Lamp,输入 PIN 00102003,完成配对,即可使用。

自定义设备

树莓派外接小音箱一只,用来放电台,尝试用 HomeKit 控制树莓派的禁音。命令:

amixer set PCM on
amixer set PCM off

代码:

package main

import (
        "os/exec"
        "github.com/brutella/hc"
        "github.com/brutella/hc/accessory"
        "log"
)

func main() {
        info := accessory.Info{
                Name:            "Radio",
                SerialNumber: "051AC-23AAM2",
                Manufacturer: "Apple",
                Model:          "RPI3",
        }
        acc := accessory.NewSwitch(info)

        acc.Switch.On.OnValueRemoteUpdate(func(on bool) {
                log.Println("Toggled PCM!")

                if on == true {
                        exec.Command("amixer", "set", "PCM", "on").Run()
                        log.Println("Client changed switch to on")
                } else {
                        exec.Command("amixer", "set", "PCM", "off").Run()
                        log.Println("Client changed switch to off")
                }
        })

        config := hc.Config{Pin: "00102004"}
        t, err := hc.NewIPTransport(config, acc.Accessory)
        if err != nil {
                log.Fatal(err)
        }

        hc.OnTermination(func() {
                t.Stop()
        })

        t.Start()
}

其他

HAP 将智能家居分为以下维度:

  • Accessory: 单个设备,例如开关,温度计,调节器
  • Service: 一组值,合起来提供服务,例如中央空调(调节温度,风速等等)

TODO

]]>
猫·仁波切
Swift 3.0 尝试——从入门到再学一门(A Glimpse of Swift 3.0)2016-04-28T02:06:53+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2016/04/28/a-glimpse-of-swift-3-dot-0安装工具

https://github.com/kylef/swiftenv

Swift 3.0 新变化

以下内容来自 Swift 语言提案1

Swift 3.0 发布计划

Swift Package System

https://github.com/donald-pinckney/swift-packages

AST 结构

代码位于 include/swift/ASTlib/AST

ModuleDecl 模块(单个库或是可执行文件)。编译的最小单元,由多个文件组成。

FileUnit(抽象类) 文件作用域,是代码组织的最小单元。

  • DerivedFileUnit: A container for a module-level definition derived as part of an implicit protocol conformance.
  • SourceFile: A file containing Swift source code. .swift 或 .sil 也可以是虚拟 REPL
    • Imports: Vec<(ImportedModule, ImportOptions)>
    • Identifier
    • Decls: Vec
    • LocalTypeDecl: Vec
    • ObjCMethods: Map<ObjCSelector, AbstractFunctionDecl>
    • infix, postfix, prefix operators: OperatorMap
  • BuiltinUnit

swift 命令入口

入口函数 tools/driver/driver.cppmain 函数。

集成多个子工具。同时若 PATH 下有名为 swift-foobar 的可执行文件,则可通过 swift foobar 调用。

编译器前端 swift -frontend

编译。同时支持打印出各种编译时中间结果。

API Notes 功能 swift -apinotes

参考信息位于 https://github.com/apple/swift/tree/master/apinotes .

简单说,API Notes 机制就是通过 .apinotes 文件(YAML格式)描述 Objective-C Framework 和对应 Swift API 的关系。最终生成 .apinotesc 文件,与 .swiftmodule 文件一起作为 Swift 的模块。

主要功能包括且不限于:

  • SwiftBridge:设置对应的 Bridge 类型,例如 NSArray 对应与 Swift.Array
  • Nullability/NullabilityOfRet: 类的属性、方法的参数、返回值对应类型是否可以为 null,即对应与 Swift 的 T 还是 T?
  • Availability:方法是否在 Swift 中暴露,并给出 availability message
  • SwiftName:方法 Selector 在 Swift 中的重命名,例如 filteredArrayUsingPredicate: 替换为 filtered(using:)

Dump 为YAML文件:

$> swift -apinotes -binary-to-yaml /path/to/lib/swift/macosx/x86_64/Dispatch.apinotesc -o=-

Module Wrap 工具 swift -modulewrap

// Wraps .swiftmodule files inside an object file container so they
// can be passed to the linker directly. Mostly useful for platforms
// where the debug info typically stays in the executable.
// (ie. ELF-based platforms).

用法:

swift -modulewrap ObjectiveC.swiftmodule -o objc.o

实际发现是在 .o 里定义了 ___Swift_AST 符号。

REPL

Swift 提供了两个 REPL(Read-Evaluate-Print Loop),一个是 Swift 本身内置,另一个集成到了 lldb 命令行下。前者只有基本功能,即将废弃,后者功能更强大。

子命令分别是:

  • swift -deprecated-integrated-repl
  • swift -lldb-repl

swift -repl 子命令选择可用的 REPL 进入,一般是 lldb-repl,除非找不到 lldb 时。这也是 Swift 命令不带任何参数的默认行为。

  1. (apple/swift-evolution)[https://github.com/apple/swift-evolution] 

]]>
猫·仁波切
Rust Pattern Match(Rust中的模式匹配)2016-03-22T15:35:19+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2016/03/22/rust-pattern-match模式匹配

汉语字典中对“模式”的解释是:事物的标准样式。在计算机科学中,它指特定类型的数据(往往是序列或是树形结构)满足某一特定结构或格式。“匹配”本身是指一个判断寻找过程。最早的模式匹配用于文本编辑器中的正则字符串搜索,之后才作为编程语言特性。

模式匹配基础

模式匹配在计算机科学领域有两层意思。其一,可以特指字符串匹配算法,例如为人熟知的 KMP 字符串匹配算法、命令行工具 grep 等。 其二,特指在一些语言中作为一种以结构的方式处理数据的工具,此时的匹配过程往往是树形匹配,与此相伴的往往还有一个特性叫 guard(守卫)。

Rust 中模式匹配随处可见,例如在let变量绑定语句、match匹配语句中等。利用好模式匹配这一特性可以使代码更简洁易懂。Rust支持模式匹配中的变量绑定、结构体/元组解构、守卫条件判断、数值范围匹配等特性。

原始匹配

match 语句中可以直接匹配字面常量,下划线_匹配任意情形。

let x = 1;

match x {
    1 => println!("one"),
    2 => println!("two"),
    3 => println!("three"),
    _ => println!("anything"),
}

以上代码会打印出one

结构匹配

match 用于匹配一个表达式的值,寻找满足条件的子分支(arm)并执行。每个子分支包含三部分:一系列模式、可选的守卫条件以及主体代码块。

多个模式

每个子分支可以是多个模式,通过 | 符号分割:

let x = 1;

match x {
    1 | 2 => println!("one or two"),
    3 => println!("three"),
    _ => println!("anything"),
}

以上代码打印出one or two

守卫条件

通过if引入子分支的守卫条件:

enum OptionalInt {
    Value(i32),
    Missing,
}

let x = OptionalInt::Value(5);

match x {
    OptionalInt::Value(i) if i > 5 => println!("Got an int bigger than five!"),
    OptionalInt::Value(..) => println!("Got an int!"),
    OptionalInt::Missing => println!("No such luck."),
}

模式匹配进阶

其实进阶,不如直接从libsyntax源码看看到底模式匹配是如何实现。syntax::ast::Pat

从AST源码中寻找语法要素屋外户两个要点,其一,语法要素是如何表达为对应AST的;其二,对应AST在哪些父AST中出现。

Rust中使用syntax::ast::Pat枚举来表示一个模式匹配。

pub struct Pat {
    pub id: NodeId,
    pub node: PatKind,
    pub span: Span,
}

pub enum PatKind {
    /// Represents a wildcard pattern (`_`)
    /// 表示通配,下划线
    Wild,

    /// A `PatKind::Ident` may either be a new bound variable,
    /// or a unit struct/variant pattern, or a const pattern (in the last two cases
    /// the third field must be `None`).
    ///
    /// In the unit or const pattern case, the parser can't determine
    /// which it is. The resolver determines this, and
    /// records this pattern's `NodeId` in an auxiliary
    /// set (of "PatIdents that refer to unit patterns or constants").
    Ident(BindingMode, SpannedIdent, Option<P<Pat>>),

    /// A struct or struct variant pattern, e.g. `Variant {x, y, ..}`.
    /// The `bool` is `true` in the presence of a `..`.
    Struct(Path, Vec<Spanned<FieldPat>>, bool),

    /// A tuple struct/variant pattern `Variant(x, y, z)`.
    /// "None" means a `Variant(..)` pattern where we don't bind the fields to names.
    TupleStruct(Path, Option<Vec<P<Pat>>>),

    /// A path pattern.
    /// Such pattern can be resolved to a unit struct/variant or a constant.
    Path(Path),

    /// An associated const named using the qualified path `<T>::CONST` or
    /// `<T as Trait>::CONST`. Associated consts from inherent impls can be
    /// referred to as simply `T::CONST`, in which case they will end up as
    /// PatKind::Path, and the resolver will have to sort that out.
    QPath(QSelf, Path),

    /// A tuple pattern `(a, b)`
    Tup(Vec<P<Pat>>),
    /// A `box` pattern
    Box(P<Pat>),
    /// A reference pattern, e.g. `&mut (a, b)`
    Ref(P<Pat>, Mutability),
    /// A literal
    Lit(P<Expr>),
    /// A range pattern, e.g. `1...2`
    Range(P<Expr>, P<Expr>),
    /// `[a, b, ..i, y, z]` is represented as:
    ///     `PatKind::Vec(box [a, b], Some(i), box [y, z])`
    Vec(Vec<P<Pat>>, Option<P<Pat>>, Vec<P<Pat>>),
    /// A macro pattern; pre-expansion
    Mac(Mac),
}

以上AST定义,即说明,到底什么被认为是一个“模式”。

以下介绍Pat在哪些AST中出现。

全局 Item

全局 Item 中,使用模式匹配的均为函数参数。

ItemKind::Fn

Fn 全局函数 -> FnDecl 函数声明 -> [Arg] 函数头参数声明。

ItemKind::Trait

Trait -> [TraitItem] -> TraitItemKind::Method -> MethodSig -> FnDecl 方法声明,同上。

ItemKind::Impl

Impl -> [ImplItem] -> ImplItemKind::Method -> MethodSig -> FnDecl

ast::Stmt 语句

StmtKind::Decl

Decl -> DeclKind::Local

let 语句 let <pat>:<ty> = <expr>;

StmtKind::Expr 表达式

见下。

ast::Expr

match外,if letwhile letfor控制语句支持同时进行模式匹配。具体实现是一种desugared过程,即,去语法糖化。

同时类似于函数定义,闭包参数也支持模式匹配。

if let

IfLet(P<Pat>, P<Expr>, P<Block>, Option<P<Expr>>)

if let pat = expr { block } else { expr }

This is desugared to a match expression.

while let

WhileLet(P<Pat>, P<Expr>, P<Block>, Option<Ident>)

'label: while let pat = expr { block }

for

ForLoop(P<Pat>, P<Expr>, P<Block>, Option<Ident>)

'label: for pat in expr { block }

match

Match(P<Expr>, Vec<Arm>)

match 语句,在 Arm 中出现,其中 Arm 定义为

pub struct Arm {
    pub attrs: Vec<Attribute>,
    pub pats: Vec<P<Pat>>,
    pub guard: Option<P<Expr>>,
    pub body: P<Expr>,
}

闭包

Closure(CaptureBy, P<FnDecl>, P<Block>)

闭包,例如 move |a, b, c| {a + b + c}

相关 feature gate

advanced_slice_patterns - See the match expressions section for discussion; the exact semantics of slice patterns are subject to change, so some types are still unstable.

slice_patterns - OK, actually, slice patterns are just scary and completely unstable.

box_patterns - Allows box patterns, the exact semantics of which is subject to change.

参考

https://doc.rust-lang.org/book/patterns.html

]]>
猫·仁波切
广州实时工具App逆向2015-06-18T09:39:06+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2015/06/18/guangzhou-realtime-bus简记。用了 IDA Pro,安卓手机的 Remote 客户端。以及 apktool 等。

Github: guangzhou-realtime-bus

  • 生成 e=3 的 1024 位 RSA 密钥对
  • 公钥串用查表加密(byte 映射),然后 base64 封装发送给服务器
  • 服务器返回一串用公钥加密过的数据
  • 用本地私钥解密后,该数据包含未知96字节的一段数据和 DES Key
  • 从此通信用 DES 加密

base64封装过程:先打包字符串长度,然后是原始字符串(JSON),然后是0x10(md5字符串长度), 然后是 md5 校验值。整个二进制字符串用 base64 转码,POST 给服务器。

具体的登录注册过程还需要进一步抓包分析,不过暂时兴趣不在这里了。

]]>
猫·仁波切
北京实时公交分析2015-06-01T05:17:50+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2015/06/01/beijing-realtime-bus361 条线路,705条单向线路。 aibang 负责数据服务。

每辆车,每15秒更新一次 GPS,

整理成为 Repo andelf/beijing-realtime-bus.

]]>
猫·仁波切
为第三方扩展创建 Swift 模块2015-01-23T15:21:00+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2015/01/23/swift-3rd-library-install-as-swift-modules本文提出了一种将第三方扩展引入到 Swift 标准库的方法。

以 Alamofire 为例,

cd Path-To-Alamofire-Src-Dir
mkdir -p 32 64

# 创建动态链接库,及对应 Swift 模块,32/64版本
xcrun swiftc -sdk $(xcrun --show-sdk-path --sdk iphoneos) Alamofire.swift -target arm64-apple-ios7.1 -target-cpu cyclone -emit-library -emit-module -module-name Alamofire -v -o libswiftAlamofire.dylib -module-link-name swiftAlamofire -Xlinker -install_name -Xlinker @rpath/libswiftAlamofire.dylib

mv Alamofire.swiftdoc Alamofire.swiftmodule libswiftAlamofire.dylib ./64

xcrun swiftc -sdk $(xcrun --show-sdk-path --sdk iphoneos) Alamofire.swift -target armv7-apple-ios7.1 -target-cpu cyclone -emit-library -emit-module -module-name Alamofire -v -o libswiftAlamofire.dylib -module-link-name swiftAlamofire -Xlinker -install_name -Xlinker @rpath/libswiftAlamofire.dylib

mv Alamofire.swiftdoc Alamofire.swiftmodule libswiftAlamofire.dylib ./64

# 创建 universal lib
lipo -create ./{32,64}/libswiftAlamofire.dylib  -output ./libswiftAlamofire.dylib

# 创建模拟器用 lib
xcrun swiftc -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) Alamofire.swift -target i386-apple-ios7.1 -target-cpu yonah -emit-library -emit-module -module-name Alamofire -v -o libswiftAlamofire.dylib -module-link-name swiftAlamofire -Xlinker -install_name -Xlinker @rpath/libswiftAlamofire.dylib

其他相关 target

-target armv7-apple-ios7.1 -target-cpu cortex-a8
-target arm64-apple-ios7.1 -target-cpu cyclone
-target i386-apple-ios7.1 -target-cpu yonah
-target x86_64-apple-ios7.1 -target-cpu core2

其实你了解 Swift 模块结构的化,应该回想到,将第三方模块创建为 swiftmodule 应该是最靠谱的选择。不过实际操作发现, 编译命令无法很方便地调整,主要是因为 xcodebuild 系统,和编译命令不知道怎么导出。也是略纠结。

实际上,如果使用 Carthage 的话,即把第三方扩展作为 Framework 引入,会导致无法支持 iOS 7,但是 Swift 本身是支持 iOS 7 的, 在编译命令和生成的文件中检查发现,对于 iOS 7,Swift 使用了纯静态模块编译的方法。所以其实我们引入第三方扩展的时候也可以这样做。

以下是静态编译所需命令:

xcrun swift -sdk $(xcrun --show-sdk-path --sdk macosx) SwiftyJSON.swift -c -parse-as-library -module-name SwiftyJSON -v -o SwiftyJSON.o

ar rvs libswiftSwiftyJSON.a SwiftyJSON.o

如何使用?

将编译结果扔到:

/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift_static

下对应目录。

然后在 Xcode 里,直接 import。

]]>
猫·仁波切
Swift beta3 Changes ( Swift 在 beta3 中的变化)2014-07-08T09:22:11+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/07/08/swift-beta3-changes准确说是 beta2 Swift version 1.0 (swift-600.0.34.4.8) 到 beta3 Swift version 1.0 (swift-600.0.38.7) 的变化。

对了,补充下。 beta1 Swift version 1.0 (swift-600.0.34.4.5) 到 beta2 几乎没有什么变化。

语法

nil 成为关键字。

[KeyType : ValueType] 可以表示字典类型 Dictionary<KeyType, ValueType>

[Type] 用于表示原 Array 类型 Type[],等价 Array<T>,原用法会导致警告。

增加 @noinline 属性

.. 运算符改为 ..<,不容易和 ... 混淆。

函数、类型

sort() 改名为 sorted()。新增 sort() 函数,参数为 inout

Index 类型中的 .succ() 变为 .successor().pred() 变为 .predecessor()

C/ObjC 交互变化

增加 UnsafeMutableArray<T> 类型。

增加 CFunctionPointer<T> 类型。

删除 CConstVoidPointerCMutableVoidPointer。替换为 UnsafePointer<()>ConstUnsafePointer<Int32>

删除 CConstPointer<T>CMutablePointer<T>。替换为 UnsafePointer<T>ConstUnsafePointer<T>

这么一来指针操作简单了好多。原有会出现 COpaquePointer 的不合理情况,也都对应到适合的类型。

CString 可以从 UnsafePointer<UInt8>UnsafePointer<CChar> 两种类型构造获得,之前只支持 UInt8

module.map 中头文件声明转换为 Swift 声明不再使用 C 兼容类型,直接使用 Swift 相应类型。原有 CInt,现在成为 Int32

结构体会自动添加构造函数 init(field1:field2:...) 这样。

nil

去掉了 NilType,增加了 NilLiteralConvertiblenil 成为关键字。可以认为是 nil 常量。

protocol NilLiteralConvertible {
  class func convertFromNilLiteral() -> Self
}

除了 Optional 、上面所提到的指针类型外,RawOptionSet 也实现了该协议。

Array

去掉了 .copy()unshare() 方法。

增加了以下方法:

func makeUnique(inout buffer: ArrayBuffer<T>, e: T, index: Int)
func sorted(isOrderedBefore: (T, T) -> Bool) -> Array<T>

看起来 Array 对底层容器的引用有了更好的控制 ArrayBufferType 增加了判断方法 func isMutableAndUniquelyReferenced() -> Bool

Array 目前可以认为是真正的值类型。

指针

增加了 _Pointer protocol

protocol _Pointer {
  var value: RawPointer { get }
  init(_ value: RawPointer)
}

表示一个类型可以对应到原生指针。

同时成为内部桥接类型,编译器内部在转换时使用它(取出 RawPointer, 构造具体指针类型)。

模块

增加了 StdlibUnittest 模块。 声明代码。单元测试终于有了。

]]>
猫·仁波切
Use Swift Dynamic Framework (如何科学地引用第三方 Swift 库)2014-07-07T04:09:54+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/07/07/use-swift-dynamic-library排名 16 了。啧啧。你看才刚出一个月。

目前已经有了很多非常棒的 Swift 第三方库, JSON 处理啊、 HTTP 访问啊、 UIView 插件啊等等。

如何科学地引用这些第三方库呢?

现状

CocoaPods 由于完全使用静态链接解决方法,过度依赖 Objective-C ,目前应该是官方 repo 有提到是 -Xlinker error , 这个问题之前我也遇到过,无解。除非手工执行 ar 不用 ldlibtool

小伙伴有用子目录的方法引用代码,貌似不错,还有就是直接用 git submodule,看起来维护性也可以。

简单解决方案

一个良好的第三方库应该实现为 Cocoa Touch Framework (实际内容为 Header + 动态链接库)。而不是直接把 Swift 代码 Copy 过来放入自己的项目。这里以一个简单项目为例,介绍如何科学使用。

目标描述

用 Swift 创建一个 Demo ,使用 SwiftyJSON 和 LTMorphingLabel 库。

项目的名字叫 DemoApp 。

创建 Workspace

创建一个 Workspace ,名字随意,位置能找到就好。这个 Workspace 主要用来管理我们的项目及其依赖的第三方库。

创建 DemoApp

在 Workspace 创建一个 App ,因为是测试所以我选了 Single View Application 。

引入 SwiftyJSON

SwiftyJSON 是一个 Cocoa Touch Framework ,可以直接使用, git clone 后,添加项目到 Workspace 即可。

尝试操作发现。。最容易最不会出错的方法就是直接从 Finder 里把 .xcodeproj 文件拖动到 Workspace 。

引入 LTMorphingLabel

LTMorphingLabel 是一个 App Deme 式项目。其中 Label View 的实现在一个子目录中。可以采用创建 Cocoa Touch Framework 的方法来引入这几个文件。

当然也可以直接把目录拖到我们的 DemoApp 里,不过太原始粗暴了。

为 App 添加依赖

在 DemoApp 的 Genral 选项卡中,添加 Linked Frameworks and Libraries 。选择 Workspace 中 SwiftyJSON 和 LTMorphingLabel 两个 .framework

如果是直接选择来自其他项目的 .framework 而不是同一 Workspace ,那么这里也许还要同时加入 Embedded Binaries

使用

添加好依赖后,就可以在 DemoApp 项目代码中 import SwiftyJSON 或者 import LTMorphingLabel 来使用对应的库。同时还可以用 Command + 鼠标点击的方法查看声明代码。

除错

比较坑爹的是,实际上按照以上方法, LTMorphingLabel 并不能正常使用,查看报错信息发现是自动生成的 LTMorphingLabel-Swift.h 有处语法无法被识别,编辑器找到 .h 文件,注释掉这行诡异代码即可。

看起来目前的 Bridge Header 和 -emit-objc-header 实现还是有问题的。小伙伴一定要淡定。

对于非 Workspace

如果不喜欢使用 Workspace ,也可以将第三方库的编译结果,一个 .framework 目录拖到项目文件里,然后添加 Embedded Binaries

评论

创建 Cocoa Touch Framework 选项中,可以使用 Swift 代码,此时编译结果(默认)会包含 module.modulemap 文件, 之前有介绍过它的作用,通过它, Swift 可以使用第三方模块。参考 Module System of Swift (简析 Swift 的模块系统)

实际上这个解决方案绕了一大圈,通过 Swift 文件导出 ProjName-Swift.h、然后 module.modulemap 模块描述文件引入、然后再由 Swift 导入。

其实 .framework 同时也包含了 ProjName.swiftmodule/[ARCH].swiftmodule 不过看起来没有使用到,而且默认在 IDE 下也不支持 Swift 从 .swiftmodule 文件导入,比较坑。希望以后版本能加入支持。

.framework 包含了所有 Swift 标准库的动态链接库,小伙伴可能会以为这会导致编译后的 App 变大。其实大可放心,任何 Swift 语言的 App 都会包含这些动态链接库,而且只会包含一个副本。此方法对 App 最终的大小几乎无影响。

注: 个人测试了下,发现这个 .swiftmodule 是可以通过其他方法使用的,绕过 module.modulemap,应该是更佳的解决方案,但是需要控制命令行参数。

至于静态链接库,过时了。抛弃吧。

参考

]]>
猫·仁波切
Swift Undocumented Grammar (Swift 黑语法)2014-07-03T19:05:11+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/07/03/swift-undocumented-grammar本文介绍 Swift 的 Undocumented 语法特性。

电子书上介绍的 default function parameter 这里都不好意思拿出来写。

咳咳。持续更新。

用关键字当变量名

Keywards as variable name.

// escaped variable name
let `let` = 1000
dump(`let`, name: "variable named let")

new 关键字

The new keyword.

快速初始化数组。

let an_array_with_100_zero = new(Int)[100]

protocol type

use protocol<Protocol1, Protocol2, ...> as a type.

How I find it?

瞎试出来的。

]]>
猫·仁波切
Cocoa Extensions in Swift ( Cocoa 在 Swift 中所添加的扩展)2014-07-03T17:05:55+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/07/03/cocoa-in-swift最近看到了 Swift Style Guide 个人觉得内容太少, Swift 本身作为一门庞大的语言,语素众多。本文就 Swift 本身对 Cocoa 的扩展,看看对日常 Cocoa 风格有什么影响。

Swift 本身的特性,导致它在一些用法上和 Objective-C 上有所不同,比如 ObjC 的 struct 单纯和 C 的一样,但是在 Swift 中的 struct 则要强大得多。

个人认为比如 CGPointMake 这样的函数,理论上不应该出现在 Swift 代码中。而是应该用 CGPoint(x:y:)

本文可以作为参考手册使用。

标准库扩展

ObjectiveC

值得注意的是 Selector 相关方法,实现了 StringLiteralConvertible。也可以从 nil 获得。

Foundation

这里忽略之前介绍过的 _BridgedToObjectiveC 相关内容。

协议附加

Sequence 协议

NSMutableArray NSSet NSArray NSMutableDictionary NSMutableSet NSDictionary

所有以上这些类型都可以通过 for-in 操作。

*LiteralConvertible

NSNumber NSString NSArray NSDictionary

隐式类型转换

CF 几乎都对应到了 NS 类型。这里略去

  • NilType -> NSZone
  • Dictionary<KeyType: Hashable, ValueType> -> NSDictionary
  • NSDictionary -> Dictionary<NSObject, AnyObject>
  • String <-> NSString
  • NSArray -> AnyObject[]
  • A[] -> NSArray
  • Float Double Int UInt Bool -> NSNumber
  • NSRange -> Range<Int> // 比较有意思的一个

方法扩展

// let s = NSSet(objects: 12, 32, 23, 12)
extension NSSet {
  convenience init(objects elements: AnyObject...)
}
extension NSOrderedSet {
  convenience init(objects elements: AnyObject...)
}
// 这里注意,NSRange 和 Swift Range 对 range 结束的表述方法不同
// NSRange 保存 range 元素个数
// Swift Range 保存的是结束元素
// let r = NSRange(0..20)
extension NSRange {
  init(_ x: Range<Int>)
}
// let prop = NSDictionary(objectsAndKeys: "Feather", "name", "Programming", "hobby")
extension NSDictionary {
  convenience init(objectsAndKeys objects: AnyObject...)
}
extension NSObject : CVarArg {
  @objc func encode() -> Word[]
}

字符串的扩展方法非常多。

  static func availableStringEncodings() -> NSStringEncoding[]
  static func defaultCStringEncoding() -> NSStringEncoding
  static func localizedNameOfStringEncoding(encoding: NSStringEncoding) -> String
  static func localizedStringWithFormat(format: String, _ arguments: CVarArg...) -> String
  static func pathWithComponents(components: String[]) -> String
  static func stringWithContentsOfFile(path: String, encoding enc: NSStringEncoding, error: NSErrorPointer = default) -> String?
  static func stringWithContentsOfFile(path: String, usedEncoding: CMutablePointer<NSStringEncoding> = default, error: NSErrorPointer = default) -> String?
  static func stringWithContentsOfURL(url: NSURL, encoding enc: NSStringEncoding, error: NSErrorPointer = default) -> String?
  static func stringWithContentsOfURL(url: NSURL, usedEncoding enc: CMutablePointer<NSStringEncoding> = default, error: NSErrorPointer = default) -> String?
  static func stringWithCString(cString: CString, encoding enc: NSStringEncoding) -> String?
  static func stringWithUTF8String(bytes: CString) -> String?
  func canBeConvertedToEncoding(encoding: NSStringEncoding) -> Bool
  var capitalizedString: String { get }
  func capitalizedStringWithLocale(locale: NSLocale) -> String
  func caseInsensitiveCompare(aString: String) -> NSComparisonResult
  func commonPrefixWithString(aString: String, options: NSStringCompareOptions) -> String
  func compare(aString: String, options mask: NSStringCompareOptions = default, range: Range<String.Index>? = default, locale: NSLocale? = default) -> NSComparisonResult
  func completePathIntoString(_ outputName: CMutablePointer<String> = default, caseSensitive: Bool, matchesIntoArray: CMutablePointer<String[]> = default, filterTypes: String[]? = default) -> Int
  func componentsSeparatedByCharactersInSet(separator: NSCharacterSet) -> String[]
  func componentsSeparatedByString(separator: String) -> String[]
  func cStringUsingEncoding(encoding: NSStringEncoding) -> CChar[]?
  func dataUsingEncoding(encoding: NSStringEncoding, allowLossyConversion: Bool = default) -> NSData
  var decomposedStringWithCanonicalMapping: String { get }
  var decomposedStringWithCompatibilityMapping: String { get }
  func enumerateLines(body: (line: String, inout stop: Bool) -> ())
  func enumerateLinguisticTagsInRange(range: Range<String.Index>, scheme tagScheme: String, options opts: NSLinguisticTaggerOptions, orthography: NSOrthography?, _ body: (String, Range<String.Index>, Range<String.Index>, inout Bool) -> ())
  func enumerateSubstringsInRange(range: Range<String.Index>, options opts: NSStringEnumerationOptions, _ body: (substring: String, substringRange: Range<String.Index>, enclosingRange: Range<String.Index>, inout Bool) -> ())
  var fastestEncoding: NSStringEncoding { get }
  func fileSystemRepresentation() -> CChar[]
  func getBytes(inout buffer: UInt8[], maxLength: Int, usedLength: CMutablePointer<Int>, encoding: NSStringEncoding, options: NSStringEncodingConversionOptions, range: Range<String.Index>, remainingRange: CMutablePointer<Range<String.Index>>) -> Bool
  func getCString(inout buffer: CChar[], maxLength: Int, encoding: NSStringEncoding) -> Bool
  func getFileSystemRepresentation(inout buffer: CChar[], maxLength: Int) -> Bool
  func getLineStart(start: CMutablePointer<String.Index>, end: CMutablePointer<String.Index>, contentsEnd: CMutablePointer<String.Index>, forRange: Range<String.Index>)
  func getParagraphStart(start: CMutablePointer<String.Index>, end: CMutablePointer<String.Index>, contentsEnd: CMutablePointer<String.Index>, forRange: Range<String.Index>)
  var hash: Int { get }
  static func stringWithBytes(bytes: UInt8[], length: Int, encoding: NSStringEncoding) -> String?
  static func stringWithBytesNoCopy(bytes: CMutableVoidPointer, length: Int, encoding: NSStringEncoding, freeWhenDone flag: Bool) -> String?
  init(utf16CodeUnits: CConstPointer<unichar>, count: Int)
  init(utf16CodeUnitsNoCopy: CConstPointer<unichar>, count: Int, freeWhenDone flag: Bool)
  init(format: String, _ _arguments: CVarArg...)
  init(format: String, arguments: CVarArg[])
  init(format: String, locale: NSLocale?, _ args: CVarArg...)
  init(format: String, locale: NSLocale?, arguments: CVarArg[])
  var lastPathComponent: String { get }
  var utf16count: Int { get }
  func lengthOfBytesUsingEncoding(encoding: NSStringEncoding) -> Int
  func lineRangeForRange(aRange: Range<String.Index>) -> Range<String.Index>
  func linguisticTagsInRange(range: Range<String.Index>, scheme tagScheme: String, options opts: NSLinguisticTaggerOptions = default, orthography: NSOrthography? = default, tokenRanges: CMutablePointer<Range<String.Index>[]> = default) -> String[]
  func localizedCaseInsensitiveCompare(aString: String) -> NSComparisonResult
  func localizedCompare(aString: String) -> NSComparisonResult
  func localizedStandardCompare(string: String) -> NSComparisonResult
  func lowercaseStringWithLocale(locale: NSLocale) -> String
  func maximumLengthOfBytesUsingEncoding(encoding: NSStringEncoding) -> Int
  func paragraphRangeForRange(aRange: Range<String.Index>) -> Range<String.Index>
  var pathComponents: String[] { get }
  var pathExtension: String { get }
  var precomposedStringWithCanonicalMapping: String { get }
  var precomposedStringWithCompatibilityMapping: String { get }
  func propertyList() -> AnyObject
  func propertyListFromStringsFileFormat() -> Dictionary<String, String>
  func rangeOfCharacterFromSet(aSet: NSCharacterSet, options mask: NSStringCompareOptions = default, range aRange: Range<String.Index>? = default) -> Range<String.Index>
  func rangeOfComposedCharacterSequenceAtIndex(anIndex: String.Index) -> Range<String.Index>
  func rangeOfComposedCharacterSequencesForRange(range: Range<String.Index>) -> Range<String.Index>
  func rangeOfString(aString: String, options mask: NSStringCompareOptions = default, range searchRange: Range<String.Index>? = default, locale: NSLocale? = default) -> Range<String.Index>
  var smallestEncoding: NSStringEncoding { get }
  func stringByAbbreviatingWithTildeInPath() -> String
  func stringByAddingPercentEncodingWithAllowedCharacters(allowedCharacters: NSCharacterSet) -> String
  func stringByAddingPercentEscapesUsingEncoding(encoding: NSStringEncoding) -> String
  func stringByAppendingFormat(format: String, _ arguments: CVarArg...) -> String
  func stringByAppendingPathComponent(aString: String) -> String
  func stringByAppendingPathExtension(ext: String) -> String
  func stringByAppendingString(aString: String) -> String
  var stringByDeletingLastPathComponent: String { get }
  var stringByDeletingPathExtension: String { get }
  var stringByExpandingTildeInPath: String { get }
  func stringByFoldingWithOptions(options: NSStringCompareOptions, locale: NSLocale) -> String
  func stringByPaddingToLength(newLength: Int, withString padString: String, startingAtIndex padIndex: Int) -> String
  var stringByRemovingPercentEncoding: String { get }
  func stringByReplacingCharactersInRange(range: Range<String.Index>, withString replacement: String) -> String
  func stringByReplacingOccurrencesOfString(target: String, withString replacement: String, options: NSStringCompareOptions = default, range searchRange: Range<String.Index>? = default) -> String
  func stringByReplacingPercentEscapesUsingEncoding(encoding: NSStringEncoding) -> String
  var stringByResolvingSymlinksInPath: String { get }
  var stringByStandardizingPath: String { get }
  func stringByTrimmingCharactersInSet(set: NSCharacterSet) -> String
  func stringsByAppendingPaths(paths: String[]) -> String[]
  func substringFromIndex(index: Int) -> String
  func substringToIndex(index: Int) -> String
  func substringWithRange(aRange: Range<String.Index>) -> String
  func uppercaseStringWithLocale(locale: NSLocale) -> String
  func writeToFile(path: String, atomically useAuxiliaryFile: Bool, encoding enc: NSStringEncoding, error: NSErrorPointer = default) -> Bool
  func writeToURL(url: NSURL, atomically useAuxiliaryFile: Bool, encoding enc: NSStringEncoding, error: NSErrorPointer = default) -> Bool

CoreGraphics

几个常用基本类型都有了 Swift-style 的构造函数。其中 CGRect 有很多的相关运算都被封装为方法,很不错。

extension CGPoint : Equatable {
  static var zeroPoint: CGPoint
  init()
  init(x: Int, y: Int)
}
extension CGSize {
  static var zeroSize: CGSize
  init()
  init(width: Int, height: Int)
}
extension CGVector {
  static var zeroVector: CGVector
  init(_ dx: CGFloat, _ dy: CGFloat)
  init(_ dx: Int, _ dy: Int)
}
extension CGRect : Equatable {
  // 全为 0
  static var zeroRect: CGRect
  // 原点为无穷大,表示空
  static var nullRect: CGRect
  // 原点无穷小,宽高无穷大
  static var infiniteRect: CGRect
  init()
  init(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)
  init(x: Int, y: Int, width: Int, height: Int)
  var width: CGFloat
  var height: CGFloat
  var minX: CGFloat
  var minY: CGFloat
  // 中点
  var midX: CGFloat
  var midY: CGFloat
  var maxX: CGFloat
  var maxY: CGFloat
  var isNull: Bool
  var isEmpty: Bool
  var isInfinite: Bool
  var standardizedRect: CGRect
  func standardize()
  var integerRect: CGRect
  func integerize()
  func rectByInsetting(#dx: CGFloat, dy: CGFloat) -> CGRect
  func inset(#dx: CGFloat, dy: CGFloat)
  func rectByOffsetting(#dx: CGFloat, dy: CGFloat) -> CGRect
  func offset(#dx: CGFloat, dy: CGFloat)
  func rectByUnion(withRect: CGRect) -> CGRect
  func union(withRect: CGRect)
  func rectByIntersecting(withRect: CGRect) -> CGRect
  func intersect(withRect: CGRect)
  func rectsByDividing(atDistance: CGFloat, fromEdge: CGRectEdge) -> (slice: CGRect, remainder: CGRect)
  func contains(rect: CGRect) -> Bool
  func contains(point: CGPoint) -> Bool
  func intersects(rect: CGRect) -> Bool
}

AppKit

extension NSGradient {
  convenience init(colorsAndLocations objects: (AnyObject, CGFloat)...)
}

UIKit

extension UIDeviceOrientation {
  var isPortrait: Bool
  // also isLandscape isValidInterfaceOrientation isFlat
}
extension UIInterfaceOrientation {
  var isPortrait: Bool
  var isLandscape: Bool
}

这个模块是交叉编译的。。不太容易获得信息。不过好在扩展内容不多。

SpriteKit

extension SKNode {
  @objc subscript (name: String) -> SKNode[] { get }
}

特殊 Mirror 实现

NSSet NSDate NSArray NSRange NSURL NSDictionary NSString
CGPoint CGRect CGSize
NSView
UIView
SKTextureAtlas SKTexture SKSpriteNode SKShapeNode

单独添加了自己的 Mirror 类型,单独实现。

Mirror 类型其实是为 QuickLookObject 准备的,也就是在 Xcode Playground 中快速查看。

]]>
猫·仁波切
Swift Type Hierarchy ( Swift 类型层次结构 )2014-06-30T07:10:41+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/30/swift-type-hierarchy声明: 转载请注明,方便的情况下请知会本人. weibo

本文主要介绍 Swift 所有标准库类型的层次结构,及所有标准类型。本文可作为参考手册使用。

本人不保证内容及时性和正确性,请善于怀疑并反馈。谢谢。

本文探索 Swift 所有基础类型和高级类型,以及所有协议和他们之间的继承关系。

为了简化问题,某些类型略去了中间的过渡类型,人肉保证不歧义。

Swift 基础类型

数值类型

Bit

只有一位,实现为 enum.zero.one。简单明了。

协议: RandomAccessIndex IntegerArithmetic

整型

有符号:

Int Int8 Int16 Int32 Int64

协议:SignedInteger RandomAccessIndex BitwiseOperations SignedNumber CVarArg

无符号:

UInt UInt8 UInt16 UInt32 UInt64

协议:UnsignedInteger RandomAccessIndex BitwiseOperations

别名:

IntMax = Int64
UIntMax = UInt64
IntegerLiteralType = Int
Word = Int // 字长
UWord = UInt

浮点型

Float Double Float80

别名:

FloatLiteralType = Double
Float64 = Double

协议:FloatingPointNumber

逻辑型

只有一个 Bool

实例: truefalse

协议:LogicValue

只有一个 NilType

唯一实例 nil

字符(串)类型

  • String
  • Character Unicode 字符
  • UnicodeScalar 相当于 C 中的 wchar_t
  • CString 用于表示 C 中的 const char *,请参考相关文章
  • StaticString 静态字符串,内部使用,例如 fatalError

别名:

StringLiteralType = String
ExtendedGraphemeClusterType = String

官方文档

Character represents some Unicode grapheme cluster as defined by a canonical, localized, or otherwise tailored segmentation algorithm.

String 实现协议:Collection ExtensibleCollection OutputStream TargetStream

Array 类型

  • Array<T>
  • ContiguousArray<T>

实现协议 ArrayType

内部容器:

  • ArrayBuffer<T>
  • ContiguousArrayBuffer<T>

这两个类型看起来是 Array 的内部容器,一般不应该直接使用。

字典类型

Dictionary<KeyType : Hashable, ValueType>

只实现了 Collection

元祖类型

除正常元祖外,还有个特殊的别名

Void = ()

其实很多语言都这么定义的,比如 Haskell 。

Optional 类型

  • Optional<T>T?
  • ImplicitlyUnwrappedOptional<T>T!

实现协议: LogicValue,行为是判断是否为 .None

另外 Swift 的隐式类型转换 有提到,为什么 nil 可以给 Optional 类型赋值的问题。

C/ObjC 兼容类型

CBool = Bool
CFloat = Float
CDouble = Double
CChar = Int8
CSignedChar = Int8
CUnsignedChar = UInt8
CChar16 = UInt16
CWideChar = UnicodeScalar
CChar32 = UnicodeScalar
CInt = Int32
CUnsignedInt = UInt32
CShort = Int16
CUnsignedShort = UInt16
CLong = Int
CUnsignedLong = UInt
CLongLong = Int64
CUnsignedLongLong = UInt64

具体使用参考 C 交互的几篇文章,基本没区别。

Any 类型

AnyObject
// 别名
Any = protocol<>
AnyClass = AnyObject.Type

还有个用在函数定义的类型签名上, Any.Type

顺便这里看到一个奇异的语法 protocol<>,这个也是 Swift 一种用来表示类型限制的方法,可以用在类型的位置,尖括号里可以是协议的列表。

指针类型

UnsafePointer<T>
CMutableVoidPointer
CConstVoidPointer
COpaquePointer
CConstPointer<T>
AutoreleasingUnsafePointer<T>
CVaListPointer
CMutablePointer<T>

参考 C 交互文章。

其他辅助类型

多了去了。比如 for-in 实现时候的 Generator 、比如反射时候用的 *Mirror、比如切片操作用的 Range<T>。比如内部储存类。

还有储存辅助类 OnHeap<T> 等等。以后有机会再探索。

Swift 标准库协议

打印相关 Printable DebugPrintable

protocol Printable {
  var description: String { get }
}
protocol DebugPrintable {
  var debugDescription: String { get }
}

用于打印和字符串的 Interpolation 。

*LiteralConvertible

从字面常量获取。

ArrayLiteralConvertible
IntegerLiteralConvertible
DictionaryLiteralConvertible
CharacterLiteralConvertible
FloatLiteralConvertible
ExtendedGraphemeClusterLiteralConvertible
StringLiteralConvertible

其中字符串和字符的字面常量表示有所重合,也就是说 "a" 可以是字符串也可以是字符。简析 Swift 中的 Pattern Match 一文中就是遇到了类似的情况。

LogicValue

相当于重载 ifwhile 的行为。

protocol LogicValue {
  func getLogicValue() -> Bool
}

Sequence

相当于重载 for-in 。和 Generator 联用。

protocol Sequence {
  typealias GeneratorType : Generator
  func generate() -> GeneratorType
}

protocol Generator {
  typealias Element
  mutating func next() -> Element?
}
// for .. in { }
var __g = someSequence.generate()
while let x = __g.next() {
    ...
}}

整型、 Index 相关协议

这些协议都是用来表示容器类型的索引、及相关的索引运算。

这里略去了部分私有内容。略去了 Printable 等。

Swift Integer Type Hierarchy

RawOptionSet 相关协议

一般用来表示二进制的选项,类似于 C enum ,很多 Cocoa 的 flag 被映射到它。相当于一个 Wrapper 的作用。

Swift RawOptionSet

可以看到要求被 Wrap 的对象支持 BitwiseOperations

Array 相关协议

图中用虚线标注了和 Generator 的关系。

Swift Collection Protocol

Array<T> 类型实现了 ArrayType 协议。

Dictionary 类型实现了 Collection 协议。

反射相关协议

包括 MirrorMirrorDispositionReflectable

请参考 Swift 的反射

浮点数协议

只有一个 FloatingPointNumber。单独存在。是为了定义完整而存在。看官自己搞定。

IO 输出,伪输出相关

protocol Streamable {
  func writeTo<Target : OutputStream>(inout target: Target)
}

Streamable 表示可以被写入到输出流中,比如字符串、字符等。

protocol OutputStream {
  func write(string: String)
}

OutputStream 表示一个输出流,比如标准输出(stdout),也可以表示一个伪输出流,例如字符串 String

标准输出的获取方法

var stdout = _Stdout()

看起来是私有结构,某一天不能用的话,别怪我。调用时候用 inout 引用语法。

CVarArg 处理

protocol CVarArg {
  func encode() -> Word[]
}

用于处理 C 函数的可变参数,参考 简析 Swift 和 C 的交互,Part 二

Bridge 协议

这里有个疑问就是编译过程中这些 Bridge 协议有没有参与。目前还没办法确定。

  • _BridgedToObjectiveC
  • _ConditionallyBridgedToObjectiveC

具体内容可以参考 Swift 与 Objective-C 之间的交互一文。

其他

Sink 看起来是一个容器,可能是用来编码时使用。

ArrayBufferType 用于表示 ArrayType 的内部储存,看起来似乎也可以直接用。

UnicodeCodec 用于处理编码。有 UTF8UTF16UTF32 可用。

ArrayBound 用来处理数组边界,详细原理和作用过程未知。

总结

无。

参考:

]]>
猫·仁波切
My View of Swift (闲扯对 Swift 语言的看法)2014-06-30T03:05:13+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/30/my-view-of-swift其实这是很早就像要说的了,大概当时信誓旦旦说要看完那本 epub 写个读后感谈谈对 Swift 看法什么的。后来不了了之。 现在觉得这个时机或许差不多,对 Swift 的了解也算凑合了。

纯个人观点。

Swift 是系统编程语言

一开始大家还不太了解的时候,可能会有很多误解。现在好歹一个月了。误解终于少了。

是的, Swift 是系统编程语言,原因是因为它 ABI 兼容 C (不包括 name mangling 部分)。基于强大的 llvm 生成具体平台代码。不是翻译为 Objective-C 的。

编译器参数还显示, Swift 文件的中间编译结果(介于 Swift 代码和 llvm ir )是 SIL ,猜测是 Swift Intermediate Language 。好像和 llvm ir 有所联系。而且至少有两个 stage 。

不是脚本语言,也不是胶水语言。但是它的标准库 (import Swift 库) 几乎不含任何 IO 网络 等内容,随便做个功能强依赖 Cocoa 框架。也可以 import Darwin 用 C 语言的标准库来写。

猜测写个 Python C 模块这种任务是可以轻易胜任的。

而 Golang 、 Rust 本身 ABI 是和 C 不兼容的。虽然 Rust 通过 extern "C" 可以修改单个函数为兼容。

Swift 是现代语言

自动类型推导、泛型、 LLVM 。当然语言研究党都知道这些都是几十年前的“新东西”。

Swift 是半完成品

这么说主要是指 Swift 对 Cocoa 的库实在是太半吊子了。只是 Foundation 有 Bridge 支持,其他库中,明显的列表都无法支持 subscript 、 for-in 这样简单的操作。原因很简单,这些库都是自动转换 ObjC 头文件而来(参考模块那篇文章)。没有额外的封装代码。

所以其实真要用起来,可能会很纠结。或者可以预计很快就有第三方的 Bridge 库给这些类型加上舒服的 Swift 支持。

另外命令行没有静态链接库支持。只能用其他命令拼装。也侧面说明, Apple 希望开发者更多用动态链接库, Framework 。

另外目前的编译器 coredump 、 stackoverflow 太多太多。错哪都不知道。

Swift 隐藏细节太多

就对应到 Foundation 类型这个特性太说,太多黑魔法,隐式类型转换、 BridgeToObjectiveC 协议、指针类型转换。

这些隐藏的特性多少都会成为 Swift 的坑。

要知道定义在 ObjC 库的 NSString 参数某些情况下在 Swift 中被转换为 StringNSArray 都被转换为 AnyObject[]。即使有隐式类型转换,某些极端情况下,还是会有编译时错误。

Swfit 的性能

我没做过测试,但就语言特性来说, Swift 是比 ObjC 快的,因为静态类型使得他在编译时就已经知道调用函数的具体位置。而不是 Objective-C 的消息发送、 Selector 机制。

目前来看, Swift 性能略差原因主要是编译器还没足够优化、还有就是 Cocoa 拖了后腿, Cocoa 本身有大量的 AnyObject 返回值。所以实际写 Swift 代码时,多用 as 一定是好习惯。明确类型。

Swift 的未来

我不知道。至少好像感觉很多培训机构都看到了前途开始疯狂的做视频。

倒是觉得什么时候 Cocoa for Swift 出了才算它完全完成任务。

总觉得 Cocoa 拖后腿,不然放到其他平台也不错。

对了,之前不是在 App 开发领域,这才知道原来这个地盘水很深,太多唯利的培训机构,太多嗷嗷待哺等视频教程的新人。觉得挺有意思。就拿 ? ! 这个 Optional 为例,太多介绍的文章。可惜能说明白的太少太少。糊里糊涂做开发就是当前现状吧。

]]>
猫·仁波切
Swift Interop with C/ObjC Part 3 (Swift 与 ObjC 和 C 的交互,第三部分)2014-06-28T13:58:35+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/28/swift-interop-with-c-slash-objc声明: 转载请注明,方便的情况下请知会本人. weibo

之前说那是最后一篇。可惜越来越发现有很多东西还没介绍到。事不过三。再坑一篇。

前言

本文解决如下问题

  • ObjC/C 中定义的某个类型、结构体,通过 Bridge Header 或者 Module 对应到 Swift 到底是什么类型
  • 指针间的转换问题

补充之前没解决的一些问题,比如提到 CMutablePointersizeof 是两个字长,那么在函数调用中是如何对应到 C 的指针的?

预备内容:

C/ObjC to Swift 对应规则

以下内容均适合 Objective-C 。第一部分适合 C 。

以下内容再 Xcode6-beta3 中不适用 请参考 Swift 在 Xcode6-beta3 中的变化

for C

可导出的类型定义

函数、枚举、结构体、常量定义、宏定义。

结构体定义支持:

typedef struct Name {...} Name;
typedef struct Name_t {...} Name;
struct Name { ... };

其中无法处理的结构体、函数类型、 varargs 定义不导出。预计以后版本会修复。带 bit field 的结构体也无法识别。

类型对应关系

仔细分析发现,诡异情况还很多。基础类型请参考上几篇。

在函数定义参数中:

类型 对应为
void * CMutableVoidPointer
Type *Type[] CMutablePointer<Type>
const char * CString
const Type * CConstPointer<Type>
const void * CConstVoidPointer

在函数返回、结构体字段中:

类型 对应为
const char * CString
Type *const Type * UnsafePointer<Type>
void *const void * COpaquePointer
无法识别的结构指针 COpaquePointer

另外还有如下情况:

全局变量、全局常量(const)、宏定义常量(#define) 均使用 var,常量不带 set

结构体中的数组,对应为元祖,例如 int data[2] 对应为 (CInt, CInt),所以也许。。会很长。数组有多少元素就是几元祖。

for ObjC

ObjC 明显情况要好的多,官方文档也很详细。

除了 NSError ** 转为 NSErrorPointer 外,需要注意的就是:

函数参数、返回中的 NSString * 被替换为 String!NSArray * 被替换为 AnyObject[]!

而全局变量、常量的 NSString * 不变。

关于 CMutablePointer 的行为

上回说到 CMutablePointerCConstPointerCMutableVoidPointerCConstVoidPointer 四个指针类型的字长是 2,也就是说,不可以直接对应为 C 中的指针。但是前面说类型对应关系的时候, C 函数声明转为 Swift 时候又用到了这些类型,所以看起来自相矛盾。仔细分析了 lldb 反汇编代码后发现,有如下隐藏行为:

in Swift

在纯 Swift 环境下,函数定义等等、这些类型字长都为 2,不会有任何意外情况出现。

in C/ObjC

当一个函数的声明是由 Bridge Header 或者 LLVM Module 隐式转换而来,且用到了这四个指针类型,那么代码编译过程中类型转换规则、隐式转换调用等规则依然有效。只不过在代码最生成一步,会插入以下私有函数调用之一:

@transparent func _convertCMutablePointerToUnsafePointer<T>(p: CMutablePointer<T>) -> UnsafePointer<T>
@transparent func _convertCConstPointerToUnsafePointer<T>(p: CConstPointer<T>) -> UnsafePointer<T>
@transparent func _convertCMutableVoidPointerToCOpaquePointer(p: CMutableVoidPointer) -> COpaquePointer
@transparent func _convertCConstVoidPointerToCOpaquePointer(p: CConstVoidPointer) -> COpaquePointer

这个过程是背后隐藏的。然后将转换的结果传参给对应的 C/ObjC 函数。实现了指针类型字长正确、一致。

结论

作为程序员,需要保证调用 C 函数的时候类型一致。如果有特殊需求重新声明了对应的 C 函数,那么以上规则不起作用,所以重声明 C 中的函数时表示指针不可以使用这四个指针类型。

再说指针

Swift Pointers

虚线表示直接隐式类型转换。其中 UnsafePointer<T> 可以通过用其他任何指针调用构造函数获得。

CMutablePointer<T>CMutableVoidPointer 也可以通过 Array<T> 的引用隐式类型转换获得 ( &arr )。

椭圆表示类型 sizeof 为字长,可以用于声明 C 函数。

四大指针可以用 withUnsafePointer 操作。转换为 UnsafePointer<T>。上一节提到的私有转换函数请不要使用。

字符串

之前的文章已经介绍过怎么从 CString 获取 String (静态方法 String.fromCString)。

String 获取 CString 也说过, 是用 withCString

也可以从 CString(UnsafePointer.alloc(100)) 来分配空数组。

参考

]]>
猫·仁波切
Tilde Arrow in Swift (Swift 标准库中的波浪箭头 ~&gt; )2014-06-25T14:44:29+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/25/tilde-arrow-in-swift本文瞎写,没实际内容。请不用看了。

摘要

本文挖掘 Swift 标准库中的诡异操作符 ~> 波浪箭头的作用。

正文

查看标准库定义的时候,发现了一个奇怪的运算符 ~>,看起来高大上,所以探索下它到底起什么作用。

标准库对 ~> 用到的地方很多,我取最简单的一个来做说明。

protocol SignedNumber : _SignedNumber {
  func -(x: Self) -> Self
  func ~>(_: Self, _: (_Abs, ())) -> Self
}
func ~><T : _SignedNumber>(x: T, _: (_Abs, ())) -> T
func abs(_: CInt) -> CInt
func abs<T : SignedNumber>(x: T) -> T

这是对有符号整型的一个协议,我去掉了额外的属性。事实上 _Abs 类型是一个空结构, sizeof 为 0 。

写个测试程序,计算下 abs(-100) 看看情况,发现 top_level_code() 调用了 SignedNumber 版本的 abs()

callq  0x100001410               ; Swift.abs <A : Swift.SignedNumber>(A) -> A

反汇编这个库函数,发现一个有意思的调用:

callq  0x10000302a               ; symbol stub for: Swift._abs <A>(A) -> (Swift._Abs, A)

这个 _abs() 函数是私有函数, Swift 中把很多私有的函数、成员变量、结构、协议都以下划线开头,意思就是不希望我们去调用或者访问的函数,在缺乏成员访问控制的语言中,其实这么做也不错。大家可以借鉴。

_abs() 函数很简单,将任意类型 T 直接封装成 (_Abs, T) 元组,返回。

然后代码的逻辑就是用这个元祖解开重新组装,调用 ~>。逻辑如下:

// logic of abs() funciton
let (operation, val) = _abs(-100)
val ~> (operation, ()) // 返回 100

到这里就清楚了。实际上 ~> 将一个简单的操作复杂化。多调用了层,实际开销主要在元祖的解开和重组装(实际开销理论上在优化模式下应该可以忽略,因为包含 _Abs, size 为 0)。

到这里很多朋友应该已经知道怎么回事了。 SignedNumber 中的 ~> 操作是为我们提供了一个方法可以 hook 到标准库的 abs() 函数。来自 Haskell 的同学应该会见过这种单纯地用类型签名来实现函数分发调用的方式。

优点?

暂时正在考虑。想明白会发出来。

延伸

其实很多标准库函数都用到了类似的方法实现。都用到了 ~> 运算符。包括:

countElements()
// _countElements() 工具函数  _CountElements 结构
underestimateCount()
// _underestimateCount() 、 _UnderestimateCount
advance()
// _advance() 、 _Advance

等。

附录

这里列出部分定义:

protocol Sequence : _Sequence_ {
  typealias GeneratorType : Generator
  func generate() -> GeneratorType
  func ~>(_: Self, _: (_UnderestimateCount, ())) -> Int
  func ~><R>(_: Self, _: (_PreprocessingPass, ((Self) -> R))) -> R?
  func ~>(_: Self, _: (_CopyToNativeArrayBuffer, ())) -> ContiguousArrayBuffer<Self.GeneratorType.Element>
}
protocol Collection : _Collection, Sequence {
  subscript (i: Self.IndexType) -> Self.GeneratorType.Element { get }
  func ~>(_: Self, _: (_CountElements, ())) -> Self.IndexType.DistanceType
}

protocol ForwardIndex : _ForwardIndex {
  func ~>(start: Self, _: (_Distance, Self)) -> Self.DistanceType
  func ~>(start: Self, _: (_Advance, Self.DistanceType)) -> Self
  func ~>(start: Self, _: (_Advance, (Self.DistanceType, Self))) -> Self
}

相关更多声明代码信息请参考 我的 Github : andelf/Defines-Swift

总结

通过 ~>protocol 可以自定义编译器的行为。相当于 hook 标准库函数。由于内部实现未知,还不能继续断言它还有什么作用。

但是和直接用 extension 实现协议的方法相比,这个有什么好处呢?待考。

更新

可以避免 protocol 中的静态函数混淆空间,如果用全局函数,那么相当于全局函数去调用静态函数。

还有就是在使用操作符的时候,如果定义多个,那么需要编译器去寻找可用的一个版本。

仔细查看目前的 protocol 实现,发现还是有点 BUG ,类型限制还是不清楚,表述高阶类型的时候。

为了描述 ~> 的用法,我写了个 Monad.swift

]]>
猫·仁波切
Write Swift Module Cont. Static Library (使用 Swift 创建 Swift 模块 - 静态链接库)2014-06-25T13:20:55+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/25/write-swift-module-with-swift-cont声明: 转载注明我或者 SwiftChina, 请在方便的情况下情尽量告知. weibo

本文的发现基于个人研究。请尊重原创。

摘要

本文提出了一种可以编译 Swift 静态链接模块的方法,通过对 swift 编译命令行参数的控制,生成可以自由分发的静态链接库和 swift module 描述文件。同时还提出了导出 objC 头文件供 Objective-C 调用的可能。

关键词: Swift 模块 静态链接库

上次一篇文章 Module System of Swift (简析 Swift 的模块系统) 中提到:

静态链接库 .a 目前还没有找到方法, -Xlinker -static 会报错。

最近摸索了下用 Swift 创建静态链接库的方法。有所收获,这里记录下。

废话

我们中的很多人都知道,编译器编译的最后一个步骤一般都是链接,一般都是调用 ld。经过仔细分析,之前为什么不能生成 .a 静态链接库的原因,发现有如下问题:

  • -Xlinker -static 参数传递的时候, swift 命令本身不能识别,讲 -dylib-static 一起传递(这倒不是问题,参数优先级,静态盖掉了动态)
  • 链接到 -lSystem 时候,这个库没有静态链接。

所以总会报错。

思考

实际上之前的方法是走了弯路,根本没有必要去调用 ld,作为一个合格的 .a 静态链接库,只要有对应的 .o 就可以了,没必要去链接 -lSystem,也许是 swift 本身没有编译为静态链接库的参数支持。

检查 Swift 标准库中的静态链接库,果然只包含对应 .swift 代码编译后的 .o 文件。(检查方法是用 ar -t libName.a

说到底, Swift 静态链接库的目标很简单,就是包含对应 Swift 模块的所有代码,这样就避免了对应动态链接库的引入。和什么 -lSystem 没啥相干。

解决方法 HOWTO

以 lingoer 的 SwiftyJSON 为例。

我们的目标很简单,就是生成 ModName.swiftmoduleModName.swiftdoc(可选)、libswiftModName.a 三个文件。

编译

生成 .swiftmodule .swiftdoc

xcrun swift -sdk $(xcrun --show-sdk-path --sdk macosx) SwiftyJSON.swift -emit-library -emit-module -module-name SwiftyJSON -v -o libswiftSwiftyJSON.dylib -module-link-name swiftSwiftyJSON

生成 .o

xcrun swift -sdk $(xcrun --show-sdk-path --sdk macosx) -c SwiftyJSON.swift -parse-as-library -module-name SwiftyJSON -v -o SwiftyJSON.o

生成 .a

ar rvs libswiftSwiftyJSON.a SwiftyJSON.o

大功告成。

同时应该也可以用 lipo 来合成不同平台下的 .a 链接库。

使用

和静态链接库类似,需要 -I 包含 .swiftmodule 所在目录, -L 包含 .a 所在目录。

如果动态链接库和静态链接库两者同时存在,可以依靠不同目录来区分。

你丫闲的!

可能不少人要群嘲,你这意义是啥。你丫闲的。

其实在分发 library 的时候,很多时候我们需要二进制分发,希望别人可以方便地使用。这种情况下,静态链接更佳(虽然新的 iOS 8 支持动态链接,但是看起来是基于 Framework 的,略复杂些。)

甚至我们可以用 lipo 创建全平台可用的静态链接库。多赞。

补充

多个 Swift 文件可以分别编译为 .o 然后用 ar 合并。

对于 CocoaPods ,也许可以按照这个逻辑将 Swift 模块暴露出去。需要多加一个参数 -emit-objc-header (以及 -emit-objc-header-path)即可。

参考文献

]]>
猫·仁波切
Use CocoaPods with Swift (在 Swift 中使用 CocoaPods)2014-06-23T12:15:39+00:002020-11-16T07:41:32+00:00https://andelf.github.io/blog/2014/06/23/use-cocoapods-with-swift声明: 转载注明我或者 SwiftChina 。请在方便的情况下情尽量告知. weibo

本文的发现基于个人研究。请尊重原创。已授权 CocoaChina 转载个人文章。

本文介绍如何在 Swift 项目中使用 CocoaPods 。如果你已经精通 Bridging Header 的方法,请直接跳到 “扩展 CocoaPods” 一节。

什么是 CocoaPods

CocoaPods is the dependency manager for Objective-C projects. It has thousands of libraries and can help you scale your projects elegantly. 1

从介绍看,它是主要给 Objective-C 项目用的,但是我们可以很容易地混合 Objective-C 和 Swift 到同个项目,从而利用大量的 CocoaPods 库和 Swift 漂亮舒服的语法。

作为 iOS 开发新手,一定是要紧跟前人脚步,学习使用 CocoaPods 。

基础用法

这里简单略过,请参考其他无数的文章。

安装

系统默认安装,可以参考其他教程2 。在命令行下执行。

sudo gem install cocoapods

我的环境是 HomeBrew

# 添加 taobao Mirror 不然被墙掉没办法下载
gem sources -a http://ruby.taobao.org/
# 安装
gem install cocoapods
# 更新命令
rbenv rehash
# 执行
pod
# 此时一般会下载官方的所有 PodSpec 库,也可以用 pod setup 初始化环境

本文不打算在安装部分耗费太多时间。希望看到这里保证你的命令行下有可用的 pod 命令。

使用

假设我们已经有个项目,叫 ProjName ,需要使用一些注明的 CocoaPods 库,比如 AFNetworking3.

首先,命令行 cd 到我们的项目目录,一般 ls 命令会看到如下几个文件夹:

ProjName
ProjName.xcodeproj
ProjNameTests

赞,就是这里,创建一个 Podfile 文本文件,写入如下内容

platform :ios, "8.0"
pod "AFNetworking", "~> 2.0"

一般这么简单的文件都是直接 nano 写。 :)

直接创建 Podfile , CocoaPods 会创建一个项目同名的 WorkSpace ,然后添加一个叫 Pods 的项目,这个项目编译结果是一个叫 libPods.a的链接库, 它会添加到我们之前的 ProjName 项目中作为编译依赖。

当然,通过命令行执行 pod init 也可以自动创建 Podfile,而且可以自动分析当前项目的 target ,相对来说更好,也更优雅。具体请参考官方手册。这样的好处是更细致,还可以区分多个子项目子 target 。原理大同小异。

然后接下来,命令行执行 open ProjName.xcworkspace,注意这个可不是 .xcodeproj,这个是 CocoaPods 为我们创建的一个 WorkSpace ,包含我们之前的项目,和 Pods 依赖。

开始编码过程。直接在代码里调用,比如写在某个按钮的 @IBAction 里:

        let manager = AFHTTPRequestOperationManager()
        let url = "http://api.openweathermap.org/data/2.5/weather"
        println(url)

        let params = ["lat": 39.26, "lon": 41.03, "cnt":0]
        println(params)

        manager.GET(url,
            parameters: params,
            success: { (operation: AFHTTPRequestOperation!,
                        responseObject: AnyObject!) in
                println("JSON: " + responseObject.description!)
            },
            failure: { (operation: AFHTTPRequestOperation!,
                        error: NSError!) in
                println("Error: " + error.localizedDescription)
            })

这里直接抄了 JakeLin 的 SwiftWeather 代码4,就一小段,希望他不会打我。

Swift 坑爹了

看起来貌似我们已经可以在 Swift 中使用 AFNetworking 了。结果刚写几句代码一堆类和变量找不到定义,而且坑爹的是很多时候我们只能靠猜测,判断这些 Objective-C 的定义转换成 Swift 定义是什么样子,用起来就是完全靠蒙!

这不科学!

这都三礼拜了,所以大家都摸索出了调用的方法,那就是按照和 Objective-C 代码混编的例子,添加 Bridging Header !

继续

之前简单介绍过和 Objective-C 交互的内容5,大家可以去围观。

一般说来,你在 Swift 项目新建 Objective-C 类的时候,直接弹出是否创建 Bridge Header 的窗口,点 YES 就是了,这时候一般多出来个 ProjectName-Bridging-Header.h 。然后删掉这个类, Bridging Header 头文件还在。

在这个 Bridging Header 文件里写入要导入的 CocoaPods 库,就可以在 Swift 中使用了。

#import <AFNetworking/AFNetworking.h>

如果没有自动创建头文件的话,这个配置在项目的 Build Settings 中的 Swift Compiler - Code Generation 子项里。

创建一个头文件,指定为 Bridging Header 也可以。

然后编译,成功执行!

这就完事了?

实际上,前两天刚写一篇 Swift 的模块系统 , 把任意 Objective-C 库当做 Swift Module 是可行的。当时就觉得这个东西应该是可能完全进入 CocoaPods 的,但是在官方 repo 找了下发现,以前有人提过增加 module.map 支持,结果 CocoaPods 的人认为这个是 llvm 内部特性, issue 被关闭了。#2216 最近又被提起,我在后面提了下 Swift 支持,希望官方靠谱。

所以下面的内容,就是,我们是否可以在 CocoaPods 上加入 module.map 支持,然后直接在 Swift 中 import ModuleName

扩展 CocoaPods

考虑了多种方式,最后选择了 Hook 的方式。如果 Ruby 技术足够好,或许可以直接写个插件。或者直接改官方代码给官方提交。但是实在能力有限。相关的 module.map 语法参考 llvm 官方手册 Modules – Clang 3.5 documentation。用了最简单的功能。也许遇到复杂的 PodSpec 就不起作用了,但是原理如此,相信小伙伴们已经知道怎么做了。

目前我的 Podfile 大概是这个样子:

platform :ios, "8.0"
pod "AFNetworking", "~> 2.0"
pod "Baidu-Maps-iOS-SDK", "~> 2.0"

post_install do |installer|
  File.open("#{installer.sandbox_root}/Headers/module.map", 'w') do |fp|
    installer.pods.each do |pod|
      normalized_pod_name = pod.name.gsub('-', '')
      fp.write <<EOF
module #{normalized_pod_name} [system] {
  umbrella "#{pod.name}"
  export *
}
EOF
      puts "Generating Swift Module #{normalized_pod_name.green} for #{pod} OK!"
    end
  end
end

post_installPodfile 的一种 hook 机制,可以用来加入自定义操作。我在这里的写的逻辑就是,针对所有的 Pod 生成一个 module.map 文件。 位于 Pods/Headers/,这个目录被 CocoaPods 自动设置为项目的 Header Search Path 所以不需要额外处理。默认我们的 Swift 文件就找得到。

其中 normalized_pod_name 用于处理百度地图 API SDK 这一类名字带减号的库,因为他们不能作为 Module Name ,实际上或许有更好的方法来处理。

实际效果

实测发现完全没有问题,直接 import AFNetworking 或者 import BaiduMapsiOSSDK 都可以。

而且很不错的一点是,按住 Command 键,然后鼠标点击模块名、类名等,会跳转到 Swift 定义。

遇到提示 .pcm 文件 outdate 的情况下需要你删除 $HOME/Library/Developer/Xcode/DerivedData/ModuleCache 目录,这个目录保存的是预编译模块,类似于预编译头文件。

目前 Swift 还是有很多 BUG 的,调用 NSObject 也许会让编译器直接 segment fault ,不带任何出错信息。很伤情。此时请第一时间检查语法是否有诡异,其次将所有用到字符串或者 Optional 的地方都额外用变量处理,避免用字面常量。(个人经验)

如果多次调用 pod install 并在其中修改过 Podfile,那么有可能你的项目依赖会乱掉,多了不存在的 .a 文件到依赖或者多次包含。手工在项目树和项目选项里删除就可以了。此类编译错误都是链接错误。

总结

本文提出了一种 Bridging Header 之外的使用 CocoaPods 库的方法。利用有限的 Ruby 知识写了个 Hook 。目前测试 OK 。

参考

]]>
猫·仁波切