ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入ATSHA204A:手写Linux驱动实现I2C加密芯片认证

深入ATSHA204A:手写Linux驱动实现I2C加密芯片认证

简介:ATSHA204A是Microchip推出的安全认证加密芯片,支持SHA-256与AES-128算法,在Linux/Android设备中负责身份验证与数据保护。这套驱动源码包面向嵌入式驱动开发与系统安全集成人员,聚焦内核与芯片的高效通信,解决初始化、I2C读写、命令交互等关键问题。资源共12个文件,含6个头文件、4个C源文件、1个Makefile和1个Kconfig,分别承担接口定义、核心驱动逻辑、SHA辅助函数、编译规则与内核配置选项,结构清晰便于按模块移植。压缩包仅32KB,轻量精炼。已有485人学习下载,具有较高实践参考价值。研读代码不仅能够掌握字符设备注册、ioctl接口设计、设备树匹配等Linux驱动核心技能,还能理解ATSHA204A在安全启动、密钥管理等实际场景中的典型用法,为自身项目提供可复用模板与排查思路。

1. 为什么我选择自己写 ATSHA204A 的 Linux 驱动而不是直接调内核现成的

某公司做了一批工业采集设备,固件被同行抄走,板卡也被复刻。后面在板上加了 ATSHA204A 加密芯片做绑定校验,Linux 系统启动时读芯片序列号、算 HMAC,验证不过就拒绝进业务系统。这个芯片本身是 I2C 接口的硬件安全模块,内核源码树里其实已经有 atsha204a 的驱动,但真正上手会发现它对应用层非常不友好——接口绑定在内核 crypto 框架里,想用它做 license 绑定、防抄板的软件/插件方案,还是得自己把 I2C 协议和命令封装搞清楚。

这里有个反直觉的点:驱动源码里最值钱的不是那几百行内核模块,而是命令帧组装、CRC 计算、唤醒时序这些看不见的细节。把这些拆开讲透,比直接拿现成驱动 insmod 有用得多。适合正在做嵌入式安全启动、设备认证、固件完整性校验的开发者,也适合想理解硬件安全芯片怎么和 Linux 配合的人。下文按“芯片协议 → 驱动架构 → 跑通验证 → 踩坑 → 改造”这条线展开。

2. 硬件与协议层:一颗 I2C 加密芯片的通信细节

2.1 芯片能干什么:SHA-256、HMAC 与一次性密码本

ATSHA204A 不是一个通用 MCU,而是一颗专门做对称认证的加密协处理器。它内部集成 SHA-256 引擎,支持 Nonce、HMAC、MAC、Random、DeriveKey、Lock 这些命令,密钥存放在芯片内部的内存区里,通过 I2C 总线无法直接读出。正因为密钥物理上不可读,它才能承担设备认证和防伪场景里的“信任根”角色。

实际项目中我常把它用在三件事上:一是开机时算 HMAC 校验固件合法性;二是和上位机做挑战应答,上位机生成随机数发给设备,设备用芯片里的密钥算 MAC 回传,以此证明自己是真设备;三是配合硬件随机数发生器做密钥种子下发。和纯软件方案比,它最大的优势是密钥不存在 Flash 里,抄板的人即使把整个 Flash dump 出来也拿不到密钥。

芯片通过 I2C 接口连接主机,默认 7 位地址是 0x64,供电范围 2.0V 到 5.5V,I2C 速率支持到 1MHz。需要注意的是,芯片平时处于睡眠状态,每次通信前要先发送唤醒脉冲,否则命令直接丢进黑洞。

2.2 内存模型与配置 Zone:哪些数据必须烧死,哪些能现场写

这颗芯片内部不是一块简单 EEPROM,而是分了好几个区:配置区(Config Zone)88 字节、数据区(Data Zone)512 字节、OTP 区 64 字节。数据区进一步划分成 16 个 slot,每个默认 32 字节,密钥就存在 slot 里。每个 slot 有独立的读写权限配置,比如可以配成“可写、不可读”“不可写、不可读”“加密读写”等。

理解 zone 概念是驱动开发的前提。Config Zone 里存着 I2C 地址、slot 配置、锁定状态这些元数据;Data Zone 里的 slot 是业务数据的主战场。出厂时芯片所有 zone 都是可写的,一旦执行 Lock 命令把配置区或数据区锁死,里面的内容就永久固定,再写会直接返回错误。所以生产流程上,必须先规划好哪几个 slot 放什么密钥,再统一锁定,锁定之后就是一次性烧录,没有后悔药可吃。

内存区域大小主要用途锁定方式
Config Zone88 字节I2C 地址、slot 权限、锁状态LockConfig 命令
Data Zone512 字节,16 个 slot密钥、随机数种子、业务数据LockData 命令
OTP Zone64 字节一次性可编程数据LockData 命令

2.3 命令帧格式与 CRC-16 计算

驱动和芯片通信的最小单位是命令帧。I2C 上传输的报文不是裸的命令字节,而是带长度、CRC 的帧。发命令时第一字节固定是 0x03(I/O 标志),第二字节是包长度,后面跟 opcode、参数和数据,最后两个字节是 CRC-16 校验值。

常用命令的 opcode 我用表格整理一下,方便对照数据手册查参数:

命令名opcodeParam1 用途Param2 用途
Nonce0x16随机数模式NumIn 字节数
SHA0x47算法模式上下文字节数
HMAC0x11slot 号0
MAC0x08slot 号0
Random0x1B00
Read0x02zone/slot偏移量
Write0x03zone/slot偏移量
Lock0x17zone 类型0
Info0x30参数模式0

CRC 是这里最容易翻车的地方。ATSHA204A 的 CRC-16 是查表法实现,多项式 0x8005,计算范围是从“包长度”字节开始,一直到数据区最后一个字节,不包括开头的 0x03 标志。很多初次写驱动的人把 0x03 也算进去,结果芯片一直回 0xEE 错误码。初始化值通常用 0x0000,字节需要反转处理,我一般直接抄数据手册附录的查表函数,不做任何修改验证直接用。

3. 驱动架构拆解:内核态模块 + 用户态 ioctl 的两层设计

这里直接按自研驱动的方案拆,因为内核自带的 atsha204a 驱动更接近一个 crypto 加速器,接口和业务认证场景不对齐。自己写的驱动采用两层结构:底层内核模块负责 I2C 通信和命令收发,暴露一个字符设备 /dev/atsha204a;用户态库负责拼装业务参数,通过 ioctl 下发命令。为什么这样设计?内核态做 I2C 访问可以用 i2c-core 的完整能力,而业务逻辑放在用户态,调试起来不用动不动就重启机器。

3.1 设备注册:用 miscdevice 而不是 register_chrdev

字符设备用 miscdevice 注册是嵌入式驱动里的常见做法。它自动分配主设备号,不用手动 mknod,模块加载后 /dev/atsha204a 就会出现在 /dev 目录下。代码不长但五脏俱全:

#include <linux/miscdevice.h> #include <linux/fs.h> static const struct file_operations atsha_fops = { .owner = THIS_MODULE, .open = atsha_open, .release = atsha_release, .unlocked_ioctl = atsha_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = atsha_ioctl, #endif }; static struct miscdevice atsha_dev = { .minor = MISC_DYNAMIC_MINOR, .name = "atsha204a", .fops = &atsha_fops, }; static int __init atsha_init(void) { return misc_register(&atsha_dev); } module_init(atsha_init);

这段代码把驱动的行为收敛到 fops 上,挂载 ioctl 处理函数。open/release 里只做最简单的引用计数打开,不申请内存、不初始化硬件,真正的初始化放在第一次 ioctl 时做。选 miscdevice 而不是 register_chrdev 的好处是:不用自己管设备号分配,也不会和内核里其它驱动抢号,对一颗独立外设芯片来说正好够用。

compat_ioctl 这里必须和 unlocked_ioctl 一起写,否则 32 位用户态程序在 64 位内核上跑 ioctl 会直接报 ENOTTY。这个坑我在早期版本里踩过,表现是交叉编译的工具链是 32 位,内核是 64 位,测试程序一开就挂。

3.2 命令组装与发送:把协议细节放进一条 ioctl

ioctl 的入参是一个统一定义的结构体,包含 opcode、参数和缓冲区。内核态拿到参数后拼命令帧,通过 i2c_master_send 发出去。这里最关键的是把帧长度算对:

static int atsha_send_cmd(struct i2c_client *client, uint8_t opcode, uint8_t param1, uint16_t param2, uint8_t *data, size_t data_len) { uint8_t cmd[128]; uint8_t crc[2]; size_t idx = 0; if (data_len + 7 > sizeof(cmd)) return -EINVAL; cmd[idx++] = 0x03; /* I/O 标志 */ cmd[idx++] = data_len + 7; /* 包长度 = opcode + p1 + p2 + crc + count 共 7 字节 + data */ cmd[idx++] = opcode; cmd[idx++] = param1; cmd[idx++] = (param2 >> 8) & 0xFF; cmd[idx++] = param2 & 0xFF; if (data_len) { memcpy(&cmd[idx], data, data_len); idx += data_len; } atsha_crc16(&cmd[1], idx - 1, crc); cmd[idx++] = crc[0]; cmd[idx++] = crc[1]; return i2c_master_send(client, cmd, idx); }

逻辑说明:第一字节 0x03 是协议固定的 I/O 标志,第二字节是整个包从 count 字节开始到 CRC 结束的总长度,所以是 data_len + 7。CRC 的输入从 cmd[1] 开始,长度为 idx - 1,把开头的 0x03 排除在计算范围外。i2c_master_send 返回的是实际发送的字节数,调用方必须校验返回值等于 idx,否则说明帧没发完整。

接收侧对应参数说明:param2 按大端序拆成两个字节,因为芯片内部是 16 位寄存器,比如 Nonce 命令里 param2 的高 14 位表示外部随机数的字节数,低 2 位是模式位。数据缓冲区的长度受限于芯片固件,最大不超过 80 字节左右,超过会返回错误码,所以缓冲定义 128 字节足够覆盖所有命令场景。

3.3 响应读取与忙等待:0x77 状态字的处理

芯片收到命令之后需要几十毫秒计算,期间 I2C 总线上如果直接读,会读到 0x77 状态字节表示“忙”。驱动需要轮询等待,状态从 0x77 变成 0x00 后才能读出完整响应帧:

static int atsha_wait_response(struct i2c_client *client, uint8_t *resp, size_t max_len) { uint8_t status; int ret; int try = 10; do { ret = i2c_master_recv(client, &status, 1); if (ret == 1 && status != 0x77) break; msleep(10); } while (--try > 0); if (try == 0) return -ETIMEDOUT; if (status != 0x00) return -EIO; /* 0xEE 表示 CRC 错误,0x0F 表示命令不允许 */ ret = i2c_master_recv(client, &resp[1], resp[0] - 1); return atsha_crc16_verify(resp, resp[0] - 2) ? 0 : -EBADMSG; }

逻辑说明:每次读一个字节判断状态,status 为 0x00 时说明响应已经就绪,此时第一字节是响应包长度 count。再调用 i2c_master_recv 读取剩余 count - 1 个字节,最后对整个响应帧做 CRC 校验。这里有个细节:i2c_master_recv 在 I2C 协议上发的是读请求,芯片忙时会自动返回 0x77 而不是拉低总线,所以轮询不会阻塞总线。

参数说明:try 设 10 次超时上限,每次 10ms。SHA 计算在芯片内大约耗时 10ms 级别,Nonce 更快,10 次轮询通常第一次或第二次就成功。如果频繁超时,要检查是不是唤醒时序没做对,而不是拉大 try 次数硬扛。

3.4 抽象接口:从 cmd 层到 SHA / MAC 语义层

直接暴露 opcode 给用户态不够友好,业务方不应该关心 0x47 是 SHA、0x11 是 HMAC。我在 ioctl 层做了一层语义映射,用户态传一个结构体指定操作类型,内核负责翻译成对应的 opcode 并填充参数。

ioctl 命令入参结构体内核行为出参
AT_IOC_SHA256req: data + len发送 SHA 命令req.data 回填 32 字节摘要
AT_IOC_HMACreq: slot + data发送 HMAC 命令req.data 回填 32 字节 MAC
AT_IOC_RANDOMreq发送 Random 命令req.data 回填 32 字节随机数
AT_IOC_READ_SLOTreq: slot + offset发送 Read 命令req.data 回填 slot 内容
AT_IOC_WRITE_SLOTreq: slot + data发送 Write 命令无
AT_IOC_LOCKreq: zone 类型发送 Lock 命令无

这个设计把协议细节留在内核态,用户态拿到的是一组“动词”。后续如果要换芯片型号,比如从 ATSHA204A 换到 ATECC608A,只需要改内核层实现,用户态接口保持不动,二次开发和升级都省事。

4. 编译、加载与验证:从 Makefile 到摘要对比跑通全流程

4.1 交叉编译与 Makefile

驱动要跟着目标板的内核版本编译,不能直接拿发行版内核编出来的 .ko 到处用。Makefile 里指定内核源码树路径和交叉编译工具链:

obj-m := atsha204a.o KDIR := /opt/linux-6.1.28 CROSS_COMPILE := aarch64-none-linux-gnu- all: $(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

逻辑说明:KDIR 指向目标板同版本的内核源码树,编译系统会读取源码树里的 Module.symvers 和头文件来生成模块。如果你的内核已经单独编译过,最好先确认源码树根目录下有 .config 文件,否则 make modules 会提示找不到配置。交叉编译工具链前缀要和板子的 libc 匹配,这里用的是 aarch64 路线。

编译之前还有一步容易漏:内核源码树需要先执行 make modules_prepare,把生成的头文件准备好,否则外置模块编译会报缺少 generated/autoconf.h。这一步做一次就够了,后续改驱动代码只需要重新 make。

4.2 设备树节点与模块加载

编译出的 atsha204a.ko 拷贝到板子后,先用 insmod 加载,确认注册成功:

insmod atsha204a.ko dmesg | tail -n 10 ls -l /dev/atsha204a

如果使用设备树描述硬件,需要在 I2C 控制器节点下面加子节点:

&i2c1 { status = "okay"; clock-frequency = <100000>; atsha204a@64 { compatible = "atmel,atsha204a"; reg = <0x64>; }; };

驱动里的 i2c_device_id 表需要声明 compatible 字符串:

static const struct i2c_device_id atsha_id[] = { { "atsha204a", 0 }, {} }; MODULE_DEVICE_TABLE(i2c, atsha_id);

逻辑说明:设备树里 reg = <0x64> 就是芯片的 I2C 地址。如果板子上 I2C 总线上同时挂了别的设备,要先用 i2cdetect 扫一遍确认地址没有被占用。clock-frequency 这里我习惯先设 100kHz 验证,跑通了再往上拉,因为后面讲过有兼容芯片在高频下会翻车。

insmod 之后 /dev/atsha204a 由 miscdevice 自动创建。如果没有出现,多半是设备树节点没匹配上,用 dmesg 看 i2c subsystem 的输出,或者检查 /sys/bus/i2c/devices/ 下有没有对应目录。

4.3 用户态测试程序与 openssl 对拍

驱动跑通后,第一个验证动作是算 SHA-256 摘要,和 openssl 的结果对拍:

#include <stdio.h> #include <string.h> #include <fcntl.h> #include <sys/ioctl.h> #include "atsha204a.h" int main(void) { int fd = open("/dev/atsha204a", O_RDWR); struct atsha_req req; int i; if (fd < 0) { perror("open"); return 1; } memset(&req, 0, sizeof(req)); req.op = AT_SHA256; req.data_len = 11; memcpy(req.data, "hello atsha", 11); if (ioctl(fd, AT_IOC_SHA256, &req) < 0) { perror("ioctl"); return 1; } printf("sha256: "); for (i = 0; i < 32; i++) printf("%02x", req.data[i]); printf("\n"); close(fd); return 0; }

逻辑说明:测试程序把待哈希数据放进 req.data,调用 ioctl 后摘要直接回填在同一块缓冲区。硬件 SHA 和软件 SHA 结果是同一个标准算法,正常情况输出应该完全一致。如果驱动实现有错,开局就会翻车。

对拍用这条命令:

echo -n "hello atsha" | openssl dgst -sha256

两边输出一致,说明命令帧组织、CRC 和响应解析都正确,接下来再测 HMAC 和随机数就有底气了。这里有个经验:先跑 SHA 而不是先跑 HMAC,因为 SHA 命令不依赖任何 slot 配置,能隔离“驱动问题”和“密钥配置问题”。

4.4 实用工具链:配置 zone、烧写密钥、锁定

驱动验证通过只是第一步,真实产品要做密钥注入。常见做法是写一个小工具,批量执行 Write 命令把密钥写进 slot,然后执行 Lock 命令锁定。这个工具在生产烧录阶段跑,只在管理机上存在,不出现在目标设备里。

我会把工具设计成四个子命令:

atsha_tool config --read atsha_tool write --slot 2 --key-file secret.bin atsha_tool lock --zone data atsha_tool verify --slot 2 --key-file secret.bin

先读配置确认芯片处于未锁定状态,再写密钥,锁数据区,最后用 verify 从芯片内部计算 HMAC 验证密钥是否生效。写进去的密钥如果发现写错了,只要还没 Lock 就还能覆写,所以 verify 放在 Lock 之前做,锁定之后就真的不能再动了。

5. 避坑手册:ATSHA204A 驱动开发里的五类翻车现场

5.1 芯片无应答,i2c_master_send 返回 -ENXIO

现象:insmod 正常,/dev/atsha204a 也出现了,但一调用 ioctl 就报 I/O error,dmesg 里看到 i2c 传输失败。

原因:芯片默认处于睡眠状态,直接发命令帧它根本收不到。数据手册要求先产生一个唤醒脉冲:SCL 保持高电平,SDA 拉低至少 60 微秒再释放,然后等待约 1.5ms 让芯片启动。

解决:在每次发送命令之前增加唤醒流程。如果主板把 SDA/SCL 接到了 GPIO,可以用 gpiod 直接拉低 SDA 实现;如果用 I2C 控制器,可以通过 i2c_master_send 发送一个零字节包配合 usleep_range 来模拟。注意唤醒脉冲后要等一下,别立刻发命令。

5.2 CRC 校验失败,芯片返回 0xEE

现象:命令发出后,轮询读到的状态不是 0x00,而是 0xEE,这表示芯片认为收到的帧 CRC 或长度不合法,直接拒绝处理。

原因:CRC 计算范围搞错。帧的第一个字节 0x03 是 I2C 传输的包裹标志,不属于命令内容,很多参考代码把长度、opcode、数据算完 CRC 后,又把 0x03 单独甩在 CRC 外面,顺序一乱就验不过。另一种可能是 count 字段忘了加 7,导致芯片按错误长度解析帧。

解决:严格按数据手册的算法实现,CRC 输入从 count 字节开始到 data 最后一字节,不包括 0x03。写完驱动后用 5.3 节的方法先跑 SHA 命令验证,能出摘要就说明 CRC 通路对了。

5.3 内核态 msleep 在原子上下文直接 oops

现象:ioctl 里调用了 msleep 等待芯片计算,常规调用没问题,但某个业务线程在持有 spinlock 的场景下调用 ioctl,内核直接报 scheduling while atomic,然后系统卡死。

原因:msleep 会触发进程调度,spinlock 临界区不允许调度。加密等待天然要阻塞,拿自旋锁做同步是错误的设计。

解决:驱动里的互斥用 mutex 而不是 spinlock,等待只用 msleep,禁止在持锁状态下调用。如果上层业务确实需要在原子上下文访问,就把请求丢给 workqueue 异步处理,ioctl 只负责提交和等待完成。这个设计在驱动评审时也是必问项。

5.4 Slot 写错后数据区已锁定,只能换芯片

现象:生产时把密钥写进错的 slot,执行了 LockData 之后才发现问题,想重新写却返回 0x0F。

原因:Lock 命令是不可逆操作,数据区锁定后 slot 的写权限就永久关闭。写脚本时没有把“可写校验”放在 Lock 前面。

解决:生产工具把流程组织成 write -> verify -> lock 三步,verify 阶段用芯片内部计算 HMAC 和预期值比对,比对通过才允许执行 lock。我在这上面翻过车,一批板子锁完后发现密钥种子文件搞错了,整批换芯片,从那以后批量烧录前都会先用一块样板跑完整流程再上产线。

5.5 I2C 频率调太高,随机数读出全 0xFF

现象:驱动逻辑没变,只是把设备树的 clock-frequency 从 100kHz 提到 400kHz,之后 Random 命令读出的数据全是 0xFF。

原因:部分芯片或转接板在 400kHz 下的时序裕量不足,芯片没完全唤醒或者响应帧采样出错。数据手册标注了 1MHz 上限,但实际跑起来受 PCB 走线、上拉电阻、电平转换器影响很大。

解决:先在 100kHz 下把全流程验证通过,再逐步提频率。如果生产环境必须跑 400kHz,检查 I2C 总线上拉电阻是否在 2.2k 到 4.7k 之间,并且用示波器看 SDA/SCL 的上升沿,超过 300ns 就要调整。这属于玄学范畴,但频率降一档往往就好了。

6. 改造方向:把驱动做成可复用的设备认证服务

6.1 会话级认证流程设计

基础 ioctl 就绪后,下一步是把单条命令封装成会话级的认证服务。我在实际项目里用的是挑战应答模型:设备启动时从芯片取 32 字节随机数作为 nonce,把这个 nonce 发给上位机;上位机用共享密钥计算 HMAC-SHA256(nonce) 返回;设备再用芯片内部 slot 里的密钥算一次 HMAC,比对两个结果。

struct atsha_req req = {0}; req.op = AT_RANDOM; ioctl(fd, AT_IOC_RANDOM, &req); /* 先取 nonce */ memcpy(hmac_in, req.data, 32); req.op = AT_HMAC; req.slot = 2; /* 共享密钥所在的 slot */ req.data_len = 32; ioctl(fd, AT_IOC_HMAC, &req); /* 芯片内部完成 HMAC 计算 */ if (memcmp(req.data, hmac_from_host, 32) == 0) auth_pass(); else auth_fail();

这样设计的好处是密钥永远不离开芯片,即使上位机被逆向,拿到的也只是某一次会话的 HMAC 结果,无法反推密钥。把这套流程封装成一个库,上层业务只需要调用 auth_once(),不需要理解任何协议细节。

6.2 密钥管理与验证手段

密钥管理要提前规划 slot 占用表,比如 slot 0 放 HMAC 主密钥,slot 1 放固件加密密钥,slot 2 留作现场更新。如果产品需要远程换密钥,数据区不能锁定,要接受密钥可能被覆写的安全降级;如果追求最高安全性,就锁定数据区,配合云端证书体系做一次性绑定。

验证阶段不能只看认证通过,还要模拟三类攻击:错误密钥、重放旧 nonce、篡改 HMAC 结果。这三类都必须返回认证失败,且连续失败几次后触发退避策略,避免暴力穷举。性能上整个认证过程在几十毫秒量级,适合启动时做一次性校验,不适合高频调用。

从那以后,我每设计一块带安全芯片的板子,第一件事就是画 zone 配置表,把每个 slot 的用途、读写权限、锁定时机全部标清楚,再动手写驱动代码。驱动本身不难,难的是把芯片的物理特性、内存规划和业务认证流程对齐,这个顺序反过来就会在产线上吃苦头。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进