NMEA 字段数清楚了。今天换外置蓝牙:对板子就是一根串口在透传,手机侧扫服务、订通知、写特征。

第 41 章把 UART 的门打开了:uartN 对应 /dev/ttySN,termios 设成 8N1 原始模式,poll 等数据。第 43 章用同一套接口收 NMEA。这一章还是同一根排针、同一个 ttyS7,换上的却是一颗 外置 BLE 透传模组。

先把最容易走错的门焊死:

这不是 RK3568 片上的蓝牙控制器。
不是 HCI。
不是 BlueZ。
不是开源鸿蒙的 bluetooth_service。
不要去翻 @ohos.bluetooth,那一套管的是 SoC 自己的蓝牙栈。你这块底板上,BLE 是一颗独立的小模块,UART 进、射频出。板子这一侧看起来就是一个普通串口。

我见过三种典型的一天浪费:

  1. 打开桌面「设置 → 蓝牙」,搜附近设备,什么都没有,开始怀疑镜像缺蓝牙。
  2. 手机装「蓝牙串口助手」,按经典 SPP、HC-05 那套去配对,密码试 0000、1234,连不上。
  3. 串口打开了,波特率写成 9600(GPS 的习惯),模组按 115200 说话,两边都是噪声。

三种全错。这章按一次能通的路径走:板上打开 ttyS7 @ 115200,手机装 nRF Connect,连上之后订阅 FFF1、往 FFF2 写 hello,板子读到这五个字母,就算过关。反向再发 test。来回都要在一秒内看到。


1. 这不是片上蓝牙,别去设置里找

RK3568 可以接片上蓝牙,那是另一条线:UART HCI 或 SDIO,接到协议栈,再接到开源鸿蒙的蓝牙服务,应用调 Kit。本产品这条交付路径 没有把那条线接到应用。你看到的「蓝牙模块」,是排针上的一颗透传芯片:

手机 nRF Connect
    │  BLE 空中(GATT,服务 FFF0)
    ▼
外置 BLE 透传模组
    │ UART 115200 8N1
    ▼
RK3568 uart7  →  /dev/ttyS7
    │ 用户态 open / write / poll+read
    ▼
你的 C 工具,或 ArkTS 页面

模组上电自己广播。手机能在 nRF Connect 的扫描列表里看见它。不要配对、不要 PIN、不要 0000。 配对是经典蓝牙那套仪式。透传模组走的是 BLE GATT:连上、发现服务、订阅特征、读写特征。仪式到此结束。

系统设置里的蓝牙开关,打的是 SoC 协议栈。栈没起来,列表就是空的。空是正常的,不是镜像坏了。

经典「蓝牙串口助手」找的是 SPP UUID。模组根本不发那套 UUID,助手要么看不见,要么连上立刻掉。不要在助手的设置里把波特率、校验位调来调去——那是给 HC-05 一类经典模块用的。这边波特率只存在于 模组与 SoC 之间的那根 UART,手机看不到,也改不了。


2. 通信怎么走

sequenceDiagram
  participant Phone as 手机 nRF Connect
  participant Mod as BLE 透传模组
  participant UART as RK3568 uart7 / ttyS7
  participant App as 用户态工具 / ArkTS

  Note over Phone,Mod: BLE 连接,无 PIN
  Phone->>Mod: 发现服务 FFF0
  Phone->>Mod: 订阅特征 FFF1(notify)
  Phone->>Mod: 向 FFF2 写 hello
  Mod->>UART: 串口 TX 吐出 hello
  UART->>App: poll 就绪,read 得到 hello

  App->>UART: write test
  UART->>Mod: 串口 RX 收到 test
  Mod->>Phone: FFF1 notify 推送 test

两句话记住方向:

方向手机做什么板上看到什么
手机 → 板向 **FFF2** 写`/dev/ttyS7` 上 `read` 到同样的字节
板 → 手机订阅 **FFF1**板上 `write` 的字节出现在 FFF1 通知里

服务 UUID 是 FFF0。展开它才能看见 FFF1、FFF2。有的 nRF Connect 版本把 128 位 UUID 显示成 0000fff0-0000-1000-8000-00805f9b34fb,短的就是 FFF0,别被长度吓住。

模组内部相当于一个双口搬运工:UART 收到的往 FFF1 推,FFF2 收到的往 UART 吐。它不解析 hello 是什么意思。你在板上写 AT 指令,它也会原样送到手机——那不是 AT 模式,只是你把四个字母当数据发了。


3. 设备树:uart7 打开就行

板级文件:

device/board/rk/rk3568_evb/kernel/rk3568-evb-linux.dts

透传模组挂在 uart7,节点就是 /dev/ttyS7:

&uart7 {
    status = "okay";
    pinctrl-names = "default";
    pinctrl-0 = <&uart7m1_xfer>;
};

uart7m1_xfer 是一组复用:把对应 GPIO 切成 UART 的 TX/RX。引脚具体是哪两个球,以你这份 dtsi 的 pinctrl 为准。应用不认球号,只认 /dev/ttyS7。

核对运行时:

hdc shell "ls -l /dev/ttyS7"
hdc shell "cat /proc/tty/driver/serial"
hdc shell "cat /proc/device-tree/serial@fe6[0-9a-f]*/status"

期望 /dev/ttyS7 是字符设备,类似:

crw-rw-rw- 1 root root 4, 71 ... /dev/ttyS7

/proc/tty/driver/serial 里要有 7: 这一行,uart 类型常见是 16550A。没有这一行,节点即使存在也可能是有人 echo > /dev/ttyS7 在 tmpfs 上建出来的普通文件。那种文件 tcgetattr 会报 Not a tty。删掉假文件,查 DTS 的 status 和 pinctrl。

不要动 uart2。 ttyS2 是调试口,1500000 8N1,console= 占着它。应用往 ttyS2 写,等于往你自己的串口窗口里灌垃圾,严重时 console 服务被搅乱。GPS、BLE、LoRa 共用的是 uart7 那组排针,不是调试口。

同一组 UART 排针一次只插一个模块。GPS、BLE、LoRa、六轴、语音,物理上抢同一对 TX/RX。插着 GPS 再插 BLE,两颗芯片的 TX 短路,轻则谁都不说话,重则把口打坏。测完拔掉,再插下一个。


4. 完整 C:一块能双向打字的工具

下面这份 uart_tool.c 第 41 章的骨架在这里展开成可执行文件。它做五件事:

  1. open(O_RDWR | O_NOCTTY | O_NONBLOCK)
  2. termios:8N1、原始模式、关掉 CRTSCTS、关掉软件流控
  3. poll 等可读,再 read(不靠 VTIME,也不靠 select)
  4. 文本模式丢掉非 ASCII,避免非法 UTF-8 把 ArkTS 闪退
  5. 命令行一次性发送、或进入交互:键盘一行发走,串口来的打印出来

为什么非阻塞加 poll:开源鸿蒙这颗 SoC 的 dw-apb-uart 对 VTIME 的超时实现不可靠,纯阻塞 read 会永远卡在系统调用里。select 在 DMA 请求失败、驱动回退中断模式时,会出现「内核 rx 计数在涨,用户态 select 却说没数据」。poll(POLLIN) 在这条路径上是稳的。

为什么关 CRTSCTS:模组常常只引出 TX/RX/GND/VCC,CTS 在 SoC 这边悬空。硬件流控一开,控制器认为对端不许发,接收静默。GPS 章、六轴章都踩过。透传模组同样没有流控脚,一律关掉。

把文件放到任意目录,名字就叫 uart_tool.c:

/* uart_tool.c
 * OpenHarmony 4.1 用户态:32 位 musl,/dev/ttyS* 原始 8N1。
 * 用法:
 *   uart_tool /dev/ttyS7 115200              交互
 *   uart_tool /dev/ttyS7 115200 send hello   发一行文本再读 1 秒
 *   uart_tool /dev/ttyS7 115200 recv 1000    只收 1000 ms
 */

#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>

static speed_t baud_const(int b)
{
    switch (b) {
    case 4800:   return B4800;
    case 9600:   return B9600;
    case 19200:  return B19200;
    case 38400:  return B38400;
    case 57600:  return B57600;
    case 115200: return B115200;
    default:     return B0;
    }
}

static int uart_open(const char *path, int baud)
{
    speed_t sp = baud_const(baud);
    if (sp == B0) {
        fprintf(stderr, "不支持的波特率 %d(本工具列出的那几档)\n", baud);
        return -1;
    }

    int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
    if (fd < 0) {
        fprintf(stderr, "open %s: %s\n", path, strerror(errno));
        return -1;
    }

    struct termios t;
    memset(&t, 0, sizeof(t));
    if (tcgetattr(fd, &t) != 0) {
        fprintf(stderr, "tcgetattr: %s(是不是普通文件假节点?)\n", strerror(errno));
        close(fd);
        return -1;
    }

    cfsetispeed(&t, sp);
    cfsetospeed(&t, sp);
    t.c_cflag &= ~CSIZE;
    t.c_cflag |= CS8 | CLOCAL | CREAD;
    t.c_cflag &= ~PARENB;
    t.c_cflag &= ~CSTOPB;
    t.c_cflag &= ~CRTSCTS;                 /* 必须关:CTS 悬空会卡死收 */
    t.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
    t.c_iflag &= ~(IXON | IXOFF | IXANY | INLCR | ICRNL);
    t.c_oflag &= ~OPOST;
    t.c_cc[VMIN]  = 0;
    t.c_cc[VTIME] = 0;                     /* 超时交给 poll */

    tcflush(fd, TCIOFLUSH);
    if (tcsetattr(fd, TCSANOW, &t) != 0) {
        fprintf(stderr, "tcsetattr: %s\n", strerror(errno));
        close(fd);
        return -1;
    }
    return fd;
}

static int uart_write_text(int fd, const char *s)
{
    size_t n = strlen(s);
    ssize_t w = write(fd, s, n);
    if (w < 0) {
        fprintf(stderr, "write: %s\n", strerror(errno));
        return -1;
    }
    return (int)w;
}

/* 在 timeout_ms 内尽量收。文本模式只留可打印 ASCII 和 \r\n\t。 */
static int uart_read_text(int fd, char *out, int outsz, int timeout_ms)
{
    int total = 0, idle = 0;
    while (idle < timeout_ms && total < outsz - 1) {
        struct pollfd p;
        p.fd = fd;
        p.events = POLLIN;
        p.revents = 0;
        int pr = poll(&p, 1, 100);
        if (pr > 0 && (p.revents & POLLIN)) {
            unsigned char buf[256];
            int r = (int)read(fd, buf, sizeof(buf));
            if (r > 0) {
                int i;
                for (i = 0; i < r && total < outsz - 1; i++) {
                    unsigned char c = buf[i];
                    if ((c >= 0x20 && c <= 0x7E) || c == '\r' || c == '\n' || c == '\t')
                        out[total++] = (char)c;
                }
                idle = 0;
                continue;
            }
        }
        idle += 100;
    }
    out[total] = '\0';
    return total;
}

static void interactive(int fd)
{
    fprintf(stderr, "交互:一行回车发出去;Ctrl-C 退出。模组连上后往 FFF2 写,这里能看见。\n");
    for (;;) {
        struct pollfd ps[2];
        ps[0].fd = fd;
        ps[0].events = POLLIN;
        ps[1].fd = STDIN_FILENO;
        ps[1].events = POLLIN;

        if (poll(ps, 2, 500) <= 0)
            continue;

        if (ps[0].revents & POLLIN) {
            char rx[512];
            int n = uart_read_text(fd, rx, sizeof(rx), 200);
            if (n > 0)
                printf("RX %d: %s\n", n, rx);
        }
        if (ps[1].revents & POLLIN) {
            char line[256];
            if (!fgets(line, sizeof(line), stdin))
                break;
            size_t L = strlen(line);
            while (L > 0 && (line[L - 1] == '\n' || line[L - 1] == '\r'))
                line[--L] = '\0';
            if (L == 0)
                continue;
            int w = uart_write_text(fd, line);
            printf("TX %d: %s\n", w, line);
        }
    }
}

int main(int argc, char **argv)
{
    if (argc < 3) {
        fprintf(stderr, "用法: %s <设备> <波特率> [send <文本>|recv <毫秒>]\n", argv[0]);
        return 1;
    }
    const char *dev = argv[1];
    int baud = atoi(argv[2]);
    int fd = uart_open(dev, baud);
    if (fd < 0)
        return 1;

    if (argc >= 5 && strcmp(argv[3], "send") == 0) {
        int w = uart_write_text(fd, argv[4]);
        printf("TX %d 字节\n", w);
        char rx[512];
        int n = uart_read_text(fd, rx, sizeof(rx), 1000);
        if (n > 0)
            printf("RX %d: %s\n", n, rx);
        else
            printf("RX: 1 秒内无 ASCII 数据(先让手机订阅 FFF1,或检查连没连上)\n");
        close(fd);
        return 0;
    }
    if (argc >= 5 && strcmp(argv[3], "recv") == 0) {
        int ms = atoi(argv[4]);
        char rx[1024];
        int n = uart_read_text(fd, rx, sizeof(rx), ms);
        if (n > 0)
            printf("RX %d: %s\n", n, rx);
        else
            printf("RX: %d ms 内无数据\n", ms);
        close(fd);
        return 0;
    }

    interactive(fd);
    close(fd);
    return 0;
}

5. 编出来、丢到板上

用户态必须是 32 位 armv7 + musl。内核是 aarch64,这不矛盾,第 02 章写过。随手用 aarch64-linux-gnu-gcc 编出来的东西,板上 exec 会失败。

OHOS=$HOME/code/opcode/OpenHarmony          # 改成你的源码根
CLANG=$OHOS/prebuilts/clang/ohos/linux-x86_64/llvm/bin/clang
SYSROOT=$OHOS/prebuilts/ohos-sdk/linux/11/native/sysroot

$CLANG --target=arm-linux-ohos --sysroot=$SYSROOT \
    -O2 -Wall \
    -Wl,--dynamic-linker=/system/lib/ld-musl-arm.so.1 \
    -o uart_tool uart_tool.c

file uart_tool
# 期望:ELF 32-bit LSB executable, ARM, ... interpreter /system/lib/ld-musl-arm.so.1

传到板上:

hdc file send uart_tool /data/local/tmp/uart_tool
hdc shell "chmod 0755 /data/local/tmp/uart_tool"
hdc shell "ls -l /dev/ttyS7"

ttyS7 若是 crw------- 且属主 root,HAP 读不到,C 工具用 root 的 hdc shell 还能跑。先用 C 工具把通路确认了,再谈应用权限。临时放权:

hdc shell "chmod 0666 /dev/ttyS7"

重启会丢。交付做法是开机服务里 chmod,脚本放在:

device/board/rk/rk3568_evb/board_perm/board-perm.sh

init.rk30board.cfg 里 once = 1 跑一次。服务名随你,命令必须是 cfg 允许的那几个,别把注释字符串塞进 cmds。第 41 章给过完整片段,这里不重复抄。


6. 我带你跑一次双向透传

插好模组,确认排针上没有第二颗 UART 模块。USB OTG 连电脑。手机装 nRF Connect(Android / iOS 都行,商店里搜这个名字)。

6.1 板上先听着

hdc shell "/data/local/tmp/uart_tool /dev/ttyS7 115200 recv 20000"

这是「听 20 秒」。你去手机上操作,20 秒内写完 hello。

打开失败的典型输出:

open /dev/ttyS7: No such file or directory

DTS 没 okay,或 uart7 的 pinctrl 被别人抢走。

tcgetattr: Not a tty

/dev/ttyS7 是普通文件。ls -l 看第一位是 - 不是 c。删掉它,查驱动有没有 bind。

open /dev/ttyS7: Permission denied

chmod 0666。

6.2 手机 nRF Connect,逐步点

  1. 打开 nRF Connect,点扫描。列表里会出现模组的广播名(各家名字不同,认刚上电那一颗,离手机近的 RSSI 大)。
  2. 点 Connect。不要去系统设置里配对。不要输入 PIN。连上之后 Scanner 变成已连接设备。
  3. 进入设备页,等服务发现完成。找到 FFF0(或 0000fff0-...)。
  4. 展开。点 FFF1 右侧的三条斜线(Notify)。图标亮起来,表示订阅成功。不订阅,板上发的数据手机收不到,这是最常见的「单向通」。
  5. 点 FFF2,选 Write / Write Request,文本框输入 hello,发送。

板上那条 recv 20000 应该打出:

RX 5: hello

有的模组会在后面加 \r 或 \n,过滤之后你仍能看见 hello。20 秒到了还是 RX: 20000 ms 内无数据,按第 12 节往下查,不要先改 ArkTS。

6.3 反向:板子说话,手机听

nRF Connect 停在 FFF1 的日志窗口。板上:

hdc shell "/data/local/tmp/uart_tool /dev/ttyS7 115200 send test"

期望:

TX 4 字节
RX: 1 秒内无 ASCII 数据

TX 是关键。RX 空很正常——你没有从手机往回写。手机 FFF1 的通知列表里应出现 test,ASCII 74 65 73 74。

FFF1 没动:

  • 没点 Notify。回去点三条斜线。
  • 写到了别的 tty。确认命令行是 /dev/ttyS7,不是 ttyS2、ttyS3。
  • 连接已经掉了。nRF Connect 顶部不是 Connected,重新连。

来回都在一秒内看到,这章的硬件加驱动就过了。应用只是把同样的 open/write/poll 换皮。

6.4 交互模式,当一块双向白板

hdc shell "/data/local/tmp/uart_tool /dev/ttyS7 115200"

你在 hdc 里敲 ping 回车,手机 FFF1 出 ping。手机往 FFF2 写 pong,hdc 打 RX 4: pong。这是验收时最直观的画面。Ctrl-C 结束。


7. ArkTS 页面:文本模式的串口台

应用里不要再发明一套协议。页面三件事:打开 ttyS7 @ 115200、发送文本、轮询读。原生库约定叫 libboard.so,和第 41 章同一份。

entry/src/main/ets/pages/BleUart.ets:

import board from 'libboard.so';

@Entry
@Component
struct BleUart {
  @State dev: string = '/dev/ttyS7';
  @State baud: string = '115200';
  @State tx: string = 'hello';
  @State log: string = '先打开串口,再让手机用 nRF Connect 连模组。';
  @State opened: boolean = false;
  private timer: number = -1;

  aboutToDisappear() {
    this.stopPoll();
    if (this.opened) {
      board.uartClose();
      this.opened = false;
    }
  }

  build() {
    Column() {
      Text('BLE 串口透传').fontSize(20).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
      Text('外置模组,不是片上蓝牙。手机用 nRF Connect:服务 FFF0,通知 FFF1,写 FFF2。不要配对。')
        .fontSize(13).fontColor('#666').margin({ bottom: 10 })

      Row() {
        Text('设备').fontSize(14).width(48)
        TextInput({ text: this.dev }).onChange((v: string) => { this.dev = v; }).layoutWeight(1)
      }.margin({ bottom: 6 })
      Row() {
        Text('波特').fontSize(14).width(48)
        TextInput({ text: this.baud }).type(InputType.Number)
          .onChange((v: string) => { this.baud = v; }).layoutWeight(1)
      }.margin({ bottom: 6 })

      Row() {
        Button(this.opened ? '关闭' : '打开').onClick(() => { this.toggle(); }).margin({ right: 8 })
        Button(this.timer >= 0 ? '停止读' : '连续读').onClick(() => { this.togglePoll(); })
      }.margin({ bottom: 8 })

      Row() {
        TextInput({ text: this.tx, placeholder: '发给手机的文本' })
          .onChange((v: string) => { this.tx = v; }).layoutWeight(1)
        Button('发送').onClick(() => { this.send(); }).margin({ left: 8 })
      }.margin({ bottom: 8 })

      Text(this.log).fontSize(14).fontColor('#1565C0').width('100%')
    }
    .padding(16).width('100%').height('100%').alignItems(HorizontalAlign.Start)
  }

  toggle() {
    if (this.opened) {
      this.stopPoll();
      board.uartClose();
      this.opened = false;
      this.log = '已关闭';
      return;
    }
    let b: number = parseInt(this.baud);
    if (Number.isNaN(b)) {
      b = 115200;
    }
    let r: number = board.uartOpen(this.dev, b);
    this.opened = (r === 0);
    this.log = this.opened
      ? ('已打开 ' + this.dev + ' @' + b + '。手机连上后点连续读,再向 FFF2 写 hello。')
      : ('打开失败。查节点、chmod 0666、DTS uart7、别占用 ttyS2。');
  }

  send() {
    if (!this.opened) {
      this.log = '先打开';
      return;
    }
    let n: number = board.uartWrite(this.tx, 0);   /* 0 = 文本,不是十六进制 */
    this.log = 'TX ' + n + ' 字节:' + this.tx + '(看手机 FFF1)';
  }

  togglePoll() {
    if (this.timer >= 0) {
      this.stopPoll();
      this.log = '已停止连续读';
      return;
    }
    if (!this.opened) {
      this.log = '先打开';
      return;
    }
    this.timer = setInterval(() => {
      let s: string = board.uartRead(200, 0);
      if (s.length > 0) {
        this.log = 'RX: ' + s;
      }
    }, 400);
  }

  stopPoll() {
    if (this.timer >= 0) {
      clearInterval(this.timer);
      this.timer = -1;
    }
  }
}

连续读间隔 400 ms、每次 uartRead 超时 200 ms。uartRead 若在 ArkTS 主线程里同步跑 poll,间隔会被吃满,停止按钮点不动。第 41 章给了 uartReadAsync(napi_async_work)。页面要流畅,把上面的 board.uartRead 换成 await board.uartReadAsync(200, 0),并把回调改成 async。演示通路,同步版也能用,别开太短的 setInterval。

声明文件 entry/src/main/cpp/types/libboard/index.d.ts:

export function uartOpen(path: string, baud: number): number;
export function uartClose(): number;
export function uartWrite(data: string, isHex: number): number;
export function uartRead(timeoutMs: number, asHex: number): string;
export function uartReadAsync(timeoutMs: number, asHex: number): Promise<string>;

oh-package.json5 里模块名与 libboard.so 对齐。HAP 是 32 位 armeabi-v7a。签名、安装走第 69 章,这里只要求页面能调到 so。


8. NAPI:C 侧和工具是同一套 termios

entry/src/main/cpp/board_uart.cpp 里,打开和读写必须与 uart_tool.c 同策略。下面是精简后仍完整可编的三件套。全局一个 fd,应用同时只开一个串口——这正是排针「一次一个模块」在软件上的投影。

#include <fcntl.h>
#include <poll.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>
#include "napi/native_api.h"

static int g_uart = -1;

static speed_t baud_const(int b)
{
    switch (b) {
    case 9600:   return B9600;
    case 115200: return B115200;
    case 38400:  return B38400;
    case 57600:  return B57600;
    default:     return B9600;
    }
}

static napi_value UartOpen(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value a[2];
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    char path[64] = {0};
    size_t len = 0;
    int baud = 115200;
    napi_get_value_string_utf8(env, a[0], path, sizeof(path) - 1, &len);
    napi_get_value_int32(env, a[1], &baud);
    if (g_uart >= 0) {
        close(g_uart);
        g_uart = -1;
    }
    int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
    int rc = -1;
    if (fd >= 0) {
        struct termios t;
        memset(&t, 0, sizeof(t));
        if (tcgetattr(fd, &t) == 0) {
            cfsetispeed(&t, baud_const(baud));
            cfsetospeed(&t, baud_const(baud));
            t.c_cflag = (t.c_cflag & ~CSIZE) | CS8 | CLOCAL | CREAD;
            t.c_cflag &= ~(PARENB | CSTOPB | CRTSCTS);
            t.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
            t.c_iflag &= ~(IXON | IXOFF | IXANY | INLCR | ICRNL);
            t.c_oflag &= ~OPOST;
            t.c_cc[VMIN] = 0;
            t.c_cc[VTIME] = 0;
            tcflush(fd, TCIOFLUSH);
            if (tcsetattr(fd, TCSANOW, &t) == 0) {
                g_uart = fd;
                rc = 0;
            }
        }
        if (rc != 0)
            close(fd);
    }
    napi_value ret;
    napi_create_int32(env, rc, &ret);
    return ret;
}

static napi_value UartClose(napi_env env, napi_callback_info info)
{
    (void)info;
    if (g_uart >= 0) {
        close(g_uart);
        g_uart = -1;
    }
    napi_value ret;
    napi_create_int32(env, 0, &ret);
    return ret;
}

static napi_value UartWrite(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value a[2];
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    char buf[512] = {0};
    size_t len = 0;
    int isHex = 0;
    napi_get_value_string_utf8(env, a[0], buf, sizeof(buf) - 1, &len);
    napi_get_value_int32(env, a[1], &isHex);
    int n = -1;
    if (g_uart >= 0) {
        /* 写前清输入:丢掉上次残留,避免和下一包粘在一起 */
        tcflush(g_uart, TCIFLUSH);
        if (!isHex)
            n = (int)write(g_uart, buf, len);
        else {
            /* 十六进制路径第 45 章用,这里保留接口 */
            n = (int)write(g_uart, buf, len);
        }
    }
    napi_value ret;
    napi_create_int32(env, n, &ret);
    return ret;
}

static napi_value UartRead(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value a[2];
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    int timeoutMs = 500, asHex = 0;
    napi_get_value_int32(env, a[0], &timeoutMs);
    napi_get_value_int32(env, a[1], &asHex);
    char out[2048];
    int total = 0, idle = 0;
    out[0] = '\0';
    if (g_uart >= 0) {
        while (idle < timeoutMs && total < (int)sizeof(out) - 1) {
            struct pollfd p;
            p.fd = g_uart;
            p.events = POLLIN;
            p.revents = 0;
            int pr = poll(&p, 1, 100);
            unsigned char rb[256];
            int r = 0;
            if (pr > 0 && (p.revents & POLLIN))
                r = (int)read(g_uart, rb, sizeof(rb));
            if (r > 0) {
                int i;
                for (i = 0; i < r && total < (int)sizeof(out) - 1; i++) {
                    unsigned char c = rb[i];
                    if (asHex) {
                        static const char *H = "0123456789ABCDEF";
                        if (total + 2 >= (int)sizeof(out) - 1)
                            break;
                        out[total++] = H[c >> 4];
                        out[total++] = H[c & 0xF];
                    } else if ((c >= 0x20 && c <= 0x7E) || c == '\r' || c == '\n' || c == '\t') {
                        out[total++] = (char)c;
                    }
                }
                idle = 0;
            } else {
                idle += 100;
            }
        }
        out[total] = '\0';
    }
    napi_value ret;
    napi_create_string_utf8(env, out, NAPI_AUTO_LENGTH, &ret);
    return ret;
}

Init 里把四个函数挂上,名字与 index.d.ts 一致。uartWrite 的十六进制解析第 45 章补全——BLE 这章发 ASCII 即可,isHex 传 0。


9. 验收

一次性清单,全打勾才算这颗模组过:

项动作通过
节点`ls -l /dev/ttyS7``c` 开头的字符设备
驱动`cat /proc/tty/driver/serial`有 `7:` 行
打开`uart_tool /dev/ttyS7 115200 recv 2000`不报 `Not a tty` / `Permission denied`
手机连nRF Connect Connect无需 PIN,状态 Connected
订阅FFF1 Notify 打开图标亮
手机→板FFF2 写 `hello`板上 1 秒内打出 `hello`
板→手机`send test`FFF1 1 秒内出现 `test`
负例系统设置蓝牙列表可以是空的,不作为失败

不要用「手机系统蓝牙里能看见名字」当验收。能看见广播只说明模组在供电,GATT 通不通还没测。


10. 坑:每一个都是真事

10.1 跑去设置里开蓝牙

桌面「设置 → 蓝牙」打的是片上协议栈。本产品这条线没把控制器交给它。列表空、开关灰,都不是模组坏了。验收工具是 nRF Connect,不是设置。

有人会在源码树里搜 bluetooth_service、ohos.bluetooth,然后开始编 HCI。那是另一条交付。你现在要的是 write(/dev/ttyS7)。先把五个字母跑通,再谈协议栈。

10.2 经典串口助手 + 密码 0000

SPP 助手假设对端是经典蓝牙、有配对、有 PIN。透传模组不走那条。助手连不上时,人会改密码、改 UUID、改手机。全是空转。卸掉助手,装 nRF Connect。

「密码 0000」这个传说来自某几颗经典模块的出厂配对码。把它用到 BLE 透传上,只会让你在配对对话框里卡死。对话框弹出来就点取消,回到 nRF Connect 的 Connect。

10.3 波特率写成 9600

同一根排针上午测 GPS,下午换 BLE,工具里波特率还停在 9600。模组侧是 115200。两边都能 open 成功,write 也返回正数,内容全是垃圾。文本过滤会把高位字节丢掉,于是「什么都读不到」。

先看模组丝印或手册上的 UART 波特率,再看命令行第二个参数。BLE 透传这章默认 115200。LoRa 那章是 9600。不要共用一个记忆。

10.4 占用 ttyS2

调试口 1500000,应用 115200,两个数都对,口错了。你在 PuTTY 窗口里突然冒出 hello,那是应用写进了 console。立刻停。uartOpen 的路径写死 /dev/ttyS7,页面上的输入框也可以改,但默认不要给 ttyS2。

10.5 CRTSCTS 让接收消失

termios 抄网上「标准 8N1」示例,里面常常 c_cflag |= CRTSCTS。模组没有 CTS 线,脚悬空,UART 认为不允许发,FIFO 不往用户态送。/proc/tty/driver/serial 的 rx 不涨。关流控之后 rx 开始涨,poll 立刻有数据。第 41 章把这条写成铁律,这里再钉一次:透传模组 关硬件流控。

10.6 只写不订

nRF Connect 里 FFF2 能写,板上也能收到,反向没有。因为 FFF1 没订 Notify。写和通知是两个特征。订 FFF1 是板→手机的开关,写 FFF2 是手机→板的开关。两个都要开。

10.7 非 ASCII 把应用打崩

波特率不对时,UART 吐 0x80 以上的字节。napi_create_string_utf8 造出非法 UTF-8,ArkTS 的 split / startsWith 直接崩。所以文本模式在 C 里就把非 ASCII 丢掉。调试「乱码」时用 recv 看过滤后还有没有字;没有字,多半是波特率或接线,不是应用解析写错。

10.8 权限

hdc shell 是 root,C 工具能开,HAP 报打开失败。看 ls -l /dev/ttyS7 是不是 0666。开机服务没跑、或跑在节点出现之前,都会这样。board-perm.sh 里对 ttyS7 做十几秒短轮询 chmod,和 GPIO 预导出是同一类活。


11. 排障顺序,别跳

通不了的时候按这个顺序,不要从改 ArkTS 开始:

1. ls -l /dev/ttyS7          字符设备?权限?
2. cat /proc/tty/driver/serial   有 7: ?
3. 拔掉排针上其它 UART 模块
4. uart_tool recv 20s,同时手机 FFF2 写 hello
5. 若 4 失败:nRF Connect 是否 Connected、FFF0 是否在、写的是不是 FFF2
6. 若 4 成功、应用失败:chmod、hap 是否 32 位、so 是否真的编进包
7. 反向 send test,看 FFF1 是否已 Notify

第 4 步成功、第 6 步失败,问题在应用,不在射频。第 4 步失败,问题在模组、接线、波特率、GATT,不在 ArkTS。分层切开,一天能结束。


本章小结

  • 外置 BLE 透传模组 = 一颗挂在 uart7 上的串口设备。手机用 nRF Connect 走 GATT,不用片上蓝牙栈,不用经典配对。
  • 服务 FFF0,通知 FFF1(模组 UART → 手机),写入 FFF2(手机 → 模组 UART)。不要 PIN。
  • 板上 115200 8N1、关 CRTSCTS、poll 读、文本过滤非 ASCII。工具和 NAPI 同一套。
  • 验收是双向五个字母,一秒内到。设置里搜不到蓝牙不是故障。

下一章还是这根排针,换成 LoRa 透传:波特率变成 9600,数据变成十六进制,单模块只能证明串口活着,无线电要两套。


路径清单

用途路径
板级 DTS uart7`device/board/rk/rk3568_evb/kernel/rk3568-evb-linux.dts`
节点`/dev/ttyS7`
内核串口表`/proc/tty/driver/serial`
用户态工具本章 `uart_tool.c`,丢 `/data/local/tmp/uart_tool`
开机放权`device/board/rk/rk3568_evb/board_perm/board-perm.sh`
init`*.rk30board.cfg` 里 once 服务
NAPI`entry/src/main/cpp/board_uart.cpp`(`libboard.so`)
ArkTS`entry/src/main/ets/pages/BleUart.ets`
声明`entry/src/main/cpp/types/libboard/index.d.ts`
手机nRF Connect(服务 FFF0 / 通知 FFF1 / 写 FFF2)
调试口(勿占)`/dev/ttyS2`,1500000

系列第 44 篇 · 芯片:瑞芯微 RK3568 · OpenHarmony 4.1(API 11) · Linux 5.10

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐