As an embedded software engineer, I'm always driven to expand my expertise in bridging the gap between hardware and software. My previous work includes creating a UART bridge using serdev, giving me a strong foundation in serial communication. To broaden my skill set, I wanted to tackle another key serial protocol: I2C. This project specifically focused on mastering I2C communication and the sysfs interface to manage my devices on linux platforms. Previously, I've worked with character devices (device nodes under /dev), but this time I wanted to delve into the /sys filesystem. Sysfs offers a human-readable and understandable way to describe device attributes through files. It's easier to configure and monitor devices compared to character devices, where a single file often serves multiple purposes and requires a deeper understanding of its internal structure. Having built an LCD controller driver (output) via GPIO and character device, this project allowed me to tackle the world of input based drivers, focusing on an I2C-based analog-to-digital converter (ADC).
This project had two main goals: First, develop a robust I2C driver for the ADS1115. Second, create a user-space application, controlled via sysfs, that could read and interpret analog data from a joystick connected to the ADC. This significantly enhanced my skills while providing a practical hardware/software solution to a common challenge: the lack of built-in ADC pins on the Raspberry Pi 4B development platform.
I originally envisioned creating an input-based driver for the Raspberry Pi to react to user actions. I had a spare joystick module, which uses two potentiometers to measure positions on the horizontal and vertical axes. Therefore, the goal was to design a simple driver that could translate joystick movements into directional commands. However, the Raspberry Pi 4B lacks onboard ADC pins. After researching, I realized an external I2C-based ADC like the ADS1115 was the ideal choice.
Fast forward to the present, and I was eager to dive into I2C driver development. This project presented a perfect opportunity to integrate input hardware with I2C communication, refine my approach to embedded system design, and gain practical experience integrating hardware and software. I've previously worked with external I2C python libraries, but I wanted to write a driver with all the proper linux components.
The ADS1115 is a 16-bit precision ADC offering four analog input channels, I2C bus communication, a programmable gain amplifier (PGA), and low-power operating modes. My implementation concentrates on reading single-ended inputs to measure the X and Y axes of the joystick module, with channel selection managed through sysfs.
The ADS1115's behavior is controlled through the Configuration Register (address 0x01). The latest ADC reading is stored in the Conversion Register (address 0x00). To perform a conversion, I write a configuration command to the Configuration Register, wait for the process to complete, and then read the resulting value from the Conversion Register."
The ADS1115 also requires a pointer register to indicate which data register is to be used for the next read operation. In my case, it needs to point to the Conversion Register for reading the raw ADC value. To ensure data integrity in multi-threaded scenarios, I implemented a mutex_lock in the driver.
According to the datasheet, grounding the ADDR pin sets the I2C slave address to 0x48, which I verified using i2cdetect -y 1 on the Raspberry Pi. I adhered to the datasheet's recommendations for circuit design, including pull-up resistors on the SDA and SCK lines and a 0.1uF decoupling capacitor between VDD and GND. Because the Raspberry Pi 4B operates at 3.3V logic, I supplied the ADS1115 with 3.34V to ensure the GPIO pins were not overvolted.
During initial testing with a potentiometer, I observed voltage levels ranging from 1.8 to 3 volts. The joystick module is specified to operate within the 0 to Vdd (3.3V) voltage range. The ADS1115 offers a flexible PGA for adapting to various voltage ranges, and I chose to experiment with both ±2.048V and ±4.096V full-scale ranges (FSR). I initially hardcoded it to a full-scale range (FSR) of 4.096V. While testing, I did find that an FSR of 2.048V had a smaller difference of 0.016V from multimeter readings compared to the FSR 4.096 with a 0.04V difference. However, the wider range provided sufficient resolution for my intended use case. Also, this made sure the joystick could operate in the 0-3.3V ranges. I was willing to sacrifice a small amount of accuracy for a wider dynamic range.
Default configuration in driver code
Highlighting which bits are for PGA and the single channel selection for reads. I ignore the differential channel conversions.
Function for selecting analog channel. Updates adc channel member
My previous work with UART (using serdev) provided a basic understanding of serial communication, but I2C presented new challenges. I had to learn how to use the i2c_client structure, link it to my device tree overlay (DTO), and select the appropriate I2C read and write functions."
I initially considered using i2c_smbus_read_i2c_byte_data, but it quickly became apparent that I needed to transmit two bytes of configuration data and read two bytes representing the 16-bit ADC value. Experimenting with i2c_smbus_read_i2c_word_data didn't yield the expected results, so I adopted i2c_smbus_read_i2c_block_data, which proved versatile and reliable for handling up to 32 bytes of data, making it ideal for the ADS1115.
To streamline driver initialization and exit, I utilized the module_i2c_driver() macro. This significantly reduces boilerplate code by automatically generating the necessary init and exit functions.
I2C SMBus write/read operation in the read_ads function. Utilizes predefined values and current channel member
Important parameters for the functions:
*i2c_smbus_write_i2c_block_data(struct i2c_client *client, u8 command, u8 length, const u8 *values)
struct i2c_client *client: The pointer to the i2c_client struct
u8 command: The address to write to
u8 length: the length to the data that is being written
const u8 *values: the data that is being written
*i2c_smbus_read_i2c_block_data(struct i2c_client *client, u8 command, u8 length, u8 *values)
struct i2c_client *client: The pointer to the i2c_client struct
u8 command: The address to read from
u8 length: the length to the data that is being read
u8 *values: the data that is being read
One of the core project goals was to create a sysfs interface. I wanted to expose key ADC attributes (raw value, calculated voltage, channel selection) as files in the /sys filesystem, providing user-space applications with easy access to the device. This method provides a cleaner integration for software and hardware.
I successfully implemented show and store functions for each device attribute and declared those attributes with the correct file permissions for sysfs interactions. Also, I used .probe and .remove functions to correctly manage the creation and destruction of the sysfs entries. The channel attribute was declared as 0664 to provide read and write access to superusers, while the voltage and raw_value was declared as 0444 providing read only access.
My my_adc struct contains an i2c_client pointer, a device struct pointer, the device attributes themselves (channel, raw_value, voltage_mV), and a struct mutex lock for thread safety.
probe function generate device sysfs entries
remove function cleans up device sysfs attributes
I2C Communication: Utilizes the i2c_smbus_* functions for reliable data transmission over the I2C bus.
Channel Selection: A sysfs interface for selecting the active input channel. The show and store functions facilitate reading and writing the relevant bits to the ADS1115's configuration register.
Voltage Calculation: Converts raw ADC readings into voltage values using the formula: Voltage = (Raw Value * VREF) / FULL_SCALE. In my setup, VREF is set to 4096 mV, and FULL_SCALE is 32768.
Enhanced Debugging with dev_printk: Leverages dev_info and dev_err for kernel logging, offering more context through device information, which is especially helpful in comparison to generic macros such as pr_info and pr_err.
Device Tree Configuration: Move more configuration options (e.g., PGA, data rate) to the device tree. This would allow users to customize the driver without modifying the source code. All the configurations will be done within the device tree overlay.
Industrial I/O (IIO) Subsystem Integration: Convert the driver to use the IIO subsystem. This offers a standard framework for interfacing with sensors and actuators, simplifying tasks such as calibration, data streaming, and analysis.
To validate the driver's functionality, I created two user-space applications: cycle_all.c and joystick.c. The cycle_all.c application iterates through the four input channels, printing the raw ADC value and corresponding voltage for each. On the other hand, joystick.c reads the raw values from channels 0 and 1 and translates these values into the joystick's direction.
This project, although more complex than I initially anticipated, has proven to be an incredibly rewarding learning journey. I have significantly deepened my understanding of I2C communication, device tree overlays, and the sysfs interface. Through this process, I've also enhanced my ability to interpret datasheets, gained insights into ADC operation, and practiced creating user-space programs that interact with device drivers via sysfs.
This project marks a crucial step forward in my understanding and proficiency in implementing serial protocols within embedded Linux systems. It not only broadened my skill set but also reinforced the significance of seamlessly integrating hardware and software components to achieve specific goals. The ability to read the analog values and output the direction of the joystick from kernel to user space using my newly designed and created driver was an amazing feeling.