Some time ago I found the CH341a programmer.

The board seems a bit intimidating with its ZIF socket, but once you realize the low component count, it becomes more approachable. When I found a CH341 "dev" board, I started looking for documentation on how to develop with it.

The first step was, of course, finding the datasheet, but it turned out to be quite short. After checking the basics, there was nothing more - no programming guide, no registers, nothing.
Looking for a programming manual, I could only find drivers for Linux, Windows, and Android.
The good news is that this chip has been extensively reverse engineered, so there is a lot of code available. This post is more about the journey of finding all the details.
If you just want to check the code, jump to: https://github.com/danguer/pych341
The Three Modes of CH341
The first thing you will notice is that the CH341 behaves as three different devices depending on some pin configuration.

The modes are:
- UART — in this mode the device shows up as a VCOM or Serial Port adapter. There is not much interest in this mode; aside from the full signals available, everything is transparent: you plug it in and it is recognized as a serial device, so no extra work is needed.
- PRINT — in this mode it behaves as a "standard Centronics printer interface" and should appear as a parallel printer. This also seems to be a transparent mode, so there is not much interesting here (for now).
- EPP/MEM — this is the most interesting mode, as you can use the device as an I2C master, SPI interface, or GPIO controller (it was apparently also designed to read/write parallel memory chips).
The datasheet shows how the pins have different uses under each mode.

Starting with USB Programming
Accessing the USB with pyusb is quite straightforward:
- Find a device using the vendor ID and product ID (for EPP/MEM,
vendor_id=0x1A86, product_id=0x5512). - List the endpoints.
- Find a bulk input (to read) and output (to write).
The write operation is usually wrapped in some special opcodes (more on this later), and the read is transparent - the data you receive comes from GPIO/I2C/SPI without extra frames or opcodes.
Reverse Engineering
Here is where the fun starts. The WCH website contains this file: https://www.wch-ic.com/downloads/CH341PAR_LINUX_ZIP.html, and searching further leads to this repository:
https://github.com/WCHSoftGroup/ch341par_linux
The code is basically:
- A demo (testing all functions)
- A library
- A kernel driver/module
The library calls the kernel driver and passes some arguments. It contains a lot of functions, some for different chips like CH347, and others that seem to not work at all (for example, calls to write/read from parallel memory chips).
This looked like it would just be a matter of reading the source code and following along — except there is no source code for the library.
The README.md from the zip file points to: https://github.com/WCHSoftGroup/ch34x_mphsi_master_linux
This repository contains a much simpler and more straightforward implementation. My first guess was that this was all that was needed, so I started with the I2C implementation.
I2C Implementation
The implementation is based on the ch341_i2c_stream function. The sequence in the original is:
CH341_CMD_I2C_STREAMCH341_CMD_I2C_STM_STA-
CH341_CMD_I2C_STM_OUT(ORwith the length of data) - If reading:
CH341_CMD_I2C_STM_IN(ORwith the length of data) CH341_CMD_I2C_STM_STOCH341_CMD_I2C_STM_END
The first thing to note is that there is a "packet" wrapped between CH341_CMD_I2C_STREAM and CH341_CMD_I2C_STM_END, meaning those opcodes are for the chip itself.
After verifying with a logic analyzer and the I2C protocol, CH341_CMD_I2C_STM_STA sends a Start signal and CH341_CMD_I2C_STM_STO sends a Stop signal.
The next thing to note is how you send/receive I2C data. The tricky part is that you must always send the address, which is defined as a 7-bit address plus an R/W bit (Read=1, Write=0). Most libraries handle this automatically, but here you handle the sending and receiving of data directly.
To write data to a device is straightforward:
# send a 0xFF as message to address 0xC0
address = 0xC0
data = 0xFF
cmd = (
I2CCmd.MODE_STREAM,
I2CCmd.START, # start signal
I2CCmd.DIR_OUT | 2, # sending just 1 byte of data plus 1 byte of address
address << 1, # sending 7-bit address and 0 as R/W bit
data,
I2CCmd.STOP, # stop signal
I2CCmd.END,
)
To read data is a bit different, since you first need to write the address to the device and then read from it. For example, to read 1 byte from the device:
address = 0xC0
cmd = (
I2CCmd.MODE_STREAM,
I2CCmd.START, # start signal
# write the address
I2CCmd.DIR_OUT | 1, # sending just 1 byte of address
(address << 1) | 1, # sending 7-bit address and 1 as R/W bit
# now actually read data from device
I2CCmd.DIR_IN | 1, # just read 1 byte
I2CCmd.STOP, # stop signal
I2CCmd.END,
)
The i2c.py module follows the Arduino Wire library approach.
More information about I2C: https://www.ti.com/lit/an/sbaa565/sbaa565.pdf
GPIO Implementation
This is probably the trickiest part. The first implementation appears to target CH347 and uses a control endpoint instead of bulk:
https://github.com/WCHSoftGroup/ch34x_mphsi_master_linux/blob/main/driver/ch34x_mphsi_master_gpio.c#L345
The driver has CH34xSetOutput and CH34xSet_D5_D0, so I decided to cross-reference this against the older library source code: https://github.com/zoobab/ch341-parport/blob/master/CH341PAR_LINUX/lib/ch34x_lib.c#L593
There is also a simpler implementation here: https://github.com/frank-zago/ch341-i2c-spi-gpio/blob/master/gpio-ch341.c#L198
Writing GPIO
There are some undocumented values, but here is the packet structure:
[byte0] 0xA1 (output command)
[byte1] 0x6A (magic value)
[byte2] enable mask
[byte3] Byte 1 Data (0 for LOW, 1 for HIGH)
[byte4] Byte 1 Direction (0 for INPUT, 1 for OUTPUT)
[byte5] Byte 0 Data (0 for LOW, 1 for HIGH)
[byte6] Byte 0 Direction (0 for INPUT, 1 for OUTPUT)
[byte7] Byte 2 Data (0 for LOW, 1 for HIGH)
[byte8] Byte 2 Direction (should be 0x00)
[byte9] Byte 3 Data (0 for LOW, 1 for HIGH)
[byte10] Byte 3 Direction (should be 0x00)
The packet must be exactly 11 bytes.
GPIO Bytes
There are 4 GPIO bytes in the following order:
- Byte 1 (bits 8–15), pins:
ERR, PEMP, INT, SLCT, unknown, WAIT, READ, ADDR - Byte 0 (bits 0–7), pins:
D0–D7 - Byte 2 (bits 16–23), pins:
WRITE, SCL - Byte 3 (bits 24–31), pins:
SDA
Enable Mask
The enable mask consists of pairs of bits that enable output and direction. Since this function is only for writing outputs, you need to always set both to 1. It follows the same order as the GPIO bytes, meaning the first two bits control enable and data direction for Byte 1 (bits 8–15):
bits 0,1 = Byte 1 Enable, Direction
bits 2,3 = Byte 0 Enable, Direction
bits 4,5 = Byte 2 Enable, Direction
bits 6,7 = Byte 3 Enable, Direction
Note that Byte 2 and Byte 3 only allow output direction, but you need to set Enable=0, Direction=1. The documentation is not very clear about the meaning of "enable" here.
Writing with CH34xSet_D5_D0
A special message exists to set only pins D0–D5. The packet is very simple:
cmd = (
GPIOCmd.UIO_STREAM, # 0xAB
GPIOCmd.UIO_DIR | 0x3F, # 0x40
GPIOCmd.UIO_OUT | (mask & 0x3F), # 0x80
GPIOCmd.UIO_STREAM_END, # 0x20
)
The mask bits represent the pins to set HIGH. For example, to set D0 HIGH use 0x1, and to set D0 and D2 HIGH use 0x5.
Reading
Reading is simple, though it involves some undocumented behavior:
cmd = GPIOCmd.INPUT # 0xA0
After sending the command, read 6 bytes of data:
[byte0] Byte 0 Data
[byte1] Byte 1 Data
All other bytes are undocumented or represent output pins.
To read the value of D0:
d0_value = data[0] & 0x1
SPI Implementation
This is the simplest implementation, but it does require some understanding of SPI. The implementation needs to:
- Send X bytes
- Read X bytes
This is because SPI is full-duplex, meaning that when you send data you also receive data within the same clock cycle. You must send and read simultaneously, or the buffers will overflow.
The implementation is based on: https://github.com/WCHSoftGroup/ch34x_mphsi_master_linux/blob/main/driver/ch34x_mphsi_master_spi.c#L511
The command is:
def write(self, data: bytes) -> bytes:
data_len = len(data)
cmd = bytearray()
cmd.append(SPICmd.MODE_STREAM) # 0xA8
cmd += data
self.device.write(cmd)
# after writing, read the controller response
return self.device.read(data_len)
That is all there is to it. To read from SPI, simply call data = write(0xFF).
The one tricky part is that the chip always transmits in LSB-first order, so sending 1 is transmitted as 1 0 0 0 0 0 0 0. Most devices expect MSB-first (0 0 0 0 0 0 0 1), so you can use a lookup table to convert between the two.
Chip Select
The board has CS0, CS1, and CS2, but there is no dedicated command for chip select. Instead, you enable it through GPIO using CH34xSet_D5_D0. For example, set D0 HIGH before a transaction and LOW after to use it as a chip select signal.
Top comments (0)