做了一个光照度传感综合实验,前面写过寄存器级驱动,这一篇专门聊硬件抽象层(HAL)代码。这个实验里我用的是NEWLAB实验平台,传感器侧选了一块常见的数字光照度模块,通过I2C总线通信。上一版代码把寄存器操作、时序控制、业务逻辑全部揉在一起,能用,但换一个平台就得重写一遍,非常痛苦。这次动手把HAL层单独抽出来,折腾完之后最大的感受是:分层这件事,早做早省心。
这篇主要讲清楚我在HAL层的接口设计思路、代码结构和踩坑过程,内容覆盖头文件定义、句柄设计、I2C平台适配、测量命令封装、量程与换算公式,以及最终编译调试的完整流程。适合正在做传感器驱动封装、或者准备从“能用就行”走向“可移植可复用”的开发者参考。
1. 为什么需要硬件抽象层:从寄存器驱动到可移植架构
1.1 实验(一)留下的教训
做第一个版本的时候,为了图省事,直接把所有功能全塞在一个源文件里。初始化函数直接操作I2C寄存器,读取光照度的时候把传感器命令字节写死在业务代码里,延时也直接用裸延时。测试的时候挂在开发板上看起来没问题,数据也挺准,但等我想把这个模块挪到另一个单片机平台时,问题全冒出来了。
首先是I2C底层接口不一样。不同平台的I2C外设寄存器、初始化方法完全不同,原来写死的那几个库函数在新平台上根本不存在。其次,传感器的命令字和换算系数散落在应用代码里,想改一个量程设置,得在好几个文件里翻来翻去。当时让我印象最深的一件事是:我在调试某个功能时想临时把传感器配置成高分辨率模式,结果改了一个命令字,没留意另一个地方还有一份几乎相同的配置指令,两边参数不一致,数据直接乱套。查了半天才发现是重复代码同步问题。
那个版本的代码如果继续往下加功能,后面只会越来越难维护。所以到实验(二)的时候,我第一件事就是下决心做一次结构重构,把硬件相关的东西全部隔离到HAL层里。
1.2 HAL层在嵌入式分层架构中的位置
嵌入式软件里,HAL(Hardware Abstraction Layer,硬件抽象层)处在底层驱动和应用逻辑之间。它的核心作用是把“这个传感器在这个芯片上是如何操作的”和“系统业务需要什么样的能力”这两件事拆开。
从NEWLAB这个实验的角度理解,HAL层左边是平台相关的底层驱动,比如某个单片机上的I2C读写函数、GPIO控制函数;HAL层右边是对上提供的、固定的API接口,比如“初始化光照度传感器”“读取一次光照度数值”“设置量程”。上层应用只调用这些固定的API接口,至于背后是I2C还是SPI,是在开发板A上跑还是在开发板B上跑,上层完全不关心。
我画过一张简单的分层图(虽然是文字版):应用层 -> 传感器服务层 -> HAL层 -> 平台驱动层。HAL层在这一栈里是最关键的中间层,它把平台差异消化在自己内部,不管底层驱动怎么换,上层的业务代码都不需要改动。
用生活里的例子类比的话,HAL层就像插座标准。电器(应用)只认插头形状和电压范围,不管发电厂是火电还是水电。传感器就是那个电器,插座标准就是HAL定义的接口,而不同的单片机平台就像不同的供电网络。只要HAL层把这个标准立住了,去哪都能插。
1.3 NEWLAB平台与本次实验的边界约定
NEWLAB是我在实验中使用的嵌入式实验平台,准确说是集成了主控、传感器接口、调试工具的一整套实验系统。为了避免平台代码和实验代码互相干扰,我在这次实验里定了三条边界约定:
- HAL层代码本身不包含任何平台具体的寄存器操作,所有的底层访问都通过函数指针或者宏映射的方式调用平台驱动接口。
- HAL层涉及的数据类型全部采用统一宽度定义(比如固定使用16位无符号整型表示光照度数值),避免不同编译器环境下int长度不同导致的移植问题。
- 所有与传感器硬件强相关的参数(I2C从机地址、寄存器地址、换算系数)统一在HAL头文件中集中定义,不允许散落在应用代码里。
这三条约定听上去很简单,但实际编码过程中需要一直提醒自己,尤其是第一条,写着写着就容易手滑把底层的某个寄存器操作写在HAL实现里,等于又把抽象层突破了。我自己就踩过这个坑,后面会详细说。
2. 光照度传感器的接口梳理与HAL设计决策
2.1 传感器选型与硬件接线
这次实验用的光照度传感器模块是BH1750,一款数字环境光强度传感器,I2C接口,内置16位AD转换器,可以直接输出0到65535之间的数字值。之所以在NEWLAB上选它,是因为它的通信协议相对简单,命令字不算多,同时又具备真实的寄存器配置细节,非常适合用来实践HAL层抽象。
硬件接线方面,BH1750模块只有四根线:VCC、GND、SCL、SDA。有些模块还引出了一个ADDR引脚。ADDR引脚的电平状态会决定I2C设备的从机地址:ADDR接低电平时,地址是0x23;ADDR接高电平时,地址是0x5C。实验里我把ADDR引脚直接接地,所以地址用0x23。
I2C总线需要注意上拉电阻。多数模块板上已经自带上拉电阻,直接连主控的I2C接口就能用。但如果你用的是自己搭的传感器电路,必须在SCL和SDA上各接一个2.2k到10k欧姆的上拉电阻,否则总线波形边缘太缓,通信会随机出错。这个我在实验里验证过,后面问题排查部分会再提。
2.2 数据手册阅读要点:寄存器、时序和量程
做HAL层之前,必须把传感器手册的四个关键点吃透:
第一个是设备地址和写地址/读地址的换算。BH1750的7位地址是0x23,左移一位得到写地址0x46,最低位置1得到读地址0x47。很多初学者容易在这里栽跟头,以为I2C地址直接就写0x23发送,结果通信失败。实际上I2C总线上传输的是8位地址字节,7位地址左移后补上读/写位。
第二个是命令字。BH1750的命令字都是单字节。比如0x01是上电命令,0x10是连续H分辨率模式(1 lx分辨率),0x20是H分辨率模式单次测量,0x23是0.5 lx分辨率单次测量,0x27是4 lx分辨率单次测量。HAL层要把这些命令字封装成有语义的枚举或宏,而不是让上层代码去记一串十六进制数。
第三个是测量时间参数。这是后面换算公式的关键。BH1750内部测量时间可以通过寄存器调整,默认测量时间寄存器值是69,此时H分辨率模式下寄存器值除以1.2就是实际照度(单位lx)。如果把测量时间寄存器设为145,则寄存器值直接就是照度值。这个换算细节如果不在HAL层处理好,上层用起来会非常别扭。
第四个是转换时间。不同模式下的转换时间不一样。H分辨率模式单次测量典型转换时间约120ms,L分辨率模式约24ms。转换结束之前就读取数据,拿到的是上一次的结果,不是当前光强。HAL层在时序上要保证这个延时。
2.3 HAL接口设计原则:结构体句柄 + 错误码
在设计HAL接口的时候,我定下了一个基本原则:句柄化。每个传感器实例用一个结构体描述,结构体里保存设备地址、函数指针、缓存数据等运行时信息。上层操作传感器时,传进去的是一个句柄指针,函数内部通过指针找到对应的底层平台接口。
这个思路类似操作系统的文件描述符概念。应用程序拿到一个文件描述符之后,读写操作都只需要传这个整型句柄,底层具体是磁盘还是串口不用关心。传感器HAL里的结构体句柄也是这个道理,它把“哪一路I2C”“地址是什么”“当前处于什么模式”这些状态全部收拢在一起。
配合句柄的还有一套统一的错误码规则,返回值的含义必须一致。我用的简单约定是:返回0代表成功,负数代表失败,其中-1是I2C通信错误,-2是参数错误,-3是设备无响应。这个规则不复杂,但在上层排查问题时非常有效,看到返回值就能快速定位到哪一类问题。
3. HAL层代码落地:结构体、句柄与平台适配层
3.1 平台无关的数据结构定义
HAL层头文件里,我首先定义了一个不依赖任何平台的数据结构。这个头文件可以被单片机工程引用,也可以被上位机模拟程序引用,因为它不包含任何硬件寄存器地址。
下面是头文件的核心片段:
#ifndef LIGHT_SENSOR_HAL_H #define LIGHT_SENSOR_HAL_H #include <stdint.h> #define LIGHT_SENSOR_OK 0 #define LIGHT_SENSOR_ERR_I2C (-1) #define LIGHT_SENSOR_ERR_PARAM (-2) #define LIGHT_SENSOR_ERR_DEVICE (-3) typedef enum { LIGHT_SENSOR_MODE_LOW = 0, LIGHT_SENSOR_MODE_HIGH, LIGHT_SENSOR_MODE_HIGH_2 } light_sensor_mode_t; typedef struct { uint8_t i2c_addr; void (*i2c_write)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len); void (*i2c_read)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len); void (*delay_ms)(uint32_t ms); light_sensor_mode_t mode; uint16_t register_value; } light_sensor_t; int light_sensor_init(light_sensor_t *dev); int light_sensor_read_lux(light_sensor_t *dev, float *lux); int light_sensor_set_mode(light_sensor_t *dev, light_sensor_mode_t mode); #endif这个头文件里定义了三套核心内容:错误码宏、传感器句柄结构体、对外接口函数声明。
结构体中的函数指针是HAL层的关键设计。平台差异不像以往那样用一堆的#ifdef去区分,而是通过函数指针在初始化时注入。这样整个HAL实现就是一套源代码,在不同平台上只需要初始化的时候传入不同的函数指针,代码本身完全不需要改。
3.2 平台相关接口的钩子化设计
所谓钩子化,就是定义一些函数指针类型的“插槽”,平台驱动层把真正的实现函数填进去。底层的I2C读写函数在哪个平台都是一样的签名:设备地址、寄存器地址、数据缓冲区、长度。我直接把这些签名定义成函数指针类型,然后在结构体中引用。
实际初始化的时候,上层会做类似这样的赋值操作:
dev.i2c_write = platform_i2c_write; dev.i2c_read = platform_i2c_read; dev.delay_ms = platform_delay_ms;这样做的优势非常明显,我在实验里验证了在两个不同平台上编译同一套HAL代码,替换的只有平台适配文件里的三个函数。
有人可能会问,用条件编译宏不是更直接吗?#ifdef PLATFORM_A包一段,#ifdef PLATFORM_B包一段。这样确实可以工作,但代码会变得很难读,尤其是平台数量多了以后,宏嵌套让人头皮发麻。函数指针方式更加干净,每个平台提供一份适配实现,HAL核心代码本身无需任何条件编译。
3.3 初始化与去初始化接口实现
初始化接口主要完成三件事:参数校验、填充平台钩子函数(在结构体已经赋值之后检查)、向传感器发送上电命令。
我这里贴一段初始化实现,展示的是核心逻辑:
int light_sensor_init(light_sensor_t *dev) { uint8_t cmd; if (dev == NULL) { return LIGHT_SENSOR_ERR_PARAM; } if ((dev->i2c_write == NULL) || (dev->i2c_read == NULL) || (dev->delay_ms == NULL)) { return LIGHT_SENSOR_ERR_PARAM; } if (dev->mode == LIGHT_SENSOR_MODE_LOW) { cmd = 0x23; /* 0.5 lx 分辨率的单次测量模式 */ } else { cmd = 0x20; /* 1 lx 分辨率的单次测量模式 */ } dev->i2c_write(dev->i2c_addr, cmd, NULL, 0); dev->delay_ms(10); return LIGHT_SENSOR_OK; }这里有个细节:BH1750的命令字没有寄存器地址这一步,I2C发送时直接写命令字节即可。我在HAL接口里预留的reg参数在传感器支持寄存器操作时使用,对于这种直接命令方式的传感器,reg参数可以置空。
初始化之后传感器进入测量等待状态。下一次读取光照度时才真正触发一次测量。这样做的目的是降低传感器功耗,因为实验系统可能使用电池供电。如果在工业场景里时刻都连续测量,功耗就不那么友好。
去初始化接口就更简单,主要是给传感器发送断电命令,让设备进入低功耗状态。这在长时间待机的实验环节里能省不少电。
4. 核心操作:读取光照度的完整流程实现
4.1 单次测量模式的定义与参数计算
BH1750有两种大测量类型:连续测量和单次测量。连续测量模式下,传感器会不断自动进行转换,更新数值寄存器;单次测量模式下,每次收到测量命令后只做一次转换,转换完成后进入待机状态。
我在HAL层默认使用单次测量模式,这么做有两个原因。第一,功耗低;第二,模式状态更可控,更适合综合实验里逐步验证。H分辨率单次测量模式对应的命令字是0x20,典型转换时间为120ms。
换算公式是这套代码里需要特别注意的地方。前面提到,默认测量时间寄存器值是69,此时H分辨率模式下:
实际照度(lx) = 读取到的16位寄存器值 / 1.2
这个1.2就是光传感器在典型条件下的转换系数。如果设置测量时间寄存器为145,则系数变为1,寄存器数值直接表示照度。我保留的是默认配置,所以换算系数1.2体现在HAL函数内部。
还要注意一个边界问题:当寄存器值达到65535时,说明光强超出当前量程,传感器已经饱和。此时不应该把65535/1.2的结果当成真实照度上报,而应通知上层当前处于过量程状态。我在HAL里增加了一个简单的溢出判断,超过60000时就返回一个特殊提示。
4.2 读取流程:写命令 -> 延时 -> 读数据 -> 换算
读取光照度的完整流程分四步。第一步,HAL层给传感器发送测量命令;第二步,根据当前模式延时等待转换完成;第三步,从传感器读取2字节的测量结果,注意顺序是高字节在前;第四步,把两个字节拼接成16位整数,再经过系数换算得到浮点型照度值。
下面是实现代码:
int light_sensor_read_lux(light_sensor_t *dev, float *lux) { uint8_t cmd; uint8_t raw_data[2]; uint16_t raw_value; uint8_t addr; float coeff; if ((dev == NULL) || (lux == NULL)) { return LIGHT_SENSOR_ERR_PARAM; } if (dev->mode == LIGHT_SENSOR_MODE_LOW) { cmd = 0x23; coeff = 0.5f; } else { cmd = 0x20; coeff = 1.0f; } dev->i2c_write(dev->i2c_addr, cmd, NULL, 0); if (dev->mode == LIGHT_SENSOR_MODE_LOW) { dev->delay_ms(24); } else { dev->delay_ms(120); } addr = (dev->i2c_addr << 1) | 0x01; dev->i2c_read(addr, 0, raw_data, 2); raw_value = ((uint16_t)raw_data[0] << 8) | raw_data[1]; if (raw_value >= 60000) { *lux = -1.0f; return LIGHT_SENSOR_ERR_DEVICE; } *lux = (float)raw_value / 1.2f; return LIGHT_SENSOR_OK; }读取2字节数据时,I2C的读操作只需要从设备地址开始连续读2字节即可。因为BH1750内部有一个数据寄存器,写入读命令后传感器会把测量结果从高字节到低字节依次输出,不需要指定内部寄存器地址。
4.3 量程切换与测量时间精度分析
HAL层还需要支持量程切换,也就是动态改变测量模式。我设计了一个light_sensor_set_mode接口,内部做模式切换:
int light_sensor_set_mode(light_sensor_t *dev, light_sensor_mode_t mode) { uint8_t cmd; if (dev == NULL) { return LIGHT_SENSOR_ERR_PARAM; } if (mode == LIGHT_SENSOR_MODE_LOW) { cmd = 0x23; } else if (mode == LIGHT_SENSOR_MODE_HIGH) { cmd = 0x20; } else if (mode == LIGHT_SENSOR_MODE_HIGH_2) { cmd = 0x10; /* 连续H分辨率模式 */ } else { return LIGHT_SENSOR_ERR_PARAM; } dev->i2c_write(dev->i2c_addr, cmd, NULL, 0); dev->i2c_addr = dev->i2c_addr; dev->mode = mode; dev->delay_ms(20); return LIGHT_SENSOR_OK; }量程切换背后的物理含义是不同分辨率对应不同灵敏度。照度很低时用低分辨率模式会导致数据跳变明显,最好切到0.5 lx分辨率;照度很高时用0.5 lx分辨率会让寄存器值很快到达65535,需要切换到4 lx分辨率模式(命令字0x27)来拓宽量程。
这里我想强调一个实际测量中的问题:测量时间并不是越短越好。H分辨率模式虽然精度高,但是转换时间长,如果应用场景需要快速连续采样(比如转动的扫描机构带动传感器经过一个窄光缝),H模式120ms的转换时间会导致漏检。这种情况下应该用L分辨率模式或者0.5 lx模式来换取更短的转换时间,同时接受分辨率的损失。
5. 驱动层与应用层的衔接:让HAL真正跑起来
5.1 模拟驱动层封装(platform实现)
定义好HAL接口后,还需要一套真正的平台驱动实现。在NEWLAB平台上,我用的是模拟方式的驱动层封装,也就是把HAL结构体里那三个函数指针的具体实现写在platform文件中。
举例来说,I2C写函数的实现示例:
static void platform_i2c_write(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len) { /* 调用NEWLAB平台库函数发送I2C数据 */ platform_i2c_start(); platform_i2c_write_byte(addr & 0xFE); if (data != NULL) { for (uint16_t i = 0; i < len; i++) { platform_i2c_write_byte(data[i]); } } platform_i2c_stop(); } static void platform_i2c_read(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len) { platform_i2c_start(); platform_i2c_write_byte(addr | 0x01); for (uint16_t i = 0; i < len; i++) { data[i] = platform_i2c_read_byte(); } platform_i2c_stop(); } static void platform_delay_ms(uint32_t ms) { platform_sleep(ms); }注意这段代码里有个细节:写操作时地址字节的末位是0(addr & 0xFE),读操作时地址字节的末位是1(addr | 0x01)。我在HAL层接口里传入的是7位I2C地址,所以驱动实现里必须把这个地址左移后加上读写位。这个细节如果漏了,总线上的从机无法识别。
5.2 应用层数据采集与滤波
HAL层接口稳定之后,应用层就非常简单了。我写了一个简单的数据循环,每隔200ms读取一次照度值并做滑动平均滤波:
float sample_buffer[8]; uint8_t sample_index = 0; void app_light_sensor_task(void) { float lux; float sum = 0.0f; if (light_sensor_read_lux(&light_dev, &lux) == LIGHT_SENSOR_OK) { sample_buffer[sample_index] = lux; sample_index = (sample_index + 1) % 8; for (int i = 0; i < 8; i++) { sum += sample_buffer[i]; } printf("current lux: %.2f, avg lux: %.2f\n", lux, sum / 8.0f); } else { printf("sensor read error\n"); } }滑动平均的意义在于抑制传感器输出中的噪声抖动,尤其是室内灯光条件下,50Hz/100Hz频闪会被传感器捕获一部分,单次读数会明显波动。加了8次滑动平均后,数据曲线平滑了很多,肉眼观察LED亮度变化时反馈也更稳定。
应用层代码完全不涉及I2C或者命令字,这正体现了HAL层存在的价值。
5.3 多平台移植要点:编译开关与弱符号
如果你准备把这份HAL代码移植到不同单片机平台,有几个细节可以参考。
第一,建议把platform文件单独归一个目录,不同平台之间通过编译配置来选中。比如在Makefile里定义PLATFORM = stm32或者PLATFORM = newlab,然后选择对应的platform_stm32.c还是platform_newlab.c。HAL核心代码文件不参与平台差异,这样最省事。
第二,可以给platform接口单独做一个头文件,里面用#if defined(PLATFORM_NEWLAB)之类的方式声明函数。但要注意不要让HAL核心头文件反向依赖platform头文件,否则就出现了依赖反转。我在编码时检查过:HAL头文件只包含自身需要的数据类型和宏定义,platform的函数指针签名只要和HAL结构体里的一致即可。
第三,弱符号(__attribute__((weak)))在一个工程里定义一套默认的platform弱实现,然后如果你在特定平台文件里实现了同名强符号,链接时会自动覆盖弱符号。这个技巧适合快速移植,但维护时间长了你可能会发现有些平台忘记实现某个弱函数却编译通过了,反而不利于排查。我更推荐使用显式的函数指针注入方式,强迫上层明确给每个钩子赋值。
6. 编译、调试与边界问题排查
6.1 编译配置与Makefile注意事项
HAL层源码文件不多,我整理出来的文件清单有:hal_light_sensor.c、platform_newlab.c、app_light_sensor.c,加上对应的头文件。Makefile里需要添加头文件搜索路径,并链接I2C平台库。
一个值得注意的编译陷阱是:头文件路径里如果有重名的i2c.h,很容易把自家HAL头文件和平台库的头文件搞混。我遇到过的情况是,某次把平台库的i2c.h错误包含到了HAL文件里,结果编译时报了一堆类型未定义的错误,因为平台库的I2C结构体里依赖硬件寄存器定义。后来我统一把HAL文件命名成light_sensor_hal.h这类带前缀的名字,从源头避免重名。
还有一点,编译时建议开启-Wall -Wextra选项,让所有警告都显示出来。HAL层的函数指针类型如果和实际函数签名不一致,编译器通常会提示类型不匹配,别忽略这些警告,直接修复。
6.2 常见问题速查表
我在实验过程中整理了一张问题速查表,列到下面,方便后来人对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读取总是返回0xFFFF | 传感器未上电或I2C通信异常 | 用万用表量VCC电压,再查SDA/SCL波形 |
| 读取数值一直不变 | 测量命令未写入,传感器未触发转换 | 检查初始化时是否发送了上电命令 |
| 光照度跳变很大 | 供电电源纹波大或I2C上拉不足 | 加一个100nF去耦电容,检查上拉电阻 |
| 地址冲突导致通信失败 | 同一条I2C总线上有相同地址设备 | 将ADDR引脚切换到另一电平以改地址 |
| 数值偏小 | 传感器被遮挡或量程模式不合适 | 检查传感器前窗,切换高分辨率模式 |
| 数值持续为0 | 传感器前窗被遮住或测量时间不够 | 确认没有遮光物体,确认延时时间足够 |
6.3 踩坑实录:上电无数据、噪声抖动、地址冲突
上电无数据这个问题是我在实验中最先遇到的。刚开始我把初始化和第一次读取写得很紧凑,整个流程不到2毫秒就发起了读操作。但传感器上电后需要时间稳定,尤其模块上如果有电源管理部分,稳定时间还要更长。后来我把初始化流程改成上电后延时100ms,再发送测量命令,问题就消失了。
噪声抖动的问题在上文提过,这里再补充一个处理思路。如果你要追求更干净的曲线,可以在HAL层增加一个简单的历史比较逻辑:连续几次读取值变化超过20%才刷新输出,否则沿用上一次结果。这个逻辑本质上是一个一阶滞后判断,实现成本很低,在缓慢变化的环境光场景里效果很好。
地址冲突的坑来自于NEWLAB板上I2C总线还挂了别的传感器模块。某天调试时数据一直读不对,排查到后面发现总线上有一个同地址的模块在响应,读回来的数据其实是另一个设备的。解决方式是调整BH1750模块上的ADDR引脚电平,把它从一个地址改到另一个地址,冲突就消失了。
7. 一点自己的体会
这次实验(二)做HAL层,我最大的收获不是把代码变整齐了,而是理解了“接口就是契约”这句话。HAL层核心工作不是写多少个函数,而是把这些函数的使用场景、入参、出参、错误码定义清楚,让上层开发者和底层平台开发者都能在这个契约下工作。
写代码的时候我反复提醒自己不要偷懒,比如在HAL实现里直接嵌入延时循环、直接写死某平台的I2C超时时间。这些东西一旦写进去,HAL层就不再“纯”了。虽然短期内能跑,但后续每一次平台切换都在还债。
如果只是把HAL写出来就觉得任务完成,那还差一步。真正要让HAL有价值,必须配套一套完整的测试用例。我在实验过程中把读取函数、模式切换、错误码分支都做了循环调用测试,模拟异常I2C总线状态,确认HAL不会死锁或者返回奇怪数值。这个习惯在之后的实验(三)里帮了大忙,因为应用层完全不用关心传感器实现细节,所有的注意力都放在业务逻辑和算法算法优化上。
下次我准备把HAL层继续往上层延伸,看看在传感器驱动之上再封装一层统一数据模型之后,能不能把光照度传感器和其他类型的传感器也纳入同一个数据上报框架,那样整个NEWLAB的实验体系就更统一了。在这之前,这份HAL代码还有很多值得打磨的小细节,比如增加更完善的超时机制、DMA读取等,这些就留给后续实验继续折腾了。