mirror of
https://github.com/tinygo-org/tinygo.git
synced 2026-08-17 03:03:27 +00:00
machine: make sure DMA buffers do not escape unnecessarily
Writing the pointer of a buffer to memory-mapped I/O will normally cause it to escape, which forces the compiler to heap-allocate the buffer. But we do know how long the value stays alive, so we can tell the compiler to keep it alive exactly until it is not needed anymore - and tell it to not treat the pointer-to-uintptr cast as escaping.
This commit is contained in:
committed by
Ron Evans
parent
5ae8fd1f6f
commit
b203314c2f
@@ -27,6 +27,8 @@ func (b *builder) defineIntrinsicFunction() {
|
|||||||
b.createStackSaveImpl()
|
b.createStackSaveImpl()
|
||||||
case name == "runtime.KeepAlive":
|
case name == "runtime.KeepAlive":
|
||||||
b.createKeepAliveImpl()
|
b.createKeepAliveImpl()
|
||||||
|
case name == "machine.keepAliveNoEscape":
|
||||||
|
b.createMachineKeepAliveImpl()
|
||||||
case strings.HasPrefix(name, "runtime/volatile.Load"):
|
case strings.HasPrefix(name, "runtime/volatile.Load"):
|
||||||
b.createVolatileLoad()
|
b.createVolatileLoad()
|
||||||
case strings.HasPrefix(name, "runtime/volatile.Store"):
|
case strings.HasPrefix(name, "runtime/volatile.Store"):
|
||||||
@@ -144,6 +146,20 @@ func (b *builder) createAbiEscapeImpl() {
|
|||||||
b.CreateRet(result)
|
b.CreateRet(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implement machine.keepAliveNoEscape, which makes sure the compiler keeps the
|
||||||
|
// pointer parameter alive until this point (for GC).
|
||||||
|
func (b *builder) createMachineKeepAliveImpl() {
|
||||||
|
b.createFunctionStart(true)
|
||||||
|
pointerValue := b.getValue(b.fn.Params[0], getPos(b.fn))
|
||||||
|
|
||||||
|
// See createKeepAliveImpl for details.
|
||||||
|
asmType := llvm.FunctionType(b.ctx.VoidType(), []llvm.Type{b.dataPtrType}, false)
|
||||||
|
asmFn := llvm.InlineAsm(asmType, "", "r", true, false, 0, false)
|
||||||
|
b.createCall(asmType, asmFn, []llvm.Value{pointerValue}, "")
|
||||||
|
|
||||||
|
b.CreateRetVoid()
|
||||||
|
}
|
||||||
|
|
||||||
var mathToLLVMMapping = map[string]string{
|
var mathToLLVMMapping = map[string]string{
|
||||||
"math.Ceil": "llvm.ceil.f64",
|
"math.Ceil": "llvm.ceil.f64",
|
||||||
"math.Exp": "llvm.exp.f64",
|
"math.Exp": "llvm.exp.f64",
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ func (c *compilerContext) getFunction(fn *ssa.Function) (llvm.Type, llvm.Value)
|
|||||||
llvmFn.AddFunctionAttr(c.ctx.CreateEnumAttribute(llvm.AttributeKindID("noreturn"), 0))
|
llvmFn.AddFunctionAttr(c.ctx.CreateEnumAttribute(llvm.AttributeKindID("noreturn"), 0))
|
||||||
case "internal/abi.NoEscape":
|
case "internal/abi.NoEscape":
|
||||||
llvmFn.AddAttributeAtIndex(1, c.ctx.CreateEnumAttribute(llvm.AttributeKindID("nocapture"), 0))
|
llvmFn.AddAttributeAtIndex(1, c.ctx.CreateEnumAttribute(llvm.AttributeKindID("nocapture"), 0))
|
||||||
|
case "machine.keepAliveNoEscape", "machine.unsafeNoEscape":
|
||||||
|
llvmFn.AddAttributeAtIndex(1, c.ctx.CreateEnumAttribute(llvm.AttributeKindID("nocapture"), 0))
|
||||||
case "runtime.alloc":
|
case "runtime.alloc":
|
||||||
// Tell the optimizer that runtime.alloc is an allocator, meaning that it
|
// Tell the optimizer that runtime.alloc is an allocator, meaning that it
|
||||||
// returns values that are never null and never alias to an existing value.
|
// returns values that are never null and never alias to an existing value.
|
||||||
|
|||||||
+31
-1
@@ -1,6 +1,9 @@
|
|||||||
package machine
|
package machine
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrTimeoutRNG = errors.New("machine: RNG Timeout")
|
ErrTimeoutRNG = errors.New("machine: RNG Timeout")
|
||||||
@@ -62,3 +65,30 @@ func (p Pin) Low() {
|
|||||||
type ADC struct {
|
type ADC struct {
|
||||||
Pin Pin
|
Pin Pin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert the pointer to a uintptr, to be used for memory I/O (DMA for
|
||||||
|
// example). It also means the pointer is "gone" as far as the compiler is
|
||||||
|
// concerned, and a GC cycle might deallocate the object. To prevent this from
|
||||||
|
// happening, also call keepAliveNoEscape at a point after the address isn't
|
||||||
|
// accessed anymore by the hardware.
|
||||||
|
// The only exception is if the pointer is accessed later in a volatile way
|
||||||
|
// (volatile read/write), which also forces the value to stay alive until that
|
||||||
|
// point.
|
||||||
|
//
|
||||||
|
// This function is treated specially by the compiler to mark the 'ptr'
|
||||||
|
// parameter as not escaping.
|
||||||
|
//
|
||||||
|
// TODO: this function should eventually be replaced with the proposed ptrtoaddr
|
||||||
|
// instruction in LLVM. See:
|
||||||
|
// https://discourse.llvm.org/t/clarifiying-the-semantics-of-ptrtoint/83987/10
|
||||||
|
// https://github.com/llvm/llvm-project/pull/139357
|
||||||
|
func unsafeNoEscape(ptr unsafe.Pointer) uintptr {
|
||||||
|
return uintptr(ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the given pointer stays alive until this point. This is similar to
|
||||||
|
// runtime.KeepAlive, with the difference that it won't let the pointer escape.
|
||||||
|
// This is typically used together with unsafeNoEscape.
|
||||||
|
//
|
||||||
|
// This is a compiler intrinsic.
|
||||||
|
func keepAliveNoEscape(ptr unsafe.Pointer)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func (i2c *I2C) Tx(addr uint16, w, r []byte) (err error) {
|
|||||||
|
|
||||||
// Configure for a single shot to perform both write and read (as applicable)
|
// Configure for a single shot to perform both write and read (as applicable)
|
||||||
if len(w) != 0 {
|
if len(w) != 0 {
|
||||||
i2c.Bus.TXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&w[0]))))
|
i2c.Bus.TXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(w)))))
|
||||||
i2c.Bus.TXD.MAXCNT.Set(uint32(len(w)))
|
i2c.Bus.TXD.MAXCNT.Set(uint32(len(w)))
|
||||||
|
|
||||||
// If no read, immediately signal stop after TX
|
// If no read, immediately signal stop after TX
|
||||||
@@ -58,7 +58,7 @@ func (i2c *I2C) Tx(addr uint16, w, r []byte) (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(r) != 0 {
|
if len(r) != 0 {
|
||||||
i2c.Bus.RXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&r[0]))))
|
i2c.Bus.RXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(r)))))
|
||||||
i2c.Bus.RXD.MAXCNT.Set(uint32(len(r)))
|
i2c.Bus.RXD.MAXCNT.Set(uint32(len(r)))
|
||||||
|
|
||||||
// Auto-start Rx after Tx and Stop after Rx
|
// Auto-start Rx after Tx and Stop after Rx
|
||||||
@@ -89,6 +89,11 @@ func (i2c *I2C) Tx(addr uint16, w, r []byte) (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the w and r buffers stay alive until this point, so they won't
|
||||||
|
// be garbage collected while the buffers are used by the hardware.
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(w)))
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(r)))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ func (i2c *I2C) Listen(addr uint8) error {
|
|||||||
//
|
//
|
||||||
// For request events, the caller MUST call `Reply` to avoid hanging the i2c bus indefinitely.
|
// For request events, the caller MUST call `Reply` to avoid hanging the i2c bus indefinitely.
|
||||||
func (i2c *I2C) WaitForEvent(buf []byte) (evt I2CTargetEvent, count int, err error) {
|
func (i2c *I2C) WaitForEvent(buf []byte) (evt I2CTargetEvent, count int, err error) {
|
||||||
i2c.BusT.RXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&buf[0]))))
|
i2c.BusT.RXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(buf)))))
|
||||||
i2c.BusT.RXD.MAXCNT.Set(uint32(len(buf)))
|
i2c.BusT.RXD.MAXCNT.Set(uint32(len(buf)))
|
||||||
|
|
||||||
i2c.BusT.TASKS_PREPARERX.Set(nrf.TWIS_TASKS_PREPARERX_TASKS_PREPARERX_Trigger)
|
i2c.BusT.TASKS_PREPARERX.Set(nrf.TWIS_TASKS_PREPARERX_TASKS_PREPARERX_Trigger)
|
||||||
@@ -134,6 +139,10 @@ func (i2c *I2C) WaitForEvent(buf []byte) (evt I2CTargetEvent, count int, err err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure buf stays alive until this point, so it won't be garbage
|
||||||
|
// collected while it is used by the hardware.
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(buf)))
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
evt = I2CFinish
|
evt = I2CFinish
|
||||||
err = nil
|
err = nil
|
||||||
@@ -163,7 +172,7 @@ func (i2c *I2C) WaitForEvent(buf []byte) (evt I2CTargetEvent, count int, err err
|
|||||||
|
|
||||||
// Reply supplies the response data the controller.
|
// Reply supplies the response data the controller.
|
||||||
func (i2c *I2C) Reply(buf []byte) error {
|
func (i2c *I2C) Reply(buf []byte) error {
|
||||||
i2c.BusT.TXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&buf[0]))))
|
i2c.BusT.TXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(buf)))))
|
||||||
i2c.BusT.TXD.MAXCNT.Set(uint32(len(buf)))
|
i2c.BusT.TXD.MAXCNT.Set(uint32(len(buf)))
|
||||||
|
|
||||||
i2c.BusT.EVENTS_STOPPED.Set(0)
|
i2c.BusT.EVENTS_STOPPED.Set(0)
|
||||||
@@ -180,6 +189,10 @@ func (i2c *I2C) Reply(buf []byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the buffer stays alive until this point, so it won't be garbage
|
||||||
|
// collected while it is used by the hardware.
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(buf)))
|
||||||
|
|
||||||
i2c.BusT.EVENTS_STOPPED.Set(0)
|
i2c.BusT.EVENTS_STOPPED.Set(0)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ func (a *ADC) Get() uint16 {
|
|||||||
nrf.SAADC.CH[0].PSELP.Set(pwmPin)
|
nrf.SAADC.CH[0].PSELP.Set(pwmPin)
|
||||||
|
|
||||||
// Destination for sample result.
|
// Destination for sample result.
|
||||||
nrf.SAADC.RESULT.PTR.Set(uint32(uintptr(unsafe.Pointer(&rawValue))))
|
// Note: rawValue doesn't need to be kept alive for the GC, since the
|
||||||
|
// volatile read later will force it to stay alive.
|
||||||
|
nrf.SAADC.RESULT.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(&rawValue))))
|
||||||
nrf.SAADC.RESULT.MAXCNT.Set(1) // One sample
|
nrf.SAADC.RESULT.MAXCNT.Set(1) // One sample
|
||||||
|
|
||||||
// Start tasks.
|
// Start tasks.
|
||||||
@@ -312,7 +314,7 @@ func (spi *SPI) Tx(w, r []byte) error {
|
|||||||
if nr > spiMaxBufferSize {
|
if nr > spiMaxBufferSize {
|
||||||
nr = spiMaxBufferSize
|
nr = spiMaxBufferSize
|
||||||
}
|
}
|
||||||
spi.Bus.RXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&r[0]))))
|
spi.Bus.RXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(r)))))
|
||||||
r = r[nr:]
|
r = r[nr:]
|
||||||
}
|
}
|
||||||
spi.Bus.RXD.MAXCNT.Set(nr)
|
spi.Bus.RXD.MAXCNT.Set(nr)
|
||||||
@@ -323,7 +325,7 @@ func (spi *SPI) Tx(w, r []byte) error {
|
|||||||
if nw > spiMaxBufferSize {
|
if nw > spiMaxBufferSize {
|
||||||
nw = spiMaxBufferSize
|
nw = spiMaxBufferSize
|
||||||
}
|
}
|
||||||
spi.Bus.TXD.PTR.Set(uint32(uintptr(unsafe.Pointer(&w[0]))))
|
spi.Bus.TXD.PTR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(w)))))
|
||||||
w = w[nw:]
|
w = w[nw:]
|
||||||
}
|
}
|
||||||
spi.Bus.TXD.MAXCNT.Set(nw)
|
spi.Bus.TXD.MAXCNT.Set(nw)
|
||||||
@@ -337,6 +339,11 @@ func (spi *SPI) Tx(w, r []byte) error {
|
|||||||
spi.Bus.EVENTS_END.Set(0)
|
spi.Bus.EVENTS_END.Set(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the w and r buffers stay alive for the GC until this point,
|
||||||
|
// since they are used by the hardware but not otherwise visible.
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(r)))
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(w)))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func (spi *SPI) tx(tx []byte) error {
|
|||||||
// - set data size to single bytes
|
// - set data size to single bytes
|
||||||
// - set the DREQ so that the DMA will fill the SPI FIFO as needed
|
// - set the DREQ so that the DMA will fill the SPI FIFO as needed
|
||||||
// - start the transfer
|
// - start the transfer
|
||||||
ch.READ_ADDR.Set(uint32(uintptr(unsafe.Pointer(&tx[0]))))
|
ch.READ_ADDR.Set(uint32(unsafeNoEscape(unsafe.Pointer(unsafe.SliceData(tx)))))
|
||||||
ch.WRITE_ADDR.Set(uint32(uintptr(unsafe.Pointer(&spi.Bus.SSPDR))))
|
ch.WRITE_ADDR.Set(uint32(uintptr(unsafe.Pointer(&spi.Bus.SSPDR))))
|
||||||
ch.TRANS_COUNT.Set(uint32(len(tx)))
|
ch.TRANS_COUNT.Set(uint32(len(tx)))
|
||||||
ch.CTRL_TRIG.Set(rp.DMA_CH0_CTRL_TRIG_INCR_READ |
|
ch.CTRL_TRIG.Set(rp.DMA_CH0_CTRL_TRIG_INCR_READ |
|
||||||
@@ -310,6 +310,11 @@ func (spi *SPI) tx(tx []byte) error {
|
|||||||
for ch.CTRL_TRIG.Get()&rp.DMA_CH0_CTRL_TRIG_BUSY != 0 {
|
for ch.CTRL_TRIG.Get()&rp.DMA_CH0_CTRL_TRIG_BUSY != 0 {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the read buffer stays alive until this point (in the unlikely
|
||||||
|
// case the tx slice wasn't read after this function returns and a GC cycle
|
||||||
|
// happened inbetween).
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(unsafe.SliceData(tx)))
|
||||||
|
|
||||||
// We didn't read any result values, which means the RX FIFO has likely
|
// We didn't read any result values, which means the RX FIFO has likely
|
||||||
// overflown. We have to clean up this mess now.
|
// overflown. We have to clean up this mess now.
|
||||||
|
|
||||||
|
|||||||
Vendored
+36
-8
@@ -1,11 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime/volatile"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
n1 := 5
|
n1 := 5
|
||||||
derefInt(&n1)
|
derefInt(&n1)
|
||||||
|
|
||||||
// This should eventually be modified to not escape.
|
// This should eventually be modified to not escape.
|
||||||
n2 := 6 // OUT: object allocated on the heap: escapes at line 9
|
n2 := 6 // OUT: object allocated on the heap: escapes at line 14
|
||||||
returnIntPtr(&n2)
|
returnIntPtr(&n2)
|
||||||
|
|
||||||
s1 := make([]int, 3)
|
s1 := make([]int, 3)
|
||||||
@@ -15,7 +20,7 @@ func main() {
|
|||||||
readIntSlice(s2[:])
|
readIntSlice(s2[:])
|
||||||
|
|
||||||
// This should also be modified to not escape.
|
// This should also be modified to not escape.
|
||||||
s3 := make([]int, 3) // OUT: object allocated on the heap: escapes at line 19
|
s3 := make([]int, 3) // OUT: object allocated on the heap: escapes at line 24
|
||||||
returnIntSlice(s3)
|
returnIntSlice(s3)
|
||||||
|
|
||||||
useSlice(make([]int, getUnknownNumber())) // OUT: object allocated on the heap: size is not constant
|
useSlice(make([]int, getUnknownNumber())) // OUT: object allocated on the heap: size is not constant
|
||||||
@@ -23,14 +28,14 @@ func main() {
|
|||||||
s4 := make([]byte, 300) // OUT: object allocated on the heap: object size 300 exceeds maximum stack allocation size 256
|
s4 := make([]byte, 300) // OUT: object allocated on the heap: object size 300 exceeds maximum stack allocation size 256
|
||||||
readByteSlice(s4)
|
readByteSlice(s4)
|
||||||
|
|
||||||
s5 := make([]int, 4) // OUT: object allocated on the heap: escapes at line 27
|
s5 := make([]int, 4) // OUT: object allocated on the heap: escapes at line 32
|
||||||
_ = append(s5, 5)
|
_ = append(s5, 5)
|
||||||
|
|
||||||
s6 := make([]int, 3)
|
s6 := make([]int, 3)
|
||||||
s7 := []int{1, 2, 3}
|
s7 := []int{1, 2, 3}
|
||||||
copySlice(s6, s7)
|
copySlice(s6, s7)
|
||||||
|
|
||||||
c1 := getComplex128() // OUT: object allocated on the heap: escapes at line 34
|
c1 := getComplex128() // OUT: object allocated on the heap: escapes at line 39
|
||||||
useInterface(c1)
|
useInterface(c1)
|
||||||
|
|
||||||
n3 := 5
|
n3 := 5
|
||||||
@@ -38,13 +43,13 @@ func main() {
|
|||||||
return n3
|
return n3
|
||||||
}()
|
}()
|
||||||
|
|
||||||
callVariadic(3, 5, 8) // OUT: object allocated on the heap: escapes at line 41
|
callVariadic(3, 5, 8) // OUT: object allocated on the heap: escapes at line 46
|
||||||
|
|
||||||
s8 := []int{3, 5, 8} // OUT: object allocated on the heap: escapes at line 44
|
s8 := []int{3, 5, 8} // OUT: object allocated on the heap: escapes at line 49
|
||||||
callVariadic(s8...)
|
callVariadic(s8...)
|
||||||
|
|
||||||
n4 := 3 // OUT: object allocated on the heap: escapes at line 48
|
n4 := 3 // OUT: object allocated on the heap: escapes at line 53
|
||||||
n5 := 7 // OUT: object allocated on the heap: escapes at line 48
|
n5 := 7 // OUT: object allocated on the heap: escapes at line 53
|
||||||
func() {
|
func() {
|
||||||
n4 = n5
|
n4 = n5
|
||||||
}()
|
}()
|
||||||
@@ -58,6 +63,19 @@ func main() {
|
|||||||
var rbuf [5]rune
|
var rbuf [5]rune
|
||||||
s = string(rbuf[:])
|
s = string(rbuf[:])
|
||||||
println(s)
|
println(s)
|
||||||
|
|
||||||
|
// Unsafe usage of DMA buffers: the compiler thinks this buffer won't be
|
||||||
|
// used anymore after the volatile store.
|
||||||
|
var dmaBuf1 [4]byte
|
||||||
|
pseudoVolatile.Set(uint32(unsafeNoEscape(unsafe.Pointer(&dmaBuf1[0]))))
|
||||||
|
|
||||||
|
// Safe usage of DMA buffers: keep the buffer alive until it is no longer
|
||||||
|
// needed, but don't mark it as needing to be heap allocated. The compiler
|
||||||
|
// will keep the buffer stack allocated if possible.
|
||||||
|
var dmaBuf2 [4]byte
|
||||||
|
pseudoVolatile.Set(uint32(unsafeNoEscape(unsafe.Pointer(&dmaBuf2[0]))))
|
||||||
|
// ...use the buffer in the DMA peripheral
|
||||||
|
keepAliveNoEscape(unsafe.Pointer(&dmaBuf2[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefInt(x *int) int {
|
func derefInt(x *int) int {
|
||||||
@@ -93,3 +111,13 @@ func useInterface(interface{})
|
|||||||
func callVariadic(...int)
|
func callVariadic(...int)
|
||||||
|
|
||||||
func useSlice([]int)
|
func useSlice([]int)
|
||||||
|
|
||||||
|
// See the function with the same name in the machine package.
|
||||||
|
//
|
||||||
|
//go:linkname unsafeNoEscape machine.unsafeNoEscape
|
||||||
|
func unsafeNoEscape(ptr unsafe.Pointer) uintptr
|
||||||
|
|
||||||
|
//go:linkname keepAliveNoEscape machine.keepAliveNoEscape
|
||||||
|
func keepAliveNoEscape(ptr unsafe.Pointer)
|
||||||
|
|
||||||
|
var pseudoVolatile volatile.Register32
|
||||||
|
|||||||
Reference in New Issue
Block a user