I2C- when to use or not use dma - Page 1
Forum section: electronics / general

author: cv007
author_rank: Frequent Contributor
timestamp: June 21, 2025, 01:16:50 am
content: I have created an i2c driver for an stm32g031 (master only), which uses irq's to allow both blocking/non-blocking usage. I am also using dma, where the i2c xfer function requests a channel from the dma class, and if given (we now 'own' the channel) will use dma to move the wr/rd data (irqs still used of course for anything other than writing/reading tx/rx registers). If no dma channel free, the data then moves via irq for each byte (txis/rxne irq's). If dma was used, at the completion of a transaction it is 'given' back to the dma class, so the (single) channel is used only during the transaction.I have an exception to using dma- there is a ping function that uses a start+wr with no data to move just to get a status flag (TC or NACKF) so dma is not used for that. I also validate the array of xfer_t's given to the xfer function, so can count total bytes that are going to be moved and pick a number where it makes sense to use dma. The setup for dma is trivial, so even if a single wr/rd its probably not a big deal even though there was no gain in doing so.Is there a point where dma should or should not be used? The 'should' part is relatively easy, the 'should not' I'm not sure. Probably for many dma use cases, the channel(s) are going to be fixed to particular uses for the duration, but in this case the i2c can take/release as needed and fallback to irq only if none available. If important enough, the i2c class could also 'take' a dma channel and never release it (as other users of dma could also do).Below is an example usage of the i2c dirver (compiles to about 4.9K). The driver is a header file, about 350 lines long. There are other headers already in place- such as gpio, nvic, rcc, dma, uart, Print, and so on so writing the driver was not too difficult as most of the effort was in the i2c device and not anything surrounding it (like setting up pins, rcc, setting up nvic and ram vector table entry, etc.).The ST documentation is always a bit of a struggle for me, and looking at their HAL drivers for any additional clues/answers is usually futile. The dmamux peripheral was a typical case- reading the chapter leaves you pretty much confused, and they seem to skip over the most basic things (took me a while to figure out writing a single id value to a register is all I needed).Code://........................................................................................#include MY_MCU //makefile define (specific mcu- stm32g013k8)#include "Uart.hpp"#include "I2c.hpp"#include "Led.hpp"#include "Sw.hpp"Uart uart { MCU::Uart2_A2A3, 1'000'000, Uart::TXONLY };I2c i2c { MCU::I2c1_B8B9 };Led led { MCU::PC6 };Sw sw { MCU::PF2, Sw::LOWISON };using namespace FMT; //Print- endl, dec, hex, etc.using namespace ANSI; //FMT::ANSI colors, etc.//........................................................................................classDS3231 //simple example of device that uses i2c//........................................................................................{I2c& i2c_;u8 address_;I2c::SPEED speed_; //KHZ100 or KHZ400 (ds3231 can do 400kHz)enum{ REG_COUNT = 0x12+1 }; //0x00-0x12u8 rdBuf_{ 0 }; //storage for all registersu8 wrBuf_{ 0 }; //reg pointer val (always 0)I2c::xfer_t xfers_{ //i2c decrements .len members{ i2c.WRITE, wrBuf_, 0 }, //.len members needs to{ i2c.READ, rdBuf_, 0 }, //be set before use{ i2c.END, 0, 0 } //end marker};autoupdate (){xfers_.len = 1; //write 1 byte (value of 0)xfers_.len = REG_COUNT; //read all registersreturn i2c.xferWait(xfers_, address_, speed_);}public:DS3231 (I2c& i2c, u8 address, I2c::SPEED speed = I2c::KHZ400): i2c_{i2c},address_{ address },speed_{ speed }{}autoprint (FMT::Print& printer){printer, "DS3231 read HH:MM:SS...";auto res = update();if( res == i2c.OK ){ //OKprinter,fg(LIME), //SVG colorhex0(2,rdBuf_), ':', //HH:hex0(2,rdBuf_), ':', //MM:hex0(2,rdBuf_); //SS}else { //errorprinter,fg( Rgb{255,50,0} ),"failed, RESULT: ", i2c.resultText(res);}printer, normal, endl;}}; //class DS3231//........................................................................................DS3231 ds3231{ i2c, 0x68 };intmain (){i2c.printer( uart ).debug( true ); //set i2c debug printer, turn on debugwhile(1){led.toggle();if( sw.isOn() ) i2c.debug( not i2c.debug() ); //toggle debugds3231.print( uart );// DS3231 read HH:MM:SS...06:59:39// DS3231 read HH:MM:SS...failed, RESULT: NACKCPU::delayMS(1000);}} //main//........................................................................................

author: hans
author_rank: Super Contributor
timestamp: June 21, 2025, 07:11:21 am
content: I see you're using C++ and presume you're trying to develop reusable code, I don't see a problem with the I2c driver supporting a DMA switchover if that's what you're after.For small devices (like a DS3231) it may be a bit overkill. In your code, you've to acquire a DMA channel, write 5-8 registers for set up, possibly memory sync (flush caches on Cortex M7 etc.), and then press the start button.DMA is simply a "coal shoveler" for bytes. In this case I2c is a slow peripheral, so it won't make the final application that much faster, but it would reduce CPU overhead. However that overhead is either in more setup or more IRQs.More concretely, it may be quicker to simply have 2 more I2c IRQs for a 3 byte transfer than to do DMA.You could measure that.. flip a GPIO for the duration you're in each IRQ, sum the durations of IRQs, and compare a IRQ setup vs pure DMA vs one that switched over for a certain number of bytes.I've used I2c DMA for those small OLED displays with 128x64 pixels. Its display buffer is 1kB, so it would take 20ms @ 400kHz to transfer. But in that case I did the DMA switchover above a certain number of bytes (threshold) and only inside the main application code:Code:template<uint32_t DmaThreshold, typename TdmaStreamManager>class Pic32I2cMaster_generic final : public Pic32I2c, public HwI2cMaster {volatile bool busy = false;public:// ** other methods like Init/Start/Stop/IsBusBusy/etc.// Synchronousbool Transmit(uint8_t txOut) override {auto trn = getPtr<uint32_t>(&I2C1TRN, module);auto stat = getPtr<__I2C1STATbits_t>(&I2C1STAT, module);*trn = txOut;while (stat->TRSTAT);return !stat->ACKSTAT;}// User functionbool Transmit(uint8_t* txData, size_t length) {auto trn = getPtr<uint32_t>(&I2C1TRN, module);auto irq = (module == 1) ? _I2C1_MASTER_IRQ : _I2C2_MASTER_IRQ;auto per = (module == 1) ? Pic32mxPeripheral::PerI2C1 : Pic32mxPeripheral::PerI2C2;if (DmaThreshold != 0 && length > DmaThreshold) {Pic32DmaStream& stream = TdmaStreamManager::acquire(per);stream.Transmit<uint8_t>(trn, irq, txData, length);stream.Force();stream.Wait();TdmaStreamManager::Release(stream);return true;} else {bool ret = true;for (size_t i = 0u; ret && i < length; i++) {ret = Transmit(txData);}return ret;}}}using Pic32I2cMaster = Pic32I2cMaster_generic<0, Pic32DmaArbiter>;template<size_t threshold>using Pic32I2cMasterDma = Pic32I2cMaster_generic<threshold, Pic32DmaArbiter>;Ultimately I had set the threshold to 8 bytes.As you can see my implementation wasn't quite finished, as Transmit was still a blocking call. This makes the use of DMA a bit pointless. But oh well, this was a hobby project while I was recovering, fun while it lasted.Had I now done it again, I probably would have returned a handle of somekind in this function, so the application can check whether the I2c transfer was completed in the background (IRQ or DMA). I would then also have added an "Auto stop" flag so when IRQ/DMA finishes it can releases the bus automatically (iirc that code was also polling a I/O expander at a few hundred Hz, so an immediate bus release would be useful).Especially for this application this would have been useful, as flushing a display buffer is send and forget.

author: cv007
author_rank: Frequent Contributor
timestamp: June 21, 2025, 09:02:59 am
content: QuoteTo some extent, mostly in the form of usage and outline rather than details. For example, the i2c usage for my avr is the same as the stm32 which both use a basic set of functions (same name) to do a transaction which consists of an array of transfer types, and are generally instantiated the same by giving the i2c the required info it needs (which pins to use, etc.). The stm32g031 code will of course work for all g0 series, with differences contained in the MCU namespace (pins, number of peripheral instances, etc.). The underlying details are mcu specific but as long as the code is tested/correct/reliable the details become mostly unseen afterwards.For this example, to read all ds3231 registers requires a trip to the isr 22 times, and when dma is used it is only 3 times (2 completes and a stop). In the big picture the transfer time is pretty much a fixed time due to i2c bus speed and amount of data transferred (in this case), but maybe freeing up cpu time is always a good thing and less irq's is always a good thing.I can allow an option to specify irq only or dma, but I prefer not to add more options when not really necessary even though it can be done via a default argument. I will probably just check if any single transfer in a transaction is more than 1 byte during xfer validation, and if so use dma if a channel is available.An example of adding more options than necessary is in the simple example I gave- there is really no need to pass in a slave address to DS3231. The address is fixed for this device, so an enum in the class is all that is needed.

author: Siwastaja
author_rank: Super Contributor
timestamp: June 21, 2025, 11:34:03 am
content: General principle:DMA when the overhead of setup is smaller than the cost of interrupt or poll driven approach.Conversely, do not use DMA when the setup wastes more CPU than doing the whole thing interrupt or poll based.I2C is often about transfers of a few bytes, in which case DMA makes no sense. Depends a lot of what features are available, though. Maybe higher-end DMA can sequence multiple I2C transfers without CPU intervention, and sometimes I2C is used in somewhat higher data rate applications - e.g. ADCs or IMUs producing tens of kbit/s.For the usual case of configuring a few I2C registers, DMA makes no sense.Also, when performance matters, do not ignore micro-optimization. In case of microcontrollers, this usually meanssimplifcation, tearing away unnecessary layers, and reading documentation or trying out which configuration register writes are actually unnecessary. For example, IIRC, standard STM32 DMA only requires two mandatory writes: error flag clearing and enabling the channel. Peripheral address and data size configurations usually stay constants so only need to be written once. Data memory address might need writing unless you can utilize the built-in double buffering. Even then, this totals to three lines of code and three register operations. Much less overhead than typical abstracted solution (unless the abstraction is very well designed to be performant).

author: cv007
author_rank: Frequent Contributor
timestamp: June 22, 2025, 04:16:16 am
content: QuoteThese i2c transactions are made up of individual transfers (wr or rd), and am using a single dma channel for both read and write so after each transfer (i2c TC irq) the dma channel has to be setup again via a generic memDev dma function which does about 6 register writes, and a single register write to dmamux (a default bool argument to memDev sets direction which defaults to mem -> dev).The time measurements I have for a wr + rd transaction using the ds3231, using the systick counter (numbers here are counter values converted to 0 based value)-// 1 wr, 19 rd dma-50558 irq-156456// 1 wr, 3 rd dma-44838 irq-61577// 1 wr, 2 rd dma-44525 irq-55681// 1 wr, 1 rd dma-49785 irq-49757so it appears as soon as you get more than a single byte in a single rd/wr transfer, the dma becomes a faster option. Even the two single transfers are almost the same so maybe it makes no sense to even exclude these from dma use, and one more decision in the code can be eliminated.edit- My numbers were screwed up, I must have had debugging turned on which of course messes up big time.new numbers which probably make more sense (mcu speed is 16MHz, i2c speed 400kHz)-// 1 wr, 255 rd dma-45374 irq-45354// 1 wr, 128 rd dma-45335 irq-44879// 1 wr, 19 rd dma-8584 irq-8499// 1 wr, 3 rd dma-3236 irq-3079// 1 wr, 2 rd dma-2915 irq-2767// 1 wr, 1 rd dma-2431 irq-2426The transaction time, it now appears, is for the most part based on the i2c timing and number of bytes to move, so simply timing how long a transaction takes is probably not a great way to judge the usefulness of dma in this case. The unused cpu time would be the main benefit, and if the mcu was doing more useful things in parallel (multiple interrupts in use) rather than the simple example here where the i2c is the only interrupt in use, maybe the results would start to shift more in dma favor as far as transaction time goes. The i2c peripheral in this case has a single byte buffer (shift register copies to rx/tx data register) so the transaction continues while the isr processes and this processing time takes place while the next byte is being tx/rx. In contrast, something like an avr has to stretch the clock until code handles the tx/rx, so becomes dependent on how fast you get to the isr each time (and of course avr has no dma).But again, the single wr/rd shows very little difference in transaction time, so even if no real benefit to do so there also is very little harm done. The differences in the transaction times in the table above become the processing 'between' each transfer where data is no longer moving in the background (dma or not, due to the shift register), and the dma setup does appear to take longer between these transfers. At least that is how I read it.-end editI do not want to turn drivers into a mess of options which have to be chosen (do you want dma or irq only for example), nor do I write my drivers to consider every possible need in advance (for example, there is no slave code in the driver- it will be written if/when needed). In this case I was just wondering if there is a downside to using dma for i2c. If the only downside is trading irq time for dma setup time and at the worst that setup time may take longer than a single irq, then its probably worth doing just to simplify things (you ask for a transaction, driver takes care of it). For the small transactions it will not matter either way, for the large transactions the dma will be a big advantage.The transaction is validated and the bytes per transfer is known so the i2c code can decide to skip dma based on bytes to transfer. If any are more that X bytes then use dma (if there is a free channel it can acquire). In addition to the irq processing time (nvic hardware) there is also the isr code that takes time, so as soon as you start getting more than 1 byte in a transfer, each additional byte eliminates both the irq firing time and the isr code time by using dma. Whether that effectively makes much difference in the big scheme of things, in many cases probably not but the code is in place to handle devices where it will make a difference. There would then be no need for the user of the driver to decide what transfer method they want the driver to use as you always get the best available.The public functions for the i2c driver are basically-RESULT xfer(xfer_t* xfers, u8 addr, SPEED e = KHZ100){ ... } //non-blockingRESULT xferWait(xfer_t* xfers, u8 addr, SPEED e = KHZ100){ ... } //blocking with a default timeout (uses xfer)and a few others such as turning on/off debugging, setting debug output 'printer', getting transaction last result, 'ping' an address, etc.The constructor gets the basic i2c info- a constexpr struct with peripheral instance, what pins to use and their alt function value, dma mux id's, irq vector number. There is no need for the user to deal with pin setup, setting up the ram vector table entry, turning on rcc clock, etc. That is the biggest problem I have with most manufacturers code- there is typically no high level usage, and the driver code in many cases only eliminates the need for direct register access.The xfers arg is validated (and a max xfer length is now also known), address is validated, the timing is set based on speed wanted and the current system pclk speed (no one needs to inform us if pclk changes, we check), if dma wanted we ask dma class for a channel, we turn on i2c and call the private start function which can figure out how to produce a start based on whether dma is in use or not. If xferWait is used, you will get a RESULT within the default timeout, if the non-blocking xfer is used you will get an ACTIVE result if the transaction was started and from then on its up to your code to check if the transaction is finished (a callback can be set in an END xfer_t).I have a fixed timeout value for the xferWait, so there is no need for the user to figure out what value to use for a timeout. The timeout is reset in the isr so the timeout is effectively 'between' isr's and eliminates dealing with various lengths of transfers and i2c speeds. I know most will have the user provide a timeout value, and I have done the same, but the question is always what value to use so you typically make a guestimate and/or use some value you know will not cause a premature timeout. In my thinking, the only reason for a timeout is to prevent blocking when things go wrong in the i2c. I see no real need to be precise in the timeout, it just has to prevent blocking forever when the i2c goes off the rails. The driver can handle this timeout value, with no need for the user to screw it up with a value which generates a timeout when the i2c transaction was moving along fine.Since I am using command line + makefile + stm32 nucleo32 (programming via virtual drive), any debugging is done via uart. There are places in the i2c code where if debugging is on and a 'printer' was provided it will output enough info where I can see what is going on (and is less useful once you get a handle on how the peripheral acts). There are times when it would be nice to have a debugger, but for the most part uart output works quite well with the only downside being you have to create these debug outputs and can clutter up your code a little bit. Stopping in the middle of code with a debugger also has its downsides, and about the only time I really find it helpful is when a peripheral acts in unknown ways and you can then (easily) take a look at the peripheral registers to see what is going on. The uart debugger can do the same, but you have to create the debug output code on your own and it may be a one time use (you see how the peripheral acts, now understand).

author: Siwastaja
author_rank: Super Contributor
timestamp: June 22, 2025, 06:01:42 am
content: Quote from: cv007 on June 22, 2025, 04:16:16 amWhy? I have always used two DMA channels for bidirectional peripherals. This way you get rid of the DMA channel reconfiguration in the middle of the transaction (which is timing critical and needs to be done in a relatively high-priority interrupt not to miss the read half of the transaction), and get better CPU offloading, the whole point of DMA. Additionally, peripheral addresses, DMA channel and DMAMUX configurations stay fixed, just initialize at boot.Peripheral configuration registers are slower than SRAM let alone CPU core registers so when time matters, shaving off even a single register access is often worth it. In that light, note the difference in:boot:PERIPHERAL->CONFIG = INITIAL_VALUES;runtime:PERIPHERAL->CONFIG |= ENABLE_BIT;vs.boot:PERIPHERAL->CONFIG = INITIAL_VALUES;runtime:PERIPHERAL->CONFIG = INITIAL_VALUES | ENABLE_BIT; // Get rid of RMW and just do write

author: cv007
author_rank: Frequent Contributor
timestamp: June 22, 2025, 07:49:49 am
content: QuoteWhy take up 2 channels when you only need 1. Since this i2c driver is not counting on dma to be available, asking for a free channel to use instead of two makes sense I think (5 available on this particular mcu). Setting up a channel takes about 3us with a 16MHz cpu clock, and that is about the same as a single clock period of a 400kHz i2c clock, so in the big scheme this is also insignificant and am not about to worry about it. The time not spent in an isr, which the cpu can now use for other things, most likely will offset that 3us+3us setup used for a typical two transfer transaction.The transactions are variable, and allows for the caller to do what they want. If there is a need for 2 writes and a read, it can be done. If reads need to go into different buffers, it can be done by creating two read transfers each with a different destination, or if you would rather not create a single write buffer combined from different sources you can create two write transfers each with its own source address. There is also then no need to come up with various forms of a function- write, writeRead, read, readWrite, and so on. You give the i2c xfer function an array of xfer_t's and it handles it, doing each xfer_t however you wanted it done. If you only use the simple/common combinations, doesn't matter, the usage is always the same and instead of splitting up transactions to fit the available functions the function conforms to the transaction you want. An array of transfers for the driver to handle instead of a combination of hal_transmit/hal_receive type code the user has to use individually. The same result in either case, but with the former you deal with the transaction once and you are done.Code:usingcallback_t = void(*)(I2c&);enumRWE { WRITE, READ, END };usingxfer_t = struct {RWE rwe;union { volatile u8* data; callback_t cb; }; //can store callback in an ENDvolatile u8 len;};QuoteI typically don't do the former unless required due to some bit(s) that cannot be changed (and do not know what they may be). In many cases you do know what all bits should be so no need to set/clr bits. I do this not for saving cpu cycles, as I don't really care about saving cpu cycles down at that level, but rather so I do know what the register value is at that point in time. Not always possible, but possible more often than one would think, and the question should always come to mind when writing to registers. Some mcus make good use of address space, such as silabs efm/efr or nordic nrf where you have little need to deal with anything other than simple reads/write. For efm/efr they also have the advantage when bit manipulation is needed you have set/clr registers to use so can still do only writes and also take care of atomic issues at the same time.

author: hans
author_rank: Super Contributor
timestamp: June 22, 2025, 08:06:51 am
content: There could be different constraints to optimize for.Personally I'm not that fussed in saving a few (RM)W on registers. That doesn't make your point invalid, though, because especially on faster ARM chips, register access will become painfully slow (e.g. STM32H7 500MHz chips that can only toggle GPIOs at 15-20MHz).However, in case of the STM32G031: that part only has a 5 channel DMA controller. Hogging up 2 channels just for I2c seems a giant waste, since its a half duplex protocol. There is no way these DMA channels can be concurrently active. I'd rather take the few register extra writes to save a whole DMA channel. For I2c, these register accesses should take <1us and a few bytes of code.This DMA channel count was a constraint on Pic32mx250 as well. It has a 4 channel DMA, and from the project I referred code from, 2 channels were used to send out PWM-data to WS2812 LED strips, and 1 was used to sample ADC data with a channel scan, with the final one for I2c. Fortunately on the Pic32mx250, the I2c Tx/Rx events are a single source.The pre setup of registers does save overhead time, which could swing in favour of statically allocating DMA channels (even if its direction is reconfigured).I think its debatable whether its really useful to have an 'allocator' or 'arbiter' for DMA channels when typically they are used to offload high frequency transfers that can't easily be handled in IRQ interrupts (for example, transmitting data with a timer to WS2812 LEDs). Its not like DMA is a 'shared resource' in that regards. However, if the concurrency of some channels can be overseen, I can understand why an allocator could be useful to free up resources asap (with the penalty of extra register accesses).It may be useful to have an optional argument to the allocator which tells how many free channels it should leave free, if other processes come along without an Irq fallback. Although perhaps its better to statically allocate those and never touch those channels again...With respects to CPU time: even if you set/reset a GPIO inside the IRQ handler, it will still hide the interrupt entry/return latency of the processor. On Cortex-m0, the entry latency is 16 cycles, and I presume the return latency is similar (pushing/popping of the exception frame). Say this adds up to 30-odd cycles or so, plus any Irq code inside the IRQ handler. I think its reasonable to expect every I2c Irq mitigated can save up to 60-100 cpu cycles easily.. Multiply that number by the amount of bytes for the actual CPU time is saved. With CPU just running 16MHz I think its a considerable amount of time.

author: Siwastaja
author_rank: Super Contributor
timestamp: June 22, 2025, 08:53:57 am
content: Well yeah, obviously, if you are running out of DMA channels, then it seems a good tradeoff to use a single one and swap the direction mid-transaction. In the world of microcontrollers and their peripheral, there really is no generic answers and solutions. This is also exactly why abstractions tend to fail; problems are solved on case-by-case basis.I like the approach of nRF52 which integrates DMA on every peripheral and removes the need of manual channel mapping (and constraints resulting in from limitations in such mapping). They call it EasyDMA for a very good reason: no one wants to even think about polling for status registers or interrupting on them, when you can just write your destination variable (or array) address to one of the peripheral registers and enable it, and that's it.

author: cv007
author_rank: Frequent Contributor
timestamp: June 22, 2025, 10:13:57 am
content: QuoteI just looked at the code I had for an nrf52, and although I used blocking code, the dma was used and I ended up with read/write/writeRead functions that simply setup the whole transaction via the easy dma along with the shorts setup and simply waited for a stop. In that case, it may have been no advantage to using dma for what I was doing but it certainly was easy to use with no downside. I don't remember what it took to do without using easy dma, but if I remember right you ended up in a different chapter in the user manual- same peripheral, but different. Something like that. I wish some manufacturers would take a look at some of these mcu's that seem to make sense and have some good ideas.As an example of my xfer_t array usage which allows two writes for example (such as writing to alarm registers)-Code:template<unsigned N>auto blockWrite(u8 regAddr, u8(&v)){//can put on stack, as we block until finishedI2c::xfer_t xfers{{ I2c::WRITE, &regAddr, 1 }, //register pointer write{ I2c::WRITE, v, N },{ I2c::END, 0, 0 }};return i2c.xferWait(xfers, ADDRESS, speed_);}and other functions can use this, such as-auto alarm1(u8(&v)){ return blockWrite(ALARM1_REG, v); }Of course this can easily be done in other ways, but you will most likely combine/assemble the passed in registers with the index value that precedes it into a single write. In this case it becomes handy to simply put it all in one transaction. For the stm32, one has to 'look ahead' to see if the next transfer is the same type and set the reload bit if so to keep the transfer type going without a need for a restart. A restart of course would not work for two writes as this device will then treat the first byte of the next write as an index register write once again. I'm sure one could do the same with the HAL driver, but will most likely not be straight forward to write/read as you will most likely end up monkeying around with the reload bit on your own.