A framework for interactive MCU register access through UART.
This is a framework for interactive register access. It consists of C code for MCU side and Python code for PC side.
The Python code include a parser and a base library. The parser can parse the register and bit field definitions from an SVD file and generate Python definitions for them, resulting in a device-dependent module. Once the module is generated, you can access the registers in MCU in simple semantics, e.g.,
GPIOA.ODR.ODR15 = 1
TIM1.PSC = 100 - 1
TIM1.CNT = TIM1.ARR.read() - 1
These statements further invokes low-level functions in the base library, sending commands to MCU through UART. The C code handles the protocol in MCU, executing commands, and returning the results, if any.
Python programming on PC is flexible and can be interactive in Jupyter Notebook/Lab. This means you no longer need to code, build, run (these are really time-consuming) each time you make changes to MCU control logic. Instead, you can implement minimal logic, i.e., an adapter, in MCU, for once, then put all control logic on PC. For register accesses, this adapter in already contained in this framework. Register accesses can even be translated back to C for deployment.
Currently this framework is only supported on STM32 with HAL library, but in principle it can work with all MCUs with UART.
-
Enable a U(S)ART with 115200 bps, 8 data bits, 1 stop bit and no parity.
-
Enable the interrupt for this UART.
-
Configure a DMA channel for UART RX.
-
Disable the interuupt for this DMA channel. You should first uncheck "Force DMA channels interrupts".
-
Enable CRC:
- Default Polynomial State: Disable
- CRC Length: 16-bit
- CRC Generating Polynomial: X12+X5+X0
- Default Init Value State: Disable
- Init Value for CRC Computation: 0
-
Save and generate code.
-
Add
seracc.h
andseracc.c
to your project (either link or copy). -
Call
uart_init()
to start the framework. The parameters are pointers to the UART instance and the DMA instance respectively.
-
Install the dependencies:
pip install pyserial pip install crc
-
Run
parse.ipynb
to generate the device-dependent module. Follow the instructions in the notebook. I have generated the modules for some parts. If you find the one for your part, you can skip this step. -
Import the generated module. Here take STM32G474 as an example.
from g474 import *
This may take several seconds.
-
During importing, the framework will ask you which COM port to use. Look up the COM number in device manager and tell it. If you are using the UART bridge from ST-LINK/V2-1 and have the driver installed, the framework can automatically detect it.
- If you accidentally disconnected the UART bridge, you can restart the kernel to reestablish the connection. If you don't want to restart, call
serial_init
inseracc
to reestablish. - Enter
0
to enter evaluation mode. In this mode, all writes are omitted and all reads return 0's. You can test the functionalities and syntaxes without connecting to the MCU.
- If you accidentally disconnected the UART bridge, you can restart the kernel to reestablish the connection. If you don't want to restart, call
-
You can evaluate a peripheral, a register or a bit field by typing it in Jupyter Notebook/Lab:
In[1] : TIM1 Out[1]:
Offset Register Content 0x00 CR1 DEC: 0, HEX: 0x00000000 0x04 CR2 DEC: 0, HEX: 0x00000000 0x0C DIER DEC: 0, HEX: 0x00000000 0x10 SR DEC: 0, HEX: 0x00000000 0x14 EGR DEC: 0, HEX: 0x00000000 0x24 CNT DEC: 0, HEX: 0x00000000 0x28 PSC DEC: 0, HEX: 0x00000000 0x2C ARR DEC: 0, HEX: 0x00000000 In[2] : TIM1.CR1 Out[2]:
31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 DITHEN UIFREMAP ARPE OPM URS UDIS CEN 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 In[3] : TIM6.CR2.MMS Out[3]: 0b000
Essentially, the environment calls the object's
__repr__
method, which shows a table in HTML and returns a string. Note that, however, do not writeTIM1.CNT = TIM1.ARR - 1
; if you want to directly make use of the value of a register, use.read()
:TIM1.CNT = TIM1.ARR.read() - 1
. Hovering the mouse on a register or bit field name shows its description. In this readme I cannot make it work. You can open theexample.ipynb
to experience this feature. -
You can write to a register or a bit field by making an assignment:
TIM6.PSC = 15 TIM6.ARR = 99
The assignment and
__repr__
method access the register in 32-bit width. If you want to read/write in 8- or 16-bit, useread8()
/read16()
/write8()
/write16()
. This makes sense for data registers of USART and SPI. -
For more functionalities, refer to
example.ipynb
.
The framework can record your operations and generate C code so that you can deploy your code into MCU. Note that this feature does not record branch or loop statements within the block, nor the expressions on right hand side; only the EXACT accesses with the EXACT masks and values are recorded.
Operations inside the with logging():
block are recorded. The logging()
function can take one parameter, which is the filename to dump the records. If left empty, it prints on the console.
If you intend to fully configure a register, it's recommended to call .reset()
before writing bit fields. This also generates more compact code.
If you intend to wait for some flag in the register, it's recommended to use the wait_until_equal
function. Wihout recording, this is equivalent to a explicit while
loop, but regarding C code generation, the latter will be converted to several reads while the former to a while
loop in C.
The generation engine automatically combines contiguous write accesses to the same register into one access. If you intend to seperate them, you can use barrier()
between accesses.
Example:
with logging() as log:
SPI1.CR1.SPE = 1
SPI1.DR.write8(0x80)
wait_until_equal(SPI1.SR.BSY, 0)
TIM1.CR1.CEN = 0
log.barrier()
TIM1.CR1.DIR = 1
TIM1.CR2.reset()
TIM1.CR2.MMS = 0b0001
TIM1.CR2.OIS1 = 1
Output:
*(volatile uint32_t*)0x40013000 |= 1u << 6; // SPI1.CR1.SPE = 0b1
*(volatile uint8_t*)0x4001300C = 128; // SPI1.DR = 128
volatile uint32_t* _reg = (volatile uint32_t*)0x40013008;
while ((*_reg & 0x00000080) != 0x00000000); // SPI1.SR.BSY != 0b0
*(volatile uint32_t*)0x40012C00 &= ~(1u << 0); // TIM1.CR1.CEN = 0b0
*(volatile uint32_t*)0x40012C00 |= 1u << 4; // TIM1.CR1.DIR = 0b1
*(volatile uint32_t*)0x40012C04 = 272; // TIM1.CR2 = 0
// TIM1.CR2.MMS = 0b0001
// TIM1.CR2.OIS1 = 0b1
You can implement your own handler in addition to the register accessor based on the UART communication infrastructure provided by the framework.
Some limitations:
- The number of handlers, including the built-in register access protocol handler, is limit to 16.
- The keys of handlers (explained below) should not exceed 7 characters. It is recommended that the key contains letters and numbers only. The key must not contain colon
:
, nor start with underline_
, which are reserved characters. - The length between UART idle states, i.e., the maximum length of a single UART command, is limited to 512. These limitations may be changed or removed in future releases.
Let's consider a Jupyter-to-I2C bridge: you can construct the data packet in Jupyter and send it to an I2C slave by the MCU.
-
Write wrapper functions in Python.
- Let
I2C
be the key for the handler. - Following the colon is the content of your protocol. The first byte of the content is either
W
for write orR
for read. For writing,W
is followed by the slave address (left-aligned), then the packet. You don't need to explicitly encode the length. For reading,R
is followed by the slave address, then the size (no larger than 255). - In either cases, MCU will return one byte indicating if the I2C operation is successful (e.g., if the slave sends ACK). 0 indicates success.
- Here is just an example. You are free to change the implementation details.
from seracc import serial_transmit, serial_receive def i2c_write(addr, val): if not isinstance(val, list): val = [val] cmd = "I2C:W".encode() cmd += bytes([addr]) cmd += bytes(val) serial_transmit(cmd) rec = serial_receive(1) if rec[0] != 0: print("I2C error") def i2c_read(addr, size): cmd = "I2C:R".encode() cmd += bytes([addr]) cmd += bytes([size]) serial_transmit(cmd) rec = serial_receive(size+1) if rec[0] != 0: print("I2C error") return rec[1:]
- Let
-
Configure related peripherals in CubeMX.
- In this case, configure the I2C and GPIO.
-
Write handler functions in C.
- The handler function must have signature
void(uint8_t*, size_t)
. The first parameter is a pointer to the content, the second being its size. - According to the protocol defined above, the function should first judge if the first byte is
W
orR
. - Then it sends the data with length implied by the content size, or receives data with specified length, with HAL functions.
- Finally, it sends back the result to PC, including a byte indicating success and, in the
R
case, the received data.
void i2c_handler(uint8_t* data, size_t size) { if (data[0] == 'W') { uint8_t addr = data[1]; uint8_t len = size - 2; uint8_t ret = 0; if (HAL_I2C_Master_Transmit(&hi2c1, addr, data+2, len, len+1) != HAL_OK) ret = -1; uart_transmit(&ret, 1); } else if (data[0] == 'R') { uint8_t addr = data[1]; uint8_t len = data[2]; uint8_t ret[len+1]; ret[0] = 0; if (HAL_I2C_Master_Receive(&hi2c1, addr, ret+1, len, len+1) != HAL_OK) ret[0] = -1; uart_transmit(ret, len+1); } }
- The handler function must have signature
-
Register the handlers.
- Register the handler with a call to
uart_register_handler
. The first parameter should match the key specified in Python wrapper function.
uart_register_handler("I2C", i2c_handler);
- Register the handler with a call to
Access to global variables: Global variables have fixed address in RAM. This can be found in .map
files. In the near future this library will support parsing .map
files and provide access to the global variables. This eliminate the need to implement custom protocols to access the variable, making parameter tuning easier, e.g., PID.
A more robust framework regarding UART and DMA is required. The mapping from string to handler can be optimized in complexity.
UI improvement: descriptions that are too long cannot be completely displayed. (Need help, I can't do front-end.)
More tests on other platforms, including other series in STM32 and MCUs from other manufacturers, are needed.
Contributions are welcome!
This library is made open-source. It is renamed to "Serial Accessor" to reflect its main functionality.
Added wait_until_equal
. This can be archived by a while statement at runtime, but not for code generation. wait_until_equal
provides a method to wait for some flag in generated C code.
Contiguous write accesses to the same register is now combined into one access in generated code. If you intend to seperate them, call barrier()
between accesses.
Transactions made in Python can be translated to C simply by enclosing the statements with a with logging():
block. Note that this feature does not record branch or loop statements within the block; only the EXACT masks and values are recorded.
The reset()
method resets a bitfield or a register. For peripheral reset, please use RCC reset registers.
Previously all write commands were 12-byte long, including a 32-bit address, a 32-bit value and a 32-bit mask. However, some accesses, e.g., setting or clearing a single bit, are common and should be optimized. Now the protocol is remastered and the average length of commands in typical use cases is reduced to, say, 8 bytes.
Parsing source is switched to the SVD file. This makes the register map more complete and accurate.
Registers and bit fields can now be accessed with subsripts, e.g., TIM1.CCR[1]
is equivalent to TIM1.CCR1
. This enables a simpler syntax for multi-instance/channel treatment.
Taking the repr
of a peripheral now shows all registers in it. Similar for those subscriptable names including TIM1.CCR
.
In all tables, hovering the mouse on a name shows the description of that register or bit field.
Evaluating a register now shows a table consisting its bit fields and the corresponding values.
This project is started for teaching purposes. The registers and data fields are parsed from C headers, e.g., "stm32g474xx.h". This may be inaccurate or even fails with some advanced peripherals, but still okay for teaching purpose.