SPI 总线概述及嵌入式 Linux 从属 SPI 设备驱动程序开发(第二部分,实践)

发布于:2025-09-16 ⋅ 阅读:(42) ⋅ 点赞:(0)

大家好!我是大聪明-PLUS

这是我关于在 Linux 上开发 SPI 从设备驱动程序的文章的第二部分。
 

3. 使用spidev开发用户空间协议SPI驱动程序


如上所述,SPI 设备的用户空间 API 支持有限,只能通过基本的半双工 read() 和 write() 调用来访问从属 SPI 设备。使用 ioctl() 调用可以与从属设备进行全双工数据交换,并更改设备参数。

您可能出于多种原因想要使用此用户空间 API:

  • 在不易发生致命错误的环境中进行原型设计。用户空间中的无效指针通常不会导致整个系统崩溃。
  • 开发简单的协议,用于与需要频繁更改的以 SPI 从属模式运行的微控制器交换数据。


当然,有些驱动程序无法使用用户空间 API 实现,因为它们需要访问用户空间中无法访问的其他内核接口(例如中断处理程序或其他驱动程序堆栈子系统)。

要启用 spidev 支持,您必须:
1. 在 menuconfig 中配置内核时,激活以下项:

Device Drivers
	    SPI support
	        User mode SPI device driver support


2.在board文件中添加上一点讨论的spi_board_info结构体数组:

{	/* spidev */
	.modalias	= "spidev",
	.chip_select	= 2,
	.max_speed_hz	= 15 * 1000 * 1000,
	.mode 		= SPI_MODE_0,
	.bus_num	= 1,
},



重建并加载新内核后,系统中会出现一个名称类似于 /dev/spidevB.C 的对应设备,其中 B 是 SPI 总线号,C 是片选号。此设备无法通过 mknod 手动创建,而应该由 udev/mdev 等服务自动创建。

太好了,我们有了一个设备。剩下的就是学习如何使用它。假设我们要将字节 0x8E 发送到挂在 SPI1 上、编号为 CS 2 的设备。最简单的方法可能是这样:

echo -ne "\x8e">/dev/spidev1.2


之后,在我的测试设备上可以看到以下图片:



关于测试设备,我想说几句。或许最重要的是,它几乎没什么用,只是为了研究如何在 Linux 上使用 SPI 而制作的。它由一个移位寄存器 74HC164N 和 74HC132N 的三个 2AND-NOT 元件组成,并做了一些类似于片选的功能,这使得同步信号仅在输入 ~CS 处处于低电平(我想立即说明一下,是的,我知道 74HC595 的存在,但我在我的城市买不到)。该设备只有一个功能——在 LED 上显示写入的最后一个字节。由于我的设备并非完全“诚实”,读取时我们无法获得写入的内容(这应该是实际写入的内容),而是左移一位的值。

可以使用 ioctl() 调用配置从设备的工作参数。它们允许您更改数据传输速率、传输字的大小、传输中的字节顺序,当然还有 SPI 工作模式。
以下 ioctl() 请求允许您控制从设备的参数:

  • SPI_IOC_RD_MODE、SPI_IOC_WR_MODE — 在读取 (RD) 时,指针传输到的字节将被赋值为当前 SPI 模式的值。在写入 (WR) 时,根据传输指针指向的字节值设置设备模式。要设置模式,可以使用常量 SPI_MODE_0…SPI_MODE_3,或者通过按位“或”操作组合常量 SPI_CPHA(同步相位,如果设置则在上升沿捕获)和 SPI_CPOL(同步极性,同步信号从高电平开始)。
  • SPI_IOC_RD_LSB_FIRST 和 SPI_IOC_WR_LSB_FIRST — 传递一个指向字节的指针,该字节用于确定 SPI 字传输时的位对齐方式。零值表示最高有效位在前(MSB 优先),其他值表示使用较少使用的变体,最低有效位在前(LSB 优先)。在这两种情况下,每个字都将右对齐,因此未使用/未定义的位将位于最高有效位。RD/WR — 分别读取/写入用于确定字中位对齐方式的参数。
  • SPI_IOC_RD_BITS_PER_WORD 和 SPI_IOC_WR_BITS_PER_WORD — 通过 SPI 传输数据时,传递一个指向指定每个字位数的字节的指针。零值对应八位。RD/WR — 分别读取/写入每个字的位数。
  • SPI_IOC_RD_MAX_SPEED_HZ、SPI_IOC_WR_MAX_SPEED_HZ — 将一个指针传递给一个 u32 变量,该变量指定 SPI 的最大数据传输速率(以 Hz 为单位)。通常,控制器无法准确设置指定的速度。


通过更改频率,我发现我的测试设备可以工作在不高于约 15 MHz 的频率下,考虑到电缆长度约为 25 cm、电路板上的组件以及使用 MGTF 的触点连接,这个频率还算不错。

现在我想再次强调,并非所有控制器都支持更改位的顺序。为了了解控制器支持的功能,您需要查看位掩码 spi_master.mode_bits。掩码中位的含义可以通过spi_device结构中标志的定义来确定。我不会在这里给出 spi_device 和spi_master结构的完整描述,因为它们在本例中对于理解它们并不重要。我将在文章末尾提供一个文档链接,您可以在其中找到所有指定结构的描述。

正如我在开头提到的,spidev 允许使用相应的 ioctl() 命令进行半双工传输:

int ret;
ret = ioctl(fd, SPI_IOC_MESSAGE(num), tr);


其中 num 是 spi_ioc_transfer 类型结构数组中的传输数量;
tr 是指向 spi_ioc_transfer 类型结构数组的指针;
如果失败,则返回负值;如果成功,则返回所有传输中成功传输的字节总数。
传输结构本身具有以下形式:

struct spi_ioc_transfer {
	__u64		tx_buf;
	__u64		rx_buf;
	__u32		len;
	__u32		speed_hz;
	__u16		delay_usecs;
	__u8		bits_per_word;
	__u8		cs_change;
	__u32		pad;
};


tx_buf 和 rx_buf — 分别将指针存储在用户空间中,指向用于发送/接收数据的缓冲区。如果 tx_buf 为 NULL,则会将其置零。如果 rx_buf 为 NULL,则忽略从从设备接收的数据。len
— 接收和发送缓冲区(rx 和 tx)的长度(以字节为单位)。speed_hz
— 覆盖本次传输的数据传输速率。bits_per_word
— 覆盖本次传输的每字位数。delay_usecs
— 在传输完最后一位数据后,停用设备(调用 cs_deactivate 之前)之前的延迟时间(以微秒为单位)。spi_ioc_transfer

结构的几乎所有字段都与 spi_transfer 结构的字段相对应。在 spidev 驱动程序的深处,使用 copy_from_user()/copy_to_user() 函数将数据缓冲区预复制到内核空间/从内核空间复制数据。
正如我上面所说,并非所有控制器都支持单独更改每次传输的速度和字长,因此,如果想要获得可移植的代码,最好在这些位置填入零。这就是为什么内核文档中附带的全双工模式下使用 spidev 的标准示例,如果不对 spi_ioc_transfer 结构的初始化进行修正,就无法在 at91 系列芯片上运行。

注意:

  • 目前,还无法获得给定设备推送/捕获数据位的实际速度。
  • 目前,无法通过 spidev 更改片选信号的极性。每个从设备在未处于活动状态时将被停用,以允许其他驱动程序与其各自的设备进行通信。
  • 每个 I/O 请求传输的字节数存在限制。通常,该限制为一个内存页的大小。可以使用内核模块参数更改此值。
  • 由于 SPI 没有低级方式来确认交付,因此无法知道传输中是否存在任何错误,或者是否选择了不存在的设备。


现在我来举个例子,这是一个简化版的内核自带的 spidev 程序。虽然例子中没有明确说明,但没有人禁止使用系统调用 read() 和 write() 进行半双工数据交换。

#include <stdint.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <getopt.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/types.h>
#include <linux/spi/spidev.h>

static void pabort(const char *s)
{
	perror(s);
	abort();
}

static uint8_t mode = SPI_MODE_0;
static uint8_t bits = 0;
static uint32_t speed = 500000;

int main(int argc, char *argv[])
{
	int ret = 0;
	int fd;
	uint8_t tx[] = { 0x81, 0x18 };
	uint8_t rx[] = {0, 0 };

	if(argc!=2) {
		fprintf(stderr, "Usage: %s <spidev>\n", argv[0]);
		exit(1);
	}
	
	fd = open(argv[1], O_RDWR);
	if (fd < 0)
		pabort("can't open device");

	/* spi mode */
	ret = ioctl(fd, SPI_IOC_WR_MODE, &mode);
	if (ret == -1)
		pabort("can't set spi mode");

	ret = ioctl(fd, SPI_IOC_RD_MODE, &mode);
	if (ret == -1)
		pabort("can't get spi mode");

	/* bits per word */
	ret = ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, &bits);
	if (ret == -1)
		pabort("can't set bits per word");

	ret = ioctl(fd, SPI_IOC_RD_BITS_PER_WORD, &bits);
	if (ret == -1)
		pabort("can't get bits per word");

	/* max speed hz */
	ret = ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed);
	if (ret == -1)
		pabort("can't set max speed hz");

	ret = ioctl(fd, SPI_IOC_RD_MAX_SPEED_HZ, &speed);
	if (ret == -1)
		pabort("can't get max speed hz");

	printf("spi mode: %d\n", mode);
	printf("bits per word: %d\n", bits);
	printf("max speed: %d Hz (%d KHz)\n", speed, speed/1000);
	
	/* full-duplex transfer */
	struct spi_ioc_transfer tr = {
		.tx_buf = (unsigned long)tx,
		.rx_buf = (unsigned long)rx,
		.len = 2,
		.delay_usecs = 0,
		.speed_hz = 0,
		.bits_per_word = bits,
	};

	ret = ioctl(fd, SPI_IOC_MESSAGE(1), &tr);
	if (ret < 1)
		pabort("can't send spi message");

	for (ret = 0; ret < 2; ret++) {
		printf("%.2X ", rx[ret]);
	}
	puts("");

	close(fd);

	return ret;
}


我觉得到这里一切都很明显了,我们已经分析了所有通过 ioctl() 发送给设备的请求。剩下的就是提供用于汇编的 Makefile 文件:

all: spidev_test 
CC = /opt/arm-2010q1/bin/arm-none-linux-gnueabi-gcc 
INCLUDES = -I. 
CCFLAGS = -O2 -Wall 
clean: 
	rm -f spidev_test 
spidev_test: spidev_test.c 
	$(CC) $(INCLUDES) $(CCFLAGS) spidev_test.c -o spidev_test


唯一的事情是您需要在 CC 变量中指定交叉编译器的路径。
 

4.内核级协议SPI驱动程序的开发


开发内核模块是一个更为广泛的主题,因此在这种情况下我们将采用不同的方法:我将首先提供一个代码示例,然后简要描述其工作原理,并解释如何使用它。我不会描述所有细节,否则任何文章都不够,我只会指出最重要的几点,在文章的文档部分,您可以找到所有必要信息的链接。在此示例中,我将展示如何通过 sysfs 提供设备属性。如何实现通过设备文件提供设备访问的驱动程序已经在前面讨论过:one、two。
我的驱动程序将为用户提供更改两个属性的能力:
value - 您可以将一个数字写入其中,需要使用 LED 以二进制形式显示该数字;
mode - 模式开关,允许您设置三种操作模式之一。支持以下模式:0 - 以二进制形式显示数字的标准模式,1 - 从左到右显示的进度条模式,2 - 从右到左显示的进度条模式;
在进度条模式下,设备将显示一行连续的 LED,显示写入值占 256 的百分比。例如,如果您在模式中写入 1,在值中写入 128,则左侧 8 个 LED 中的 4 个将亮起。
如果您设置模式编号的第三位,则将使用全双工模式(fdx_transfer() 函数),而不是异步调用 spi_write() 和 spi_read()。全双工模式编号分别为 4、5、6。模式编号 3 对应于 0。
现在来看代码本身:

#include <linux/module.h>
#include <linux/init.h>
#include <linux/spi/spi.h>

#define SPI_LED_DRV_NAME	"spi_led"
#define DRIVER_VERSION		"1.0"

static unsigned char led_mode=0;
static unsigned char fduplex_mode=0;
unsigned char retval=0;
char *mtx, *mrx;

static unsigned char stbl_tmp;

enum led_mode_t {LED_MODE_DEF, LED_MODE_L2R, LED_MODE_R2L };

static inline unsigned char led_progress(unsigned long val) {
	unsigned char i, result=0x00;
	
	val++;
	val/=32;
	
	for(i = 0; i < val; i++) {
		if(led_mode==LED_MODE_R2L)
			result|=(0x01<<i);
		else
			result|=(0x80>>i);
	}
	return (unsigned char)result;
}

static int fdx_transfer(struct spi_device *spi, unsigned char *val) {
	int ret;
	struct spi_transfer t = {
		.tx_buf		= mtx,
		.rx_buf 	= mrx,
		.len		= 1,
	};
	struct spi_message	m;
	
	mtx[0]=*val;
	mrx[0]=0;	
	
	spi_message_init(&m);
	spi_message_add_tail(&t, &m);
	if((ret=spi_sync(spi, &m))<0)
		return ret;
	retval=mrx[0];
	return ret;
}

static ssize_t spi_led_store_val(struct device *dev,
				 struct device_attribute *attr,
				 const char *buf, size_t count)
{
	struct spi_device *spi = to_spi_device(dev);
	unsigned char tmp;
	unsigned long val;

	if (strict_strtoul(buf, 10, &val) < 0)
		return -EINVAL;
	if (val > 255)
		return -EINVAL;
	switch(led_mode) {
		case LED_MODE_L2R:
		case LED_MODE_R2L:
			tmp = led_progress(val);
			break;
		default:
			tmp = (unsigned char)val;
	}
	stbl_tmp=tmp;
	if(fduplex_mode)
		fdx_transfer(spi, &tmp);
	else
		spi_write(spi, &tmp, sizeof(tmp));
	return count;
}

static ssize_t spi_led_show_val(struct device *dev,
				struct device_attribute *attr,
                char *buf)
{
	unsigned char val;
	struct spi_device *spi = to_spi_device(dev);
	if(!fduplex_mode)
		spi_read(spi, &val, sizeof(val));
	return scnprintf(buf, PAGE_SIZE, "%d\n", fduplex_mode ? retval : val);
}

static ssize_t spi_led_store_mode(struct device *dev,
				 struct device_attribute *attr,
				 const char *buf, size_t count)
{
	unsigned long tmp;
	if (strict_strtoul(buf, 10, &tmp) < 0)
		return -EINVAL;
	if(tmp>6)
		return -EINVAL;
	led_mode = (unsigned char)tmp&0x03;
	fduplex_mode = ((unsigned char)tmp&0x04)>>2;
	return count;
}

static ssize_t spi_led_show_mode(struct device *dev,
				struct device_attribute *attr,
                char *buf)
{
	return scnprintf(buf, PAGE_SIZE, "%d\n", led_mode);
}

static DEVICE_ATTR(value, S_IWUSR|S_IRUSR, spi_led_show_val, spi_led_store_val);
static DEVICE_ATTR(mode, S_IWUSR|S_IRUSR, spi_led_show_mode, spi_led_store_mode);

static struct attribute *spi_led_attributes[] = {
	&dev_attr_value.attr,
	&dev_attr_mode.attr,
	NULL
};

static const struct attribute_group spi_led_attr_group = {
	.attrs = spi_led_attributes,
};

static int __devinit spi_led_probe(struct spi_device *spi) {
	int ret;
	
	spi->bits_per_word = 8;
	spi->mode = SPI_MODE_0;
	spi->max_speed_hz = 500000;
	ret = spi_setup(spi);
	if(ret<0)
		return ret;
	return sysfs_create_group(&spi->dev.kobj, &spi_led_attr_group);
}

static int __devexit spi_led_remove(struct spi_device *spi) {
	sysfs_remove_group(&spi->dev.kobj, &spi_led_attr_group);
	return 0;
}

static struct spi_driver spi_led_driver = {
	.driver = {
		.name	= SPI_LED_DRV_NAME,
		.owner	= THIS_MODULE,
	},
	.probe	= spi_led_probe,
	.remove	= __devexit_p(spi_led_remove),
};

static int __init spi_led_init(void) {
	mtx=kzalloc(1, GFP_KERNEL);
	mrx=kzalloc(1, GFP_KERNEL);
	return spi_register_driver(&spi_led_driver);
}

static void __exit spi_led_exit(void) {
	kfree(mtx);
	kfree(mrx);
	spi_unregister_driver(&spi_led_driver);
}

MODULE_AUTHOR("Lampus");
MODULE_DESCRIPTION("spi_led 8-bit");
MODULE_LICENSE("GPL v2");
MODULE_VERSION(DRIVER_VERSION);

module_init(spi_led_init);
module_exit(spi_led_exit);


现在,我们需要将设备添加到 board 文件中的 SPI 设备列表中。对于我的 SK-AT91SAM9260,我们需要打开文件 arch/arm/mach-at91/board-sam9260ek.c,并将设备的结构添加到 spi_board_info 数组(类似于 spidev):

{	/* LED SPI */
	.modalias	= "spi_led",
	.chip_select	= 1,
	.max_speed_hz	= 15 * 1000 * 1000,
	.mode 			= SPI_MODE_0,
	.bus_num		= 1,
},


从上面的代码可以看出,我的设备工作频率为 15 MHz,挂在 SPI1 上,CS 编号为 1。如果不这样做,加载模块时驱动程序将不会链接到设备。
为了构建模块,我使用以下 Makefile:

ifneq ($(KERNELRELEASE),) 
obj-m := spi_led.o 
else 
KDIR := /media/stuff/StarterKit/new_src/linux-2.6.39.1_st3 
all: 
	$(MAKE) -C $(KDIR) M=`pwd` modules 
endif 


KDIR 变量应该指向你的内核源码路径。
构建过程如下:

ARCH=arm CROSS_COMPILE=/opt/arm-2010q1/bin/arm-none-linux-gnueabi- make


其中变量 CROSS_COMPILE 指定了你的交叉编译器前缀。
现在我们重新构建内核,将模块传输到开发板上并加载它:

insmod /path/to/spi_led.ko


之后系统中就会出现设备属性,我们会看到如下图所示:

ls /sys/module/spi_led/drivers/spi:spi_led/spi1.1
driver  modalias  mode  power  subsystem  uevent  value


现在让我们回到代码本身。您应该从代码末尾开始查看。宏 MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_LICENSE 和 MODULE_VERSION 分别定义了使用 modinfo 命令可访问的信息:作者姓名、模块描述、许可证和版本。其中最重要的是许可证,因为使用非 GPL 许可证时,您将无法从使用 GPL 许可证的模块中提取代码。

宏module_init()和module_exit()分别定义了模块的初始化和卸载函数。如果模块是静态构建的,则宏 module_exit 中指定的函数将永远不会被调用。

在结构体 struct spi_driver spi_led_driver 中,设置了指向将驱动程序绑定到设备(探测)的函数、禁用设备(移除)的函数以及驱动程序所有者名称的链接。还可以在此处设置切换到省电模式(挂起)和退出省电模式(恢复)的函数链接。如果驱动程序支持同一类的多个不同设备,则它们的标识符存储在 id_table 字段中。使用spi_register_driver(struct spi_dirver *sdrv)


函数 在系统中注册 SPI 驱动程序。注册后,可以链接设备和驱动程序。如果一切顺利,接下来将调用探测指针中定义的函数。可以使用spi_unregister_driver (struct spi_driver * sdrv)函数从系统中删除驱动程序 。spi_led_probe() 函数设置用于与 spi_device 结构中的设备一起工作的控制器参数,该结构先前在spi_board_info中定义。在重新定义 spi_device 结构中的必要字段后,调用spi_setup()控制器设置函数。 现在让我们讨论一下属性。您可以在“Documentation/filesystems/sysfs.txt”文件中了解如何通过 sysfs 处理设备属性。DEVICE_ATTR 宏用于定义 device_attribute 结构。例如,以下定义



 

static DEVICE_ATTR(foo, S_IWUSR | S_IRUGO, show_foo, store_foo);


等同于以下内容:

static struct device_attribute dev_attr_foo = {
	.attr	= {
		.name = "foo",
		.mode = S_IWUSR | S_IRUGO,
		.show = show_foo,
		.store = store_foo,
	},
};


其中 show 是指向打开属性文件时执行的函数的指针;
store 是指向写入属性文件时执行的函数的指针;
mode 定义对属性文件的访问权限;
name 是属性文件的名称。
例如,看下面这行

static DEVICE_ATTR(value, S_IWUSR|S_IRUSR, spi_led_show_val, spi_led_store_val);


它定义了一个名为 value 的设备属性,允许用户读取/写入该属性。属性写入处理函数为 spi_led_store_val,属性读取处理函数为 spi_led_show_val。如果未提供此属性的写入/读取权限,则指向存储/显示函数的指针之一可能为 NULL。
让我们看看如何使用此属性:

cd /sys/module/spi_led/drivers/spi:spi_led/spi1.1
ls -l value
-rw------- 1 root root 4096 Jun 29 14:10 value
echo "32">value
cat value 
64


还记得我提到过我的硬件在读取时会将数据左移一位吗?这就是为什么我们得到的是 64 位而不是写入的 32 位。将数字写入属性文件时会发生什么:strict_strtoul() 函数会尝试将接收到的字符串缓冲区转换为数字,然后进行防错处理——检查该数字是否不超过 255。如果大于 255,则返回错误。对于用户来说,它看起来会像这样:

# echo "257">value 
bash: echo: write error: Invalid argument


接下来,检查当前工作模式,并根据该模式设置 tmp 变量。如果是进度条模式,SPI 将接收一个“不间断”的单比特数字;否则,SPI 将直接输出用户指定的字节,不做任何更改。根据 fduplex_mode 标志,选择传输方式:半双工或全双工。在第一种情况下,使用spi_write()函数;在第二种情况下,使用自写的 fdx_transfer() 函数。

现在我们讨论全双工数据传输。如您所见,用于传输的缓冲区(mtx、mrx 指针)的内存是使用 kzmalloc 函数分配的。正如我已经说过的,这是因为需要将缓冲区定位在可用于 DMA 的内存区域中。现在让我们看看 fdx_transfer() 函数本身。本质上,它收集 spi_message 消息并使用spi_sync()函数进行传输。发送消息时收到的字节保存在全局变量 retval 中,如果设置了 fduplex_mode 标志,则该变量始终由用于读取值属性的函数返回。

处理模式属性仅涉及解析传输模式,将其解析为两个全局变量 led_mode 和 fduplex_mode,这两个变量分别确定显示模式和双工模式。

想必很多人都注意到,当尝试同时从多个应用程序写入属性文件时,结果并不清楚。这肯定不会有什么好结果,这里存在明显的竞争条件。我尽量避免使代码过于复杂,但对于那些想知道如何处理这种情况的人,我建议阅读《不可靠锁定指南》。

希望理解其余内容不会有问题。
 

5.文档


讨论过。 内核源代码在 Documentation/ 目录中附带一组文档。这通常是一个不错的起点。
内核使用其自己的源代码文档系统 kerneldoc。它可以使用以下命令从内核源代码目录生成内核系统和 API 的文档:

sudo apt-get install xmlto
make htmldocs


最新稳定版内核的文档始终可在以下网址获取:www.kernel.org/doc
。您可能首先应该阅读的是《Unreliable Guide To Hacking The Linux Kernel》:www.kernel.org/doc/htmldocs/kernel-hacking.html。学习如何在 Linux 中使用 SPI,应该从 Documentation/spi 目录中的概述文档开始,尤其是 spi-summary 和 spidev。此外,还有使用 spidev 的优秀示例:spidev_fdx.c 和 spidev_test.c;希望您没有忘记,对于某些控制器,它们可能需要进行细微的修改。
您可以在 Documentation/filesystems/sysfs.txt 文件中了解如何通过 sysfs 操作设备属性。
内核中用于 SPI 操作的完整 API 描述如下:SPI Linux 内核 API 描述www.kernel.org/doc/htmldocs/device-drivers/spi.html
。为了方便在内核代码中查找信息,建议使用 Linux 交叉引用:lxr.linux.no或lxr.free-electrons.com。

就这样吧,谢谢大家的关注,欢迎批评指正。