CH32 PIOC入门(二):手把手教你用 PIOC 驱动 DS18B20 温度传感器!

本文中所涉及的公司名称、商标等,仅为介绍目的使用,其所有权利属于其法定所有者。

01 写在前面

在嵌入式开发当中,温度测量是最常见的基础需求之一。对于 -30~+110℃ 范围的温度测量,传统设计通常采用 NTC 进行温度测量。然而 NTC 存在一些固有不足:阻值与温度呈强非线性关系,需要查表或拟合曲线校准;需要逐个标定才能达到较高精度;测量电流流过元件会产生自热,连续测量时自热温升会引入额外的测量误差;分压采样结构的输出阻抗较高,且对噪声敏感(相同强度的噪声耦合,在高阻抗节点上形成的干扰电压更大),对 ADC 的输入阻抗或采样时间也有要求。这些不足在需要较高精度或批量一致性的场合尤为明显。

为了省去模拟测温链路中的换算与标定环节,能够直接输出数字量的温度传感器得到了越来越广泛的使用。DS18B20 就是其中的代表:它由 Dallas 公司设计生产(后经 Maxim Integrated 公司收购,现属 Analog Devices 公司),只需一条数据线即可通信,能够满足大部分测温应用指标要求。同时,由于其广泛应用,也有众多国产厂商推出了兼容型号(命名上普遍采用“xx18B20”形式、仅替换厂商前缀),这意味着货源足、成本较低、供应稳健,大部分产品的时序也与原版产品相兼容。不过部分型号的电气特性或性能指标与原版产品略有差异,需对照实际使用型号的数据手册确认。

由于 DS18B20 所使用的 1-Wire 数字单总线协议的时序为 μs 级(复位 480μs、时隙 60μs),若通过 GPIO 模拟(也就是所说的 Bit-banging),需要占用定时器或忙等从而浪费资源;在等待期间还需要关中断以避免时序被破坏,这可能又会影响系统实时性。同时,绝大多数 MCU 并没有原生的 1-Wire 通信外设,这也给此类全数字温度传感器的使用带来不便。

在本系列文章的上一篇中已经提到,部分 CH32 MCU 包括一个 PIOC 外设,其独立于主核运行、指令周期确定,用来处理此类“精确时序 + 单线收发”的协议来说非常合适。这样,MCU 的主核只需要像操作普通外设一样发命令、读结果即可完成 DS18B20 的通信。

本文将带领读者手把手完成通过 PIOC 与 DS18B20 通信测温度的功能。需要准备的材料如下:

  • DS18B20 或兼容产品 ×1
  • 带有 PIOC 外设的 MCU 型号板卡 ×1
  • 直插式 4.7kΩ 上拉电阻(若使用裸传感器) ×1
  • 面包板、杜邦线等(若使用裸传感器) 按需准备

本文演示平台:CH32V205RCT-R0-1v1 评估板(或任意支持 PIOC 的芯片评估板);开发环境为免费的 MounRiver Studio Ⅱ(下称 MRS2)2.6.0。

02 认识 DS18B20 与 1-Wire 协议

02.1 DS18B20 简介

DS18B20 是一种使用 1-Wire 单总线通信的数字温度传感器,供电范围 3.0~5.5V、测温范围 -55~+125℃、精度 ±0.5℃(-10~+85℃内,取决于具体的制造商,以手册为准)、在 -55~+125℃ 范围内均可测量。其最常见的封装是直插 TO-92(形如常见的小功率直插三极管),引脚顺序如图所示(图片来自数据手册,此处为介绍目的引用,版权归属原始著作权人):

DS18B20 的封装和引脚顺序

DS18B20 的外围电路非常简洁,仅需一个上拉电阻即可。其支持两种供电方式,分别为外部供电和寄生供电。外部供电需要 3 条线(电源正、地、信号),主机侧仅需要常规上拉电阻即可;寄生供电仅需要 2 条线(地、信号),但需要主机侧在转换时提供强上拉以维持供电。DS18B20 的 DQ 引脚即为 1-Wire 总线信号引脚。

DS18B20 的外围电路

若使用 DS18B20 模块,则通常已带上拉电阻,可直接对照连接到板卡使用;若使用裸传感器或无内置上拉的带线探头,需要外接 4.7kΩ 上拉电阻,可将传感器接在面包板上,然后在 3.3V 电源和信号线之间连接一只 4.7kΩ 上拉电阻。若使用带线探头,不同厂家的线色定义可能不同,请以实际产品为准。

02.2 1-Wire 单总线协议时序

1-Wire 是 Maxim Integrated 公司的注册商标,本文中仅为介绍目的使用。

DS18B20 数字温度传感器使用一种称为“1-Wire”的单总线数字协议,为使用外部上拉的开漏式总线。不同于上一篇中的 WS2812 使用的点对点、菊花链式的单线数字协议,这种结构允许总线上直接并联存在多个设备,并通过一定的方式寻址完成防冲突通信,可减少线缆数量,简化设计。在总线空闲时,依靠上拉电阻保持高电平,器件通过拉低总线形成特定时序进行通信。

所有时序都由“拉低/释放+延时+读电平”组合而成,这正是 PIOC 擅长的。1-Wire 的时序可以归纳为以下四种:

  • 复位时序:主机拉低总线至少 480μs 后释放;
  • 存在时序:器件在主机的复位时序完成后的 1560μs 内拉低总线 60240μs,表示有器件存在且响应了复位时序;
  • 写时序:主机写一位数据时,首先拉低总线;
    • 若写 0 则保持拉低总线 60~120μs,然后释放总线至少 1μs 以确保总线回到高电平;
    • 若写 1 则共拉低 1~15μs 后释放,确保总线从开始拉低到回到高电平不超过 15μs,并等待至少 60μs;
  • 读时序:主机读一位数据时,首先拉低总线 ≥1μs 并释放,在从总线开始拉低时刻计的 15μs 内读取总线电平;
    • 若读到低电平表示 0;
    • 若读到高电平表示 1;
  • 位之间的总线恢复时间:在完成 1 位数据的时序后,需要至少 1μs 的恢复时间以确保总线电平稳定。

以上仅为简单介绍,具体的时序细节会在相应章节中详细说明。

03 硬件连接

DS18B20 的接线非常简单,仅需连接电源和信号线 DQ 即可。接线如下:

  • GND 接到板卡的 GND;
  • VDD 接到板卡的 3.3V;
  • DQ 接到板卡的 PIOC IO0 引脚,本文以 PB10 为例;

若使用裸传感器或无内置上拉的带线探头,信号线 DQ 需要外接 4.7kΩ 上拉电阻到 3.3V,可在面包板上如图连接:

在面包板上连接 DS18B20

由于 PIOC 的 IO0 支持更丰富的输入指令和方法,相比 IO1 更适宜用于输入或双向数据,因此本文示例中使用 IO0 作为 PIOC 的数据线 IO 口,并选择 PB10 引脚。

上电后可用万用表量 DQ 电压,应为 3.3V 高电平(由电阻上拉)。若为低电平则说明总线存在异常。若传感器发烫则可能是 VDD/GND 接反,应断电并改正。

04 在开始 PIOC 固件开发之前

上一篇文章中,我们演示了通过 PIOC 控制 WS2812 彩灯灯带,是 PIOC 用于“数据输出”的工况;本文将涉及的 DS18B20 则是另一种常见的“单线双向总线”工况,在单个 IO 口上既要输出数据,又要输入数据,实现时分复用的双向通信。

04.1 设计程序控制流

本文将基于上一篇中提到的“命令-响应”模式,设计 PIOC 固件的控制流。PIOC 固件的主要功能是接收主核发来的命令,执行相应的操作,并将结果返回给主核。控制流如下:

  • 准备工作: 初始化 IO 等相关寄存器
  • 等待命令: PIOC 进入等待状态,直到主核发来命令
  • 命令分发: 根据接收到的命令码,分发到相应的处理子程序
  • 执行命令: 执行相应的操作,如复位、读温度等
  • 返回结果: 将操作结果写入结果寄存器,并置中断请求,通知主核,然后回到等待命令状态,准备接收下一个命令

04.2 命令码、结果码与寄存器定义

参照上一篇样例的开发范式,本文同样采用 SFR_CTRL_WR 作为命令码寄存器,SFR_CTRL_RD 作为结果码寄存器。在此应用中,我们将命令码与结果码定义如下:

  • 命令码
    • 0x01:启动温度转换(无参数)
    • 0x02:读取温度数据(无参数)
    • 0x03:查询温度转换状态(无参数)
  • 结果码
    • 0x00:成功
    • 0x01:忙(总线忙或转换未完成)
    • 0x02:无应答

本文中的例子未使用参数输入;对于结果输出,使用 SFR_DATA_REG0 至 SFR_DATA_REG8 寄存器。

出于简化示例的目的,本文基于总线上只有单个 DS18B20 的情况开发。若需使用多个传感器,需在通信过程中先进行寻址,然后再与所需的器件进行通信。

05 编写时序子程序

05.1 1-Wire 的时序实现

为了方便起见,我们采用指令延时方式产生时序。结合 DS18B20 的数据手册中对时序的要求范围和开漏单总线通信的要求,确定以下实现时序:

  • 时序基础单位: 5μs
  • 复位低电平时长: 480μs
  • 存在脉冲采样时机: 从释放开始计 70μs
  • 写 0: 70μs 低,然后释放 10μs
  • 写 1: 10μs 低,然后释放 70μs
  • 读时隙拉低时长: 5μs,然后释放 75μs
  • 读时隙采样时机: 从拉低开始计 15μs

以上取值均为 5μs 的整数倍,且都在数据手册允许的范围内。因此,首先编写一个延时 5μs 的子程序,然后通过循环调用此子程序即可实现所需的全部时序。

05.2 5μs 基础延时子程序

MRS2 新建的工程中,CH32V205 的主频默认为 160MHz,PIOC 的主频也同为 160MHz。由此推算,PIOC 消耗 1μs 需要 160 指令周期,那么消耗 5μs 就需要 800 指令周期。

上一篇文中已经对 PIOC 的指令周期进行说明,为了实现 5μs 的延时,我们可以编写一个循环计数的延时子程序。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
; ================
; Delay function
; caller CALL used 2cyc, NOP+RET used 3cyc, every loop used 5cyc.
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
DELAY_5US: MOVIP 159 ; 5us takes 159 loop cycles
DELAY_5US_1: NOP
NOP
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP DELAY_5US_1 ; else continue loop
NOP
RET

汇编代码的构思解析

以上汇编子程序是如何被想出来的呢?首先,已知汇编的子程序一般有以下的形式:

1
2
3
4
5
6
7
8
; ================
; An Example function
; an empty function, just for example
; ARGS: NONE
; BREAKS: NONE
EXAM_FUNC: NOP
; Do something
RET

细心的读者可能会注意到,EXAM_FUNC 的头部有一段注释,其相当于子程序的使用说明书,记录了关于子程序的一些信息:

  • 子程序的名字和功能简介;
  • ARGS 说明它期望的参数及存放的寄存器(这里是 NONE,表示无需准备任何参数);
  • BREAKS 列出它会破坏哪些寄存器或标志,也就是破坏列表(这里是 NONE,表示它不破坏任何寄存器或标志);
  • (需要返回结果时) RETURN 说明它的返回值及存放的寄存器。

汇编开发中,非常重要的一条原则是必须清楚每一条指令影响了什么。由于汇编器较为简单,无法判断寄存器的使用冲突是刻意为之,还是无心之失,因此这份注释对于保持代码的可读性和可维护性非常重要,非常强烈建议在每个子程序的开头添加这样的注释。在调用子程序之前,可以通过阅读它的 ARGS、BREAKS 和 RETURN 注释来判断是否需要保存现场、需要保护哪些寄存器等,避免无意间发生冲突。

调用此汇编子程序时,使用 CALL EXAM_FUNC 指令,调用时会消耗 2 个指令周期(CALL 指令本身消耗 2 个周期),然后执行子程序中的指令。子程序中可以使用 RET 返回到原来的 CALL 指令之后的一条指令位置继续执行,RET 指令消耗 2 个周期。因此,整个调用过程至少要消耗 4 个指令周期。为了实现 5μs 的延时,我们还需要再想办法消耗掉剩下的 796 个指令周期。

一个最简单的想法就是放 796 条 NOP 指令,这样就可以消耗掉 796 个指令周期。然而,这样做需要消耗 796 条指令容量,PIOC 的 ROM 总指令容量只有 2047 条(0x000 处的两个字节为保留用途,不会执行)。这显然是对资源的极大浪费。

容易注意到,796 条 NOP 指令呈现出典型的“重复性工作”特点,于是可以想到通过使用循环结构来以较少的 ROM 指令数实现较多条数的指令实际执行数量。

那么我们尝试把上面的例子改成循环结构:

1
2
3
4
5
6
7
8
9
; ================
; An Example function
; a demo function, just for example
; ARGS: NONE
; BREAKS: NONE
EXAM_FUNC: NOP
; Do something
JMP EXAM_FUNC ; Jump to label EXAM_FUNC
RET

在原子程序的 RET 前添加了一条 JMP EXAM_FUNC 指令,这样 PIOC 执行完子程序中的指令后,就会跳转到 EXAM_FUNC 继续执行,形成循环。

有没有觉得这有什么不对?由指令集手册可知,JMP 指令是无条件跳转,也就是说无论怎样,只要执行这条指令,程序就会跳转到 EXAM_FUNC 标签处继续执行,永远不会执行到 RET 指令。也就是说,这个子程序一旦被调用,就会陷入死循环,永远不会返回,消耗 ∞ 个指令周期。这显然不是我们期望的结果。

因此,需要给循环确定一个退出条件,比如循环次数。熟悉 C 语言的用户可能很快想到了这就是以下形式的循环:

1
2
3
for (int i = N; i > 0; i--) {
; // do something
}

不过,PIOC 的汇编语言并没有直接提供类似 C 语言的 for 循环语法,因此我们需要使用 PIOC 的指令来实现循环。PIOC 提供了 DECSZ 指令,它可以对一个 SFR 寄存器进行减 1 操作,并在减到 0 时跳过下一条指令。也就是如下伪代码:

1
2
3
4
5
6
cnt--;
if (cnt == 0) {
goto LABEL_1;
}
; // Next instruction here
LABEL_1: ; // Next+1 instruction here

那么我们尝试继续修改上面的例子使用 DECSZ 指令。我们选中一个 SFR 寄存器作为循环计数器,假设我们选择 SFR_INDIR_ADDR 作为循环计数器,并在子程序开始时将其初始化为循环次数 N,然后在循环体中使用 DECSZ 指令来实现循环。修改后的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
; ================
; An Example function
; a demo function, just for example
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
EXAM_FUNC: MOVIP 3 ; take 3 loop cycles
; Do something
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP EXAM_FUNC ; Jump to label EXAM_FUNC
RET

其中,通过 MOVIP 指令装入循环计数器 SFR_INDIR_ADDR 的值为 3,表示循环 3 次。这是一个临时的值,因为我们还没有开始分析消耗的指令周期,需要根据实际的延时要求进行调整。

思考:为什么选择 SFR_INDIR_ADDR 作为循环计数器?选闲置未使用的 SFR_DATA_REG31 会有什么不同?

看起来很好。让我们来“人脑仿真”一下这个循环的执行过程:

  • 循环开始,初始化计数器为 3
  • (执行一些指令)
  • 计数器减 1 并判断结果是否为 0:结果是 2,继续执行下一条指令
  • 跳到循环开始
  • 循环开始,初始化计数器为 3
  • (执行一些指令)
  • 计数器减 1 并判断结果是否为 0:结果是 2,继续执行下一条指令
  • 跳到循环开始
  • 循环开始,初始化计数器为 3
  • (…)

怎么又死循环了?注意看 JMP 指令,它跳转到循环的开始处,而循环开始处将循环计数器设置为 3。也就是相当于以下的 C 语言伪代码:

1
2
3
4
for (; i > 0; i--) {
i = 3; // Reset i to 3 every time
; // do something
}

也就是说循环计数器刚被减到 2,就被重新赋值为 3,循环永远不会结束。那么也很明显,我们只要把循环计数器的初始化移到循环体之外就好了。修改后的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
; ================
; An Example function
; a demo function, just for example
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
EXAM_FUNC: MOVIP 3 ; take 3 loop cycles
EXAM_FUNC_1: ; Do something
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP EXAM_FUNC_1 ; Jump to label EXAM_FUNC_1
RET

这样,每次循环跳转都会回到 EXAM_FUNC_1 标签处,而不会重新初始化循环计数器。再次“人脑仿真”一下,并同时计算指令周期:

  • 循环开始,初始化计数器为 3;消耗 1 个指令周期
  • (执行一些指令);消耗 m 个指令周期
  • 计数器减 1 并判断结果是否为 0:结果是 2,继续执行下一条指令;消耗 1 个指令周期(未跳转)
  • 跳到循环开始;消耗 2 个指令周期(跳转)
  • (执行一些指令);消耗 m 个指令周期
  • 计数器减 1 并判断结果是否为 0:结果是 1,继续执行下一条指令;消耗 1 个指令周期(未跳转)
  • 跳到循环开始;消耗 2 个指令周期(跳转)
  • (执行一些指令);消耗 m 个指令周期
  • 计数器减 1 并判断结果是否为 0:结果是 0,跳过下一条指令;消耗 2 个指令周期(跳转)
  • 执行返回;消耗 2 个指令周期(跳转)

对于循环计数器初值为 3 的情况,总共消耗的指令周期数为:
$$T_{loop=3}=1+(3-1)(m+1+2)+(m+2)+2=3m+11$$
此处特意将最后一次循环的指令周期消耗单独列出,因为其执行 DECSZ 后循环计数器为 0,发生跳转,消耗 2 个指令周期。

将其推广到循环计数器初值为 n 的情况,同时考虑到调用子程序时 CALL 指令消耗 2 个指令周期,因此总共消耗的指令周期数为:
$$T_{loop=n}=2+1+(n-1)(m+1+2)+(m+2)+2=nm+3n+4$$

为了得到一段延时 5μs 的子程序,我们希望其消耗的总指令周期数为 $T_{loop=n}=800$(至少比较接近),同时循环体内消耗的指令周期数 $m$ 必须是非负整数且较小(节约指令数量),循环计数器初值 $n$ 只能是整数且小于 256。因此可以列出以下约束条件:
$$nm+3n+4 \approx 800$$
$$m \in \mathbb{Z}^+, n \in \mathbb{Z}^+, n < 256$$

因此,首先选择一个较小的 $m$,比如 $m=2$,也就是循环体内的指令需要消耗 2 个指令周期。然后代入公式解算循环计数器初值 $n$:
$$2n+3n+4=800$$
最合适的整数解是$n=159$,此时 $T=5\times159+4=799$,仅比 800 少 1 个周期,只需在 RET 前再消耗 1 指令周期即可。

至此,只需要填充消耗所需指令周期数的指令即可。最简单的就是 NOP 指令,且没有副作用。最终的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
; ================
; A Delay function
; a delay function, 5us
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
EXAM_FUNC: MOVIP 159 ; take 159 loop cycles
EXAM_FUNC_1: NOP
NOP
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP EXAM_FUNC_1 ; Jump to label EXAM_FUNC_1
NOP
RET

实际上就是本节开头给出的 DELAY_5US 子程序,仅标签名不同。

05.3 用 5μs 时基组合出所需的所有延时

有了 5μs 的基础延时后,时序表里的每一档延时都是它的整数倍,通过重复调用 DELAY_5US 即可组合出所需的定长延时:

  • 复位低电平 480μs = DELAY_5US × 96;
  • 存在脉冲采样等待 70μs = DELAY_5US × 14;
  • 写 0 拉低 70μs = DELAY_5US × 14;
  • 写 1 拉低 10μs = DELAY_5US × 2;
  • 读时隙拉低 5μs = DELAY_5US × 1;
  • 读时隙释放后等待 70μs = DELAY_5US × 14。

其中对于 10μs 的延时,可以直接调用两次 DELAY_5US,仅两条 CALL 指令即可完成,没有必要再次封装为一个子程序,因为这会引入更多的跳转开销;而对于其它延时,若逐条展开,就要写 14 遍甚至 96 遍 CALL 指令来调用 DELAY_5US 子程序,为了方便起见可封装为独立子程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; ================
; Delay function of 5us*A
; call DELAY_5US with A as loop counter. additional cycles not counted but negligible.
; ARGS: A as loop counter
; BREAKS: A,Z,C
DELAY_XUS: CALL DELAY_5US ; Delay 5us
ADDL 0XFF ; cnt--, Z=1 if cnt==0
JNZ DELAY_XUS ; else continue loop
RET
;
; To call this function, set A to the number of 5us units.
; for example, to delay for 480us(96*5us):
MOVL 96
CALL DELAY_XUS
; At this point, 480us has passed and A is 0.

在调用子程序前,只需将所需的循环次数装入 A 寄存器即可。

在继续编写 DS18B20 的基本通信子程序之前,我们先来分析一下 DELAY_XUS 的实现。

DELAY_XUS 是一个典型的“循环执行 n 次”的逻辑。它的循环体是 CALL DELAY_5US,每次循环调用 DELAY_5US 延时 5μs。循环计数器的初值由调用方在调用前通过 MOVL 装入 A 寄存器,循环体内通过 ADDL 0XFF 自减,JNZ 判断循环计数器非零则继续循环。由于循环控制用的指令消耗的指令周期远小于 DELAY_5US,因此可以忽略不计。

由于长延时的实现是通过循环调用 DELAY_5US 实现的,这意味着长延时函数本身也需要一个外层循环计数器。但由于内层调用的 DELAY_5US 会破坏 SFR_INDIR_ADDR(注释中的 BREAKS 列出了这一项,实际上 DELAY_5US 每次执行都会用 MOVIP 159 把该寄存器重置成 159),所以外层循环计数的值不能放在 SFR_INDIR_ADDR 上。考虑到置立即数的方便,可以将外层计数改放在 A 里,调用方通过 MOVL 装入初值,循环体内 ADDL 0XFF 自减,JNZ 判断是否继续。不过,这会破坏 A 寄存器的值,因此调用方可能需要处理保护现场。

同理,DELAY_5US 返回时 Z 的值为 1(BREAKS 里也列了 Z,且 DECSZ 使 SFR 递减为 0 时进入返回分支),如果在返回后直接读取 Z,得到的是被 DELAY_5US 修改过的结果。但 DELAY_XUS 的循环中,CALL 之后先执行 ADDL 0XFF 递减 A,此指令重新设置了 Z,故 JNZ 读到的是 ADDL 刚产生的结果,而不是 DELAY_5US 留下的旧结果。

由此可见,被调用的子程序 DELAY_5US 开头注释中,BREAKS 列出的“破坏 Z、SFR_INDIR_ADDR” 的信息尤为有用。

05.4 总线初始化:复位脉冲与存在检测

1-Wire 的通信总是以主机发出的复位脉冲为开始。一个复位脉冲应当至少拉低总线 480μs 再释放,而器件检测到这个时序后,会在释放后的应答窗口内拉低总线作出响应,这称为存在脉冲。

我们首先定义三种结果码:

  • A=0:总线复位成功,且检测到存在脉冲;
  • A=1:总线忙;
  • A=2:总线复位成功,但未检测到存在脉冲。

为了保险起见,可以先将 IO0 设置为上拉输入,检测总线的电平。若总线为低电平,则说明此时总线有活动,处于忙状态,直接返回 A=1。

若总线空闲,则将 IO0 设置为推挽输出,拉低总线 480μs 后释放,以发出复位脉冲。复位脉冲完成后,将 IO0 设置为上拉输入,延时 70μs 后(此时正位于器件的存在脉冲最佳采样窗口内)检测器件的存在脉冲,若为低电平则说明有器件响应,返回 A=0;否则,说明没有器件响应,返回 A=2。

于是可以按执行逻辑“翻译式”地写出以下子程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; ================
; DS18B20 Bus Reset & Presence Detect
; reset bus and check for presence pulse
; ARGS: NONE
; BREAKS: A,SFR_DATA_EXCH
; RETURN: A=0 OK
; A=1 Bus is busy
; A=2 No presence pulse detected
DS18B20_BUSRST: MOVA1F 0B00000100 ; IO0 Input, Pull-up
BG2F BI_PORT_IN0,0 ; IO0 -> DATA_EXCH bit0
BTSS SFR_DATA_EXCH,0 ; Check if bus is high
RETL 1 ; else, return A=1, bus is busy
BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
MOVL 96 ; Load arg=480us(5us*96)
CALL DELAY_XUS ; Delay 480us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
MOVL 14 ; Load arg=70us(5us*14)
CALL DELAY_XUS ; Delay 70us, wait for presence pulse
BG2F BI_PORT_IN0,0 ; IO0 -> DATA_EXCH bit0
MOVL 82 ; Load arg=410us(5us*82)
CALL DELAY_XUS ; Delay 410us, wait to end
BTSC SFR_DATA_EXCH,0 ; Check if presence pulse detected
RETL 2 ; else, return A=2, no presence pulse
RETL 0 ; return A=0, OK

这段汇编代码对复位脉冲的发送和存在脉冲的检测的实现并不复杂,本质只是做顺序流程的执行,并判断状态决定返回的结果码,每一条指令都配有相应的说明。

小技巧:由于本节的 IO 操作较为简单,可直接使用 MOVA1F 来在一条指令内配置 IO 的上拉和方向,但这样做会同时改变 IO0 和 IO1 的配置。本节的子程序使用 A 来传递结果码,故使用一条 RETL 指令取代 MOVL + RET 指令的组合,同样可以省一条指令。

05.5 写时隙的实现

根据 05.1 节的约定的时序参数,写时隙的实现如下:

  • 写 0:拉低 70μs,然后释放 10μs;
  • 写 1:拉低 10μs,然后释放 70μs;

不难发现其实无论是写 1 还是写 0,都是先拉低总线一段时间,然后释放总线一段时间,且 05.1 一节约定的时序参数恰好保证了每个写时隙的总长均为 80μs。因此,不难想到以下的逻辑:

  • 先拉低总线 10μs。
  • 如果要写入的是 0,在第 10μs 至 70μs 期间应当保持拉低总线;如果要写入的是 1,则在第 10μs 至 70μs 期间应当释放总线。
  • 在最后 10μs 期间释放总线。

由于 DS18B20 的通信协议中规定了 LSB-first(低位在前),因此发送是从最低位开始的。

这样构思好控制流后,即可写出以下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
; ================
; DS18B20 Write slot
; write a bit to bus, from LSB
; ARGS: SFR_DATA_EXCH Bit0
; BREAKS: A
DS18B20_WSLOT: BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
BTSC SFR_DATA_EXCH,0 ; Check if bit to write is 1
MOVA1F 0B00000100 ; IO0 Input, Pull-up
MOVL 12 ; Load arg=60us(5us*12)
CALL DELAY_XUS ; Delay 60us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
RET

以上汇编代码实现了从 SFR_DATA_EXCH 的最低位读取要写入的位,并根据其值决定在第 10μs 至 70μs 期间是保持拉低还是释放总线。最后,释放总线并延时 10μs,完成一个写时隙。

05.6 读时隙的实现

根据 05.1 节的约定的时序参数,读时隙的实现如下:

  • 先拉低 5μs,然后释放总线
  • 从拉低开始计 15μs 时读取,即为该位结果
  • 从释放总线开始计 75μs 的时间内保持释放,直到 75μs 的时间段结束后,读时隙结束。

同样是简单的顺序流程控制,继续“翻译”,读时隙的汇编实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; ================
; DS18B20 Read slot
; read a bit from bus, to MSB
; ARGS: NONE
; BREAKS: A,SFR_DATA_EXCH
DS18B20_RSLOT: BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
CALL DELAY_5US ; Delay 5us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
BG2F BI_PORT_IN0,7 ; IO0 -> DATA_EXCH bit7
MOVL 13 ; Load arg=65us(5us*13)
CALL DELAY_XUS ; Delay 65us
RET

以上汇编代码实现了从总线上读取一个位,并将其放入 SFR_DATA_EXCH 的最高位。

小提示:由于读时隙函数会配合 RCR 指令使用,因此在读取数据时需要特别注意位移的方向。为了使最开始读到的第 1 位数据在读取完成后位于一个字节的最低位,应该将其放入 SFR_DATA_EXCH 中的最高位,然后由后续调用的 RCR 指令进行移位。

06 编写基本通信子程序

当复位、存在检测、写时隙和读时隙的子程序都实现后,就可以组合出完整的 1-Wire 通信子程序了。本节将实现字节的读写功能,供后续的 DS18B20 操作使用。

06.1 字节写的实现

在 05.5 一节中,写时隙子程序已经约定传入的字节位于 SFR_DATA_EXCH 中,且 LSB 在最低位,故沿用此约定。只需调用一次 DS18B20_WSLOT 即可写入一个位,然后通过 RCR 指令将 SFR_DATA_EXCH 中的每一位都右移,即可将要发送的下一位移到最低位;然后,继续调用 DS18B20_WSLOT 写入下一位,再移位……如此重复 8 次即可完成一个字节的写入。

因此,只需照搬 05.2 中的循环结构即可实现字节写的功能。编写的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; ================
; DS18B20 Write Byte
; write a byte to bus
; ARGS: SFR_DATA_EXCH
; BREAKS: Z,SFR_DATA_EXCH
DS18B20_WB: CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RET

以上汇编代码实现了从 SFR_DATA_EXCH 的最低位读取要写入的位,并调用 DS18B20_WSLOT 写入总线,再将 SFR_DATA_EXCH 整体右移一位,重复 8 次完成一个字节的写入。

小提示:此处没有使用循环结构,而是直接展开为8次重复的代码调用。由于 A 在 DS18B20_WSLOT 中调用 DELAY_XUS 时被使用,而 DELAY_XUS 调用 DELAY_5US 会破坏 SFR_INDIR_ADDR。此时,SFR_INDIR_ADDR2 几乎是最后一个可以单指令置立即数的 SFR 寄存器。而如果后续会使用到间接寻址,就会发生冲突,而发送 1 字节只需要重复 8 次调用,指令数量开销有限,因此不用循环结构,而是直接展开。下一节中的字节读也采取相同的方法。
留意栈深度:本节的函数看似功能简单,但调用链 DS18B20_WB -> DS18B20_WSLOT -> DELAY_XUS -> DELAY_5US 已使用 3 级堆栈,而堆栈总共有 6 级深度。因此 PIOC 编程中需要小心,避免出现过深的函数嵌套导致栈溢出。

06.2 字节读的实现

字节读的实现与字节写类似,只是调用的子程序不同。每次调用 DS18B20_RSLOT 读取一个位后,需要将读取到的位放入 SFR_DATA_EXCH 的最高位,然后通过 RCR 指令将其右移一位,以便下一次读取的位可以放入最高位。如此重复 8 次即可完成一个字节的读取。

编写的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; ================
; DS18B20 Read Byte
; read a byte from bus
; ARGS: NONE
; BREAKS: Z,SFR_DATA_EXCH
DS18B20_RB: CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RET

07 完成整个 PIOC 固件开发

一次完整的 1-Wire 命令流程为:复位 → ROM 命令 → 功能命令。单器件应用时 ROM 命令用 SKIP ROM (0xCC) 跳过寻址;功能命令则是本次要执行的操作,本文用到 CONVERT T (0x44) 启动温度转换、READ SCRATCHPAD (0xBE) 读取温度数据。

07.1 DS18B20 的操作流程

由于 DS18B20 设计上支持单总线多器件运行,因此在开始向一个器件发送或接收数据之前,必然存在一个“寻址”过程,对于 DS18B20 来说,这个过程通过 ROM 命令来实现。

因此,在通信时,主机必须先发出复位脉冲,然后等待器件响应的存在脉冲,确认有器件在总线上后再发送 ROM 命令进行“寻址”。由于许多应用中,每条总线上只有一个 DS18B20 器件,对于这种单器件应用的情况,可以在 ROM 命令阶段直接使用 SKIP ROM(指令码 0xCC)跳过寻址。为简单起见,本文不讨论单总线多器件应用的情况。

寻址完成后,特定的一个目标器件被选中,主机可以开始发送功能命令。DS18B20 支持以下功能命令:

  • Convert T 温度转换: 命令码为 0x44,用于启动温度转换过程,结果保存在温度寄存器中。
  • Write Scratchpad 写寄存器: 命令码为 0x4E,用于向 DS18B20 的寄存器写入数据。
  • Read Scratchpad 读寄存器: 命令码为 0xBE,用于读取 DS18B20 的寄存器数据。
  • Copy Scratchpad 复制寄存器: 命令码为 0x48,用于将寄存器数据写入 EEPROM 中。
  • Recall E2 读取 EEPROM: 命令码为 0xB8,用于从 EEPROM 中读取数据到寄存器。
  • Read Power Supply 读取供电方式: 命令码为 0xB4,用于检测 DS18B20 的供电方式。

本例中,我们选择器件默认的 12 位分辨率,不作调整,因此只需使用 Convert T 命令启动温度转换,然后使用 Read Scratchpad 命令读取温度数据。

07.2 组合命令:发起温度转换

主机在 ROM 命令阶段完成后,可以发起温度转换命令 CONVERT T。温度转换需要消耗一定时间来完成(与配置的分辨率有关,12 位分辨率下约 750ms,9 位分辨率下约 93.75ms)。若使用外部供电方式,主机可以在此命令后发起读时隙,若 DS18B20 仍在转换中,则会拉低总线,主机可以通过检测总线电平来判断转换是否完成;若使用寄生供电方式,由于主机需要在整个转换过程中强上拉总线,故无法发起读时隙来检测转换状态,只能通过延时等待转换完成。

为了发起温度转换,主机需要按照以下步骤进行操作:

  • 复位&存在检测
  • SKIP ROM(0xCC)
  • 发出 CONVERT T 命令(0x44)

因此可以编写此部分子程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; ================
; DS18B20 Start Temperature Conversion
; send SKIP ROM and CONVERT T command to DS18B20
; ARGS: NONE
; BREAKS: A,Z,SFR_DATA_EXCH
; RETURN: A=0 OK, else pass error code from DS18B20_BUSRST
DS18B20_CONV: CALL DS18B20_BUSRST ; Reset bus and check for presence pulse
ANDL 0XFF ; Test if A==0
JNZ DS18B20_CONV_1 ; If A!=0, return error code
MOVL 0XCC ; Load SKIP ROM command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 0X44 ; Load CONVERT T command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
DS18B20_CONV_1: RET

07.3 组合命令:读取温度数据

主机在 ROM 命令阶段完成后,可以发起读取寄存器命令 READ SCRATCHPAD。DS18B20 的寄存器共 9 字节,其中前 2 字节为温度数据,随后的 6 字节为配置、保留数据等,最后 1 字节为前 8 字节的 CRC 校验值。主机可以通过读取这些寄存器来获取温度数据和其他信息。

我们主要关注前两个寄存器。第一个寄存器是温度寄存器的低字节,第二个寄存器是温度寄存器的高字节。

小提示:温度寄存器在 DS18B20 上电后的默认值是 0x0550,对应的温度结果为 85℃。如果发现读到的温度值始终是 85℃,说明转换未完成就进行了读取或未能成功发出温度转换命令。

为了读取温度数据,主机需要按照以下步骤进行操作:

  • 复位&存在检测
  • SKIP ROM(0xCC)
  • 发出 Read Scratchpad 命令(0xBE)
  • 读 9 字节(前 2 字节为温度数据,其余为配置、保留字节等)

因此可以编写此部分子程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; ================
; DS18B20 Read Data
; send SKIP ROM and READ command to DS18B20
; ARGS: NONE
; BREAKS: A,Z,SFR_DATA_EXCH,SFR_INDIR_ADDR2,SFR_DATA_REG0-8,SFR_DATA_REG31
; RETURN: A=0 OK, else pass error code from DS18B20_BUSRST
DS18B20_READ: CALL DS18B20_BUSRST ; Reset bus and check for presence pulse
ANDL 0XFF ; Test if A==0
JNZ DS18B20_READ_2 ; If A!=0, return error code
MOVL 0XCC ; Load SKIP ROM command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 0XBE ; Load READ command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 9 ; take 9 bytes to read
MOVA SFR_DATA_REG31 ; loopCnt is in SFR_DATA_REG31
MOVIA 0x20 ; Set ptr to SFR_DATA_REG0 using INDIR2
DS18B20_READ_1: CALL DS18B20_RB ; Read a byte from bus
MOV SFR_DATA_EXCH,A ; Load read byte into A
MOVA SFR_INDIR_PORT2 ; Store A into SFR_DATA_x, auto increment
DECSZ SFR_DATA_REG31,F ; cnt--, skip next if cnt==0
JNZ DS18B20_READ_1 ; else continue loop
CLRA ; return A=0, OK
DS18B20_READ_2: RET

在正常读取数据的执行路径中,有一条 CLRA 指令将 A 清零。细心的读者如果对比上一节中发起温度转换的子程序可能会发现,这条指令在发起温度转换的子程序中并没有出现。原因是在发起温度转换的子程序中,返回 A=0 的执行路径中没有任何指令会破坏 A 寄存器的值,因此可以直接返回 A=0;而在读取温度数据的子程序中,返回 A=0 的执行路径里,为了将 SFR_DATA_EXCH 中读到的数据送入间接寻址指向的寄存器,需要经由 A 中转,因此有一条 MOV SFR_DATA_EXCH,A 指令,而这会破坏 A 寄存器的值。因此需要在返回前将 A 设置为正确的返回值。由于此执行路径上,A 在破坏前的值必然是 0,因此可以直接使用 CLRA 指令将 A 清零。

思考:为什么在正常读取数据的执行路径中,A 寄存器一定不可能非 0?为什么这里使用了 SFR_DATA_REG31 存放循环计数值?这和 05.2 当中的循环计数器使用方法有什么不同?为什么要留下 SFR_INDIR_ADDR2 不用?

07.4 命令分发与结果返回

至此,DS18B20 的基本通信子程序已经完成。为了主核能够可靠地调用,可以按照上一篇中“主核发命令带参数,PIOC 执行完回结果”的范式设计 PIOC 侧的命令分发与结果返回部分代码。

结合 04.2 一节中对命令码、结果码与寄存器的定义,本文中继续使用上一篇中的“命令码 + 参数 + 结果码”的方式来进行通信。主核通过写入命令码和参数到 PIOC 的寄存器中,PIOC 执行相应的操作后,将结果码写回到寄存器中,主核再读取结果码以判断操作是否成功。命令分发部分仍然采用上一篇中的示例汇编代码的结构,并进行少量修改以适应 DS18B20 的操作流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
; ================
; Main entry
; Dispatch commands from SFR_CTRL_WR
;
MAIN_FUNC: BC SFR_SYS_CFG,SB_INT_REQ ; Cancel IRQ
WAITB WB_DATA_MW_SR_1 ; Wait for CmdCode from MCU
MOV SFR_CTRL_WR,A ; Load CmdCode into A
CMPL 0X01 ; If: CmdCode==0x01?
JZ CMD_CONV ; Then: Jump to CALL_CONVERT_T
CMPL 0X02 ; ElseIf: CmdCode==0x02?
JZ CMD_READ ; Then: Jump to CALL_READ_SCR
CMPL 0X03 ; ElseIf: CmdCode==0x03?
JZ CMD_POLL ; Then: Jump to CALL_POLL_CONV
JMP MAIN_FUNC ; Else: No CmdCode matched. Wait for next CmdCode.
;
CMD_CONV: CALL DS18B20_CONV
MOVA SFR_CTRL_RD ; Load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
CMD_READ: CALL DS18B20_READ
MOVA SFR_CTRL_RD ; Load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
CMD_POLL: CALL DS18B20_RSLOT
MOV SFR_DATA_EXCH,A ; Load ReadSlot result into A
MOVA SFR_CTRL_RD ; Then load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC

由于检测温度转换是否完成的方式是通过单个读时隙来判断总线电平,代码非常简单,因此直接在命令分发中增加了一个 CMD_POLL 分支,用于主核发起读时隙操作,PIOC 执行后将数据存放在 SFR_DATA_EXCH 寄存器的最高位并返回给主核。

08 主核侧代码开发

08.1 主核侧的准备工作

由于 PIOC 的汇编代码文件不会自动被 MRS2 编译并载入固件,因此需要手工将 PIOC 的汇编代码编译为二进制文件,然后在主核侧将其加载到 PIOC 的 SRAM 中。

沁恒提供了一个称为 WASM53B 的汇编器,可以将 PIOC 的汇编代码编译为二进制文件。为了方便将二进制文件嵌入到主核侧的 C 代码中,可以使用随附的 BIN_HEX 工具将二进制文件转换为 C 语言的数组形式。在 Windows环境下,可以创建一个批处理脚本来自动完成编译和转换的过程。以下是一个示例批处理脚本:

1
2
3
4
@ECHO OFF
.\WASM53B PIOC_FW.ASM
.\BIN_HEX PIOC_FW.BIN PIOC_FW_inc.h /C
PAUSE

其中的文件目录可能需要按照实际情况修改。编辑好批处理脚本后,双击运行即可调用汇编器,最终生成 PIOC_FW_inc.h 文件,输出如:

1
2
3
4
5
6
7
8
9
10
11
12
13
MCU CH53X ASSEMBLER:  WASM53B Ver 3.1
Copyright (C) wch.cn 1998-2021, B211121
Website: http://wch.cn

Open source file PIOC_FW.ASM
Create list file PIOC_FW.LST
Create data file PIOC_FW.BIN
Success, END=008BH

Binary to HEX V3.0, Copyright (C) wch.cn 2016
for include data in C or ASM source file
Source size: 278 bytes
请按任意键继续. . .

以上输出表示汇编器没有检测到语法错误,成功生成了二进制文件 PIOC_FW.BIN 和 C 语言头文件 PIOC_FW_inc.h。然后,在主核侧的 C 代码中,建立一个名为 piocFwBin 的数组,包含 PIOC_FW_inc.h 中的内容,如下所示。

1
2
3
__attribute__ ((aligned (16))) static const uint8_t piocFwBin[] = {
......
};

样例中的数组内容仅为占位符,请填入实际生成的 PIOC_FW_inc.h 文件中的内容。随后在主核侧的代码中用 memcpy 将其加载到 PIOC 复用的 RAM 空间内即可。

由于在 CH32V205 上,PIOC 复用从 0x20000000 开始的 4KB RAM 作为程序 ROM 空间,为了避免与主核的 RAM 区域发生冲突,需要在链接脚本中修改 RAM 区域的定义。

修改前默认的 RAM 区域定义:

1
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 32K

主核可用的 RAM 区域是从 0x20001000 开始的 28K,修改如下:

1
RAM (xrw) : ORIGIN = 0x20001000, LENGTH = 28K

08.2 主核侧的 API 封装

为了方便起见,封装以下 PIOC 的 API 接口。在初始化 GPIO 时,由于 DS18B20 的单总线为开漏驱动,故选择了 GPIO_Mode_AF_OD 模式,而不是驱动 WS2812 时使用的 GPIO_Mode_AF_PP 模式。本文示例使用的是 PB10,若使用其它 GPIO,可能需要相应修改初始化的相关配置,以及 AF 编号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 初始化 PIOC
void PIOC_Init (void) {
RCC_PB2PeriphClockCmd (RCC_PB2Periph_AFIO | RCC_PB2Periph_GPIOB, ENABLE);
GPIO_PinAFConfig (GPIOB, GPIO_PinSource10, GPIO_AF4);
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_High;
GPIO_Init (GPIOB, &GPIO_InitStructure);

RCC_HBPeriphClockCmd (RCC_HBPeriph_PIOC, ENABLE);
PIOC->D8_SYS_CFG = RB_MST_RESET | RB_MST_IO_EN0; // Halt & reset PIOC
memcpy ((uint8_t *)(PIOC_SRAM_BASE), piocFwBin, sizeof (piocFwBin)); // load code for PIOC
PIOC->D8_SYS_CFG = RB_MST_IO_EN0;
}

// 强制复位 PIOC
void PIOC_Reset (void) {
R8_SYS_CFG |= RB_MST_RESET; // reset PIOC
R8_SYS_CFG &= (~RB_MST_RESET); // reset PIOC
}

// 启动 PIOC
void PIOC_Run (void) {
R8_SYS_CFG |= RB_MST_CLK_GATE;
}

// 停止 PIOC
void PIOC_Halt (void) {
R8_SYS_CFG &= (~RB_MST_CLK_GATE);
}

// 等待 PIOC 发出的中断请求
void PIOC_WaitForIRQ (void) {
while ((PIOC->D8_SYS_CFG & RB_INT_REQ) == 0) {
;
}
}

// 发送命令给 PIOC
void PIOC_Cmd (uint8_t cmd) {
R8_CTRL_WR = cmd;
}

// 获取 PIOC 返回的结果
uint8_t PIOC_GetResult (void) {
return R8_CTRL_RD;
}

这样,就可以通过以下例子演示的方式来调用 PIOC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 初始化 PIOC 并启动
PIOC_Init();
PIOC_Run();
while (1) {
// 发送命令给 PIOC
PIOC_Cmd(0x01); // 传入给 PIOC 的命令码
PIOC_WaitForIRQ(); // 等待 PIOC 完成操作并发出中断请求
uint8_t result = PIOC_GetResult(); // 获取结果
if (result != 0) {
printf ("pioc err:%d\n", result);
Delay_Ms (100);
continue;
}
// 处理数据(如果需要)
MyApp_ProcessData();
}

08.3 完成 DS18B20 的温度读取流程

主核侧的 API 封装完成后,就可以编写温度读取的代码了。整个过程基本上是调用 PIOC 固件中实现的命令来进行的:启动温度转换、轮询转换状态、读取结果并换算温度。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 初始化并启动 PIOC
PIOC_Init();
PIOC_Run();

while (1) {
uint8_t piocRes;

// 启动温度转换(Convert T)
PIOC_Cmd (0x01);
PIOC_WaitForIRQ();
piocRes = PIOC_GetResult();
if (piocRes) {
printf ("Conv-1-wire err:%d\n", piocRes);
Delay_Ms (100);
continue;
}

// 轮询转换状态(Poll),直到转换完成
while (1) {
PIOC_Cmd (0x03);
PIOC_WaitForIRQ();
piocRes = PIOC_GetResult();
if (piocRes & 0x80) {
break; // 转换完成
}
Delay_Ms (10); // 仍在转换,稍后再查
}

// 读取暂存区(Read Scratchpad)
PIOC_Cmd (0x02);
PIOC_WaitForIRQ();
piocRes = PIOC_GetResult();
if (piocRes) {
printf ("Read-1-wire err:%d\n", piocRes);
Delay_Ms (100);
continue;
}

// 换算温度
float tmpF = ((int16_t)PIOC->D16_DATA_REG0_1) / 16.0f;
printf ("T=%.1f\r\n", tmpF);
}

首先,需要调用 PIOC_Cmd (0x01) 发送 Convert T 命令,对应 07.4 中固件实现的 CMD_CONV 分支,固件完成复位与存在检测后,会根据执行结果返回结果码:

  • 若结果码非 0,说明复位无应答(结果码 0x02)或总线忙(结果码 0x01),打印错误信息后延时 100ms continue 跳过本轮后续步骤,重新开始;
  • 若结果为 0,则继续第二步。

第二步是一个无限循环,每 10ms(间隔可按转换时长调整)调用一次 PIOC_Cmd (0x03) 向 DS18B20 查询转换是否完成,直到 DS18B20 返回转换完成的结果。该命令对应 07.4 中固件实现的 CMD_POLL 分支,PIOC 仅执行一次读时隙(DS18B20_RSLOT),将采样到的总线电平写入 SFR_DATA_EXCH 的最高位(bit7)并通过 SFR_CTRL_RD 返回。其原理是 07.2 中所述的“外部供电下在转换期间发起读时隙查询转换状态”,此处不再赘述。

第三步调用 PIOC_Cmd (0x02) 发送 Read Scratchpad 命令,对应 07.4 中固件实现的 CMD_READ 分支,固件完成复位与存在检测后,发送 SKIP ROM 与 READ SCRATCHPAD 命令,随后读取 9 字节暂存区数据,依次存入 SFR_DATA_REG0 至 SFR_DATA_REG8 的 9 个 SFR 寄存器,结果码仍通过 SFR_CTRL_RD 返回。与第一步相同,若结果非 0 则打印错误并延时后重试。

此时温度数据存放在 SFR_DATA_REG0 与 SFR_DATA_REG1 两个 8 位寄存器中,PIOC->D16_DATA_REG0_1 实际上是将 DATA_REG0 作为低字节(对应温度 LSB)、DATA_REG1 作为高字节(对应温度 MSB)组合为 16 位寄存器读取。温度数据是一个 12 位有符号的 Q4 定点数,通过符号位扩展填充到 16 位。格式如下:

  • Bit15-Bit12:符号位扩展,始终与 Bit11 保持一致;
  • Bit11-Bit0: 有符号温度值,单位为 0.0625℃/LSB(Q4.12);

也就是说,原 16 位寄存器的高 5 位(bit15~bit11)都是同一个符号位 S 的重复,这正是标准的有符号数符号扩展行为。因此,从 PIOC->D16_DATA_REG0_1 读出的 16 位原始值本身就是一份完整的、带符号扩展的 Q4 定点数,直接把读出的值强制转换为 16 位有符号数(int16_t),再在浮点格式下除以 16 即可完成换算:

1
float tmpF = ((int16_t)PIOC->D16_DATA_REG0_1) / 16.0f;

也就是读者可能经常在其它 DS18B20资料中看到的换算公式:

$$T = \frac{data}{16} = 温度值 \times 0.0625(℃)$$

08.4 编译、下载与效果演示

首先,确保主核侧的 C 代码已经正确包含了 PIOC 固件的二进制数组,并且按 08.3 一节完成主函数的编写。然后点击 MRS2 工具栏的重新编译按钮(或按 Shift+F7)编译工程,编译产物位于工程的 obj/ 目录;点击工具栏的下载按钮(或按 F8)将编译生成的固件下载到芯片。

在 MRS2 中编译和下载

随后,将开发板通过 USB 转串口线连接到电脑,默认的串口 TXD 引脚为 PA9(以实际工程为准)。打开串口助手,设置波特率为 115200,数据位 8,停止位 1,无校验,即可看到周期性输出的温度数值:

串口输出

若 DS18B20 未接好、上拉电阻缺失或接线错误,导致通信失败,则会看到类似以下的错误信息:

1
2
Conv-1-wire err:2
Conv-1-wire err:2

其中 err:2 对应 04.2 结果码定义中的“无应答”,可参考 03 章的接线检查方法排查。若一切正常但温度始终是 85.0,说明读到的是 DS18B20 上电默认温度寄存器值 0x0550,转换未真正完成。

09 本篇中的实用技巧

在本文的开发中,我们用到了许多 PIOC 开发的实用技巧。这里把正文用到的技巧单独整理出来,方便读者回顾。

技巧 1:[05.2]子程序使用说明书(ARGS / BREAKS / RETURN)

每个子程序头部都用注释写清“入口条件 / 破坏列表 / 返回值”,这份注释相当于子程序的“使用说明书”。汇编没有编译器检查参数与寄存器的冲突,调用子程序前先读一下 ARGS / BREAKS / RETURN,就能判断是否要保存现场、保存哪些寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
; ================
; Delay function
; caller CALL used 2cyc, NOP+RET used 3cyc, every loop used 5cyc.
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
DELAY_5US: MOVIP 159 ; 5us takes 159 loop cycles
DELAY_5US_1: NOP
NOP
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP DELAY_5US_1 ; else continue loop
NOP
RET

技巧 2:[05.3]破坏列表(BREAKS)决定寄存器怎么分配

05.2 一节延时子程序的“使用说明书”里,BREAKS 列出的破坏列表是调用方需要留意的一项。它写清了这个子程序会破坏哪些寄存器或标志。由于汇编器不会自动判断寄存器冲突,最好在调用前先确认被调子程序的 BREAKS,再决定自己的寄存器怎么分配。

而这一点在 05.3 的参数化延时就得到了应用:DELAY_5US 每次执行都会用 MOVIP 159 把 SFR_INDIR_ADDR 重置成 159(破坏列表中有),所以外层循环计数不能放在 SFR_INDIR_ADDR 上。考虑到置立即数的方便,DELAY_XUS 把外层计数改放在 A 里,这样调用方可以用 MOVL 装入初值,循环体内 ADDL 0XFF 自减、JNZ 判断是否继续。不过这会破坏 A 的值,因此再上一级的调用方可能需要处理保护现场。究竟要不要保护、保护哪些寄存器、哪些寄存器可以放心使用,都要结合被调子程序的破坏列表来确定。

技巧 3:[05.3]用 5μs 时基 + 参数化延时组合定长延时

基础延时(DELAY_5US)+ 参数化循环(DELAY_XUS)把 480μs、70μs 等长延时压缩成几条指令,避免逐条展开数十条 CALL 浪费大量程序空间(见 05.3)。调用前用 MOVL 装入循环次数即可:

1
2
3
4
DELAY_XUS:      CALL DELAY_5US              ; Delay 5us
ADDL 0XFF ; cnt--, Z=1 if cnt==0
JNZ DELAY_XUS ; else continue loop
RET

技巧 4:[06.1]留意栈深度

PIOC 的堆栈总共只有 6 级。06.1 的 DS18B20_WB -> DS18B20_WSLOT -> DELAY_XUS -> DELAY_5US 调用链已用掉 3 级栈深,功能复杂后需考虑如何节省栈的使用,避免嵌套过深导致栈溢出。

10 结语

本文以通过 PIOC 与 DS18B20 通信为例,演示了如何使用 PIOC 进行单线双向数字协议的通信,并带领读者逐步完成了 PIOC 侧的汇编固件开发。与上一篇驱动 WS2812 的“纯输出”工况不同,1-Wire 总线的数据线需要分时双向通信,这恰好发挥了 PIOC 的优势:指令周期确定、IO 操作灵活,很适合处理这类 μs 级时序的单线协议。把“精确时序 + 单线收发”这件主核难以优雅处理的事,交给了一个更合适的组件来实现,也就是所说的“专业的人做专业的事”。

回顾整条开发路径,其实思路并不复杂:先实现 5μs 的基础延时,组合出复位、写时隙、读时隙等全部时序;把位时序封装成字节读写,然后组合出“复位-寻址-功能命令”的完整流程;通过“命令-响应”的方式实现与主核侧的协同,完成温度转换与读取操作。

PIOC 的汇编开发并不神秘,更确切地说就是 KISS(Keep It Simple, Stupid)原则的应用。类似地,本文中涉及的许多方法也能迁移到其他单线双向器件乃至更多数字协议的驱动开发上。

附录

完整汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
; PIOC FW for DS18B20
; ASSUME DQ @ IO0, fClk=160M
;
; Include SFR definition
INCLUDE PIOC_INC.ASM
;
; RESET VECTOR at 0x0000
ORG 0X0000 ; Begin at 0
DW 0X0000 ; RSVD word
JMP MAIN_FUNC
;
; ================
; Delay function
; caller CALL used 2cyc, NOP+RET used 3cyc, every loop used 5cyc.
; ARGS: NONE
; BREAKS: Z,SFR_INDIR_ADDR
DELAY_5US: MOVIP 159 ; 5us takes 159 loop cycles
DELAY_5US_1: NOP
NOP
DECSZ SFR_INDIR_ADDR,F ; cnt--, skip next if cnt==0
JMP DELAY_5US_1 ; else continue loop
NOP
RET
;
; ================
; Delay function of 5us*A
; call DELAY_5US with A as loop counter. additional cycles not counted but negligible.
; ARGS: A as loop counter
; BREAKS: A,Z,C
DELAY_XUS: CALL DELAY_5US ; Delay 5us
ADDL 0XFF ; cnt--, Z=1 if cnt==0
JNZ DELAY_XUS ; else continue loop
RET
;
; ================
; DS18B20 Bus Reset & Presence Detect
; reset bus and check for presence pulse
; ARGS: NONE
; BREAKS: A,SFR_DATA_EXCH
; RETURN: A=0 OK
; A=1 Bus is busy
; A=2 No presence pulse detected
DS18B20_BUSRST: MOVA1F 0B00000100 ; IO0 Input, Pull-up
BG2F BI_PORT_IN0,0 ; IO0 -> DATA_EXCH bit0
BTSS SFR_DATA_EXCH,0 ; Check if bus is high
RETL 1 ; else, return A=1, bus is busy
BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
MOVL 96 ; Load arg=480us(5us*96)
CALL DELAY_XUS ; Delay 480us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
MOVL 14 ; Load arg=70us(5us*14)
CALL DELAY_XUS ; Delay 70us, wait for presence pulse
BG2F BI_PORT_IN0,0 ; IO0 -> DATA_EXCH bit0
MOVL 82 ; Load arg=410us(5us*82)
CALL DELAY_XUS ; Delay 410us, wait to end
BTSC SFR_DATA_EXCH,0 ; Check if presence pulse detected
RETL 2 ; else, return A=2, no presence pulse
RETL 0 ; return A=0, OK
;
; ================
; DS18B20 Write slot
; write a bit to bus, from LSB
; ARGS: SFR_DATA_EXCH Bit0
; BREAKS: A
DS18B20_WSLOT: BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
BTSC SFR_DATA_EXCH,0 ; Check if bit to write is 1
MOVA1F 0B00000100 ; IO0 Input, Pull-up
MOVL 12 ; Load arg=60us(5us*12)
CALL DELAY_XUS ; Delay 60us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
RET
;
; ================
; DS18B20 Read slot
; read a bit from bus, to MSB
; ARGS: NONE
; BREAKS: A,SFR_DATA_EXCH
DS18B20_RSLOT: BC SFR_PORT_IO,SB_PORT_OUT0 ; LOW -> IO0
MOVA1F 0B00000001 ; IO0 Output
CALL DELAY_5US ; Delay 5us
MOVA1F 0B00000100 ; IO0 Input, Pull-up
CALL DELAY_5US ; Call twice, delay 10us
CALL DELAY_5US
BG2F BI_PORT_IN0,7 ; IO0 -> DATA_EXCH bit7
MOVL 13 ; Load arg=65us(5us*13)
CALL DELAY_XUS ; Delay 65us
RET
;
;
; ================
; DS18B20 Write Byte
; write a byte to bus
; ARGS: SFR_DATA_EXCH
; BREAKS: Z,SFR_DATA_EXCH
DS18B20_WB: CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RCR SFR_DATA_EXCH,F ; Shift right to put next bit on LSB
CALL DS18B20_WSLOT ; Write a bit to bus
RET
;
; ================
; DS18B20 Read Byte
; read a byte from bus
; ARGS: NONE
; BREAKS: Z,SFR_DATA_EXCH
DS18B20_RB: CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RCR SFR_DATA_EXCH,F ; Shift right
CALL DS18B20_RSLOT ; Read a bit from bus
RET
;
; ================
; DS18B20 Start Temperature Conversion
; send SKIP ROM and CONVERT T command to DS18B20
; ARGS: NONE
; BREAKS: A,Z,SFR_DATA_EXCH
; RETURN: A=0 OK, else pass error code from DS18B20_BUSRST
DS18B20_CONV: CALL DS18B20_BUSRST ; Reset bus and check for presence pulse
ANDL 0XFF ; Test if A==0
JNZ DS18B20_CONV_1 ; If A!=0, return error code
MOVL 0XCC ; Load SKIP ROM command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 0X44 ; Load CONVERT T command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
DS18B20_CONV_1: RET
;
; ================
; DS18B20 Read Data
; send SKIP ROM and READ command to DS18B20
; ARGS: NONE
; BREAKS: A,Z,SFR_DATA_EXCH,SFR_INDIR_ADDR2,SFR_DATA_REG0-8,SFR_DATA_REG31
; RETURN: A=0 OK, else pass error code from DS18B20_BUSRST
DS18B20_READ: CALL DS18B20_BUSRST ; Reset bus and check for presence pulse
ANDL 0XFF ; Test if A==0
JNZ DS18B20_READ_2 ; If A!=0, return error code
MOVL 0XCC ; Load SKIP ROM command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 0XBE ; Load READ command into A
MOVA SFR_DATA_EXCH ; Load command to SFR_DATA_EXCH
CALL DS18B20_WB ; Write a byte to bus
MOVL 9 ; take 9 bytes to read
MOVA SFR_DATA_REG31 ; loopCnt is in SFR_DATA_REG31
MOVIA 0x20 ; Set ptr to SFR_DATA_REG0 using INDIR2
DS18B20_READ_1: CALL DS18B20_RB ; Read a byte from bus
MOV SFR_DATA_EXCH,A ; Load read byte into A
MOVA SFR_INDIR_PORT2 ; Store A into SFR_DATA_x, auto increment
DECSZ SFR_DATA_REG31,F ; cnt--, skip next if cnt==0
JNZ DS18B20_READ_1 ; else continue loop
CLRA ; return A=0, OK
DS18B20_READ_2: RET
;
; ================
; Main entry
; Dispatch commands from SFR_CTRL_WR
;
MAIN_FUNC: BC SFR_SYS_CFG,SB_INT_REQ ; Cancel IRQ
WAITB WB_DATA_MW_SR_1 ; Wait for CmdCode from MCU
MOV SFR_CTRL_WR,A ; Load CmdCode into A
CMPL 0X01 ; If: CmdCode==0x01?
JZ CMD_CONV ; Then: Jump to CALL_CONVERT_T
CMPL 0X02 ; ElseIf: CmdCode==0x02?
JZ CMD_READ ; Then: Jump to CALL_READ_SCR
CMPL 0X03 ; ElseIf: CmdCode==0x03?
JZ CMD_POLL ; Then: Jump to CALL_POLL_CONV
JMP MAIN_FUNC ; Else: No CmdCode matched. Wait for next CmdCode.
;
CMD_CONV: CALL DS18B20_CONV
MOVA SFR_CTRL_RD ; Load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
CMD_READ: CALL DS18B20_READ
MOVA SFR_CTRL_RD ; Load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
CMD_POLL: CALL DS18B20_RSLOT
MOV SFR_DATA_EXCH,A ; Load ReadSlot result into A
MOVA SFR_CTRL_RD ; Then load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
END
;
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×